Files
rfc-app/backend/app/entry.py
T
Ben Stull f05ee59763 v0.47.0 — SLICE-1: metadata sidecars — storage + dual-read + migration tool
§22.4a SLICE-1 of docs/design/2026-06-06-configurable-collection-metadata.md
(§7.2). Entry metadata can live in a per-entry `<slug>.meta.yaml` sidecar with
the `.md` kept as pure prose (INV-2). Additive and non-breaking — with no
sidecars present every corpus stays on the legacy frontmatter path,
byte-identical (N=1 unchanged).

- Dual-read (app/metadata.py `read_entry`) — sidecar-else-legacy-frontmatter,
  identical records (INV-6); unknown/forward-compat keys ride along through
  parse→serialize and migration (INV-7, `Entry.extra`). A degenerate sidecar
  (malformed/empty/slug-less) never drops the entry — slug backstopped from the
  filename stem, flagged not lost (INV-3).
- Migration tool (`metadata.migrate_collection`) — idempotent, one ChangeFiles
  commit per collection (new `gitea.change_files`). Tested as a function; its
  Owner-gated operator trigger is DEFERRED to SLICE-4 (write paths must become
  sidecar-aware first — see the design's SLICE-4 note + INV-8). No production
  trigger ships here, so no corpus is rewritten.
- Malformed flag — migration 033 adds `cached_rfcs.metadata_malformed`
  (additive); the corpus mirror derives it; catalog list + entry-detail APIs
  surface `metadata_malformed`.
- INV-7 at graduation — graduation now carries `Entry.extra` through the rebuild
  instead of dropping forward-compat keys.

Gate: backend 575 passed (28 new: test_metadata / _migration / _cache +
graduation extra-preservation). Frontend untouched. CHANGELOG 0.47.0 +
upgrade-steps; VERSION + frontend/package.json -> 0.47.0.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 08:24:27 -07:00

194 lines
7.3 KiB
Python

"""Meta-repo entry file shape per §2.1.
One markdown file per RFC under rfcs/<slug>.md, frontmatter on top, body
below. The frontmatter carries the canonical RFC state — id, repo,
owners, arbiters, graduation timestamps — and the body holds the pitch
(for super-drafts) or is empty (for graduated entries per §13.3 step 3).
This module contains the parser, the serializer, and a small validator
for the frontmatter shape. The parser is intentionally lenient about
unknown keys — future fields land in frontmatter without breaking older
readers.
"""
from __future__ import annotations
import re
from dataclasses import dataclass, field
from datetime import date
from typing import Any
import yaml
FRONTMATTER_RE = re.compile(r"^---\n(.*?)\n---\n?(.*)$", re.DOTALL)
SLUG_RE = re.compile(r"^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$")
_ABSENT = object()
@dataclass
class Entry:
slug: str
title: str
state: str = "super-draft" # super-draft | active | withdrawn | retired (§3, §13.7)
id: str | None = None # 'RFC-NNNN' or None
repo: str | None = None
proposed_by: str = ""
proposed_at: str = "" # ISO date
graduated_at: str | None = None
graduated_by: str | None = None
owners: list[str] = field(default_factory=list)
arbiters: list[str] = field(default_factory=list)
tags: list[str] = field(default_factory=list)
# §6.6: per-RFC model availability. None means the key is absent
# from frontmatter (inherit the operator universe). An empty list
# means an explicit opt-out from AI on this RFC. A populated list
# narrows the picker to its intersection with the operator universe.
models: list[str] | None = None
# §6.7: optional gitea_login naming the user whose registered API
# credentials pay for AI calls on this RFC. None means absent —
# operator credentials per §18 are used. The binding is inert until
# the named user has a funder_consents row (the hybrid two-key rule).
funder: str | None = None
# §22.4c: an `active` entry that landed without a human review gate
# carries unreviewed=True until an owner clears it. Orthogonal to
# `state`; only meaningful for active entries. reviewed_at/reviewed_by
# are the provenance of the clear, paralleling graduated_at/by.
unreviewed: bool = False
reviewed_at: str | None = None
reviewed_by: str | None = None
body: str = ""
# §22.4a (configurable collection metadata, SLICE-1): frontmatter / sidecar
# keys outside the known set above are preserved here verbatim so they ride
# along untouched through a parse→serialize round-trip and the
# frontmatter→sidecar migration (INV-7). Includes future collection-`fields:`
# schema values, which the engine does not interpret.
extra: dict[str, Any] = field(default_factory=dict)
# Frontmatter keys the Entry models explicitly; everything else is `extra`.
KNOWN_KEYS = {
"slug", "title", "state", "id", "repo", "proposed_by", "proposed_at",
"graduated_at", "graduated_by", "owners", "arbiters", "tags", "models",
"funder", "unreviewed", "reviewed_at", "reviewed_by",
}
def parse(text: str) -> Entry:
match = FRONTMATTER_RE.match(text)
if not match:
raise ValueError("Entry file missing frontmatter")
fm = yaml.safe_load(match.group(1)) or {}
body = match.group(2).lstrip("\n")
return from_frontmatter(fm, body)
def from_frontmatter(fm: dict[str, Any], body: str = "") -> Entry:
"""Build an Entry from an already-parsed metadata mapping + body.
Shared by `parse()` (legacy `.md` frontmatter) and the SLICE-1 dual-read
sidecar path (`metadata.read_entry`), so both produce identical records
(INV-6). `fm` keys outside `KNOWN_KEYS` are preserved on `Entry.extra`.
"""
raw_models = fm.get("models", _ABSENT)
if raw_models is _ABSENT or raw_models is None:
models: list[str] | None = None
else:
models = [str(m) for m in raw_models]
raw_funder = fm.get("funder")
funder = str(raw_funder).strip() if raw_funder else None
unreviewed = bool(fm.get("unreviewed") or False)
extra = {k: v for k, v in fm.items() if k not in KNOWN_KEYS}
return Entry(
slug=str(fm.get("slug") or ""),
title=str(fm.get("title") or ""),
state=str(fm.get("state") or "super-draft"),
id=fm.get("id") or None,
repo=fm.get("repo") or None,
proposed_by=str(fm.get("proposed_by") or ""),
proposed_at=str(fm.get("proposed_at") or ""),
graduated_at=fm.get("graduated_at"),
graduated_by=fm.get("graduated_by"),
owners=list(fm.get("owners") or []),
arbiters=list(fm.get("arbiters") or []),
tags=list(fm.get("tags") or []),
models=models,
funder=funder,
unreviewed=unreviewed,
reviewed_at=fm.get("reviewed_at") or None,
reviewed_by=fm.get("reviewed_by") or None,
body=body,
extra=extra,
)
def to_frontmatter_dict(entry: Entry) -> dict[str, Any]:
"""The canonical ordered metadata mapping for an entry.
Shared by `serialize()` (which wraps it in `---` fences over the body) and
the SLICE-1 sidecar writer (`metadata.sidecar_yaml`, which emits the same
mapping as a standalone `<slug>.meta.yaml`). Known keys first in canonical
order, then `extra` (INV-7).
"""
fm: dict[str, Any] = {
"slug": entry.slug,
"title": entry.title,
"state": entry.state,
"id": entry.id,
"repo": entry.repo,
"proposed_by": entry.proposed_by,
"proposed_at": entry.proposed_at,
"graduated_at": entry.graduated_at,
"graduated_by": entry.graduated_by,
"owners": entry.owners,
"arbiters": entry.arbiters,
"tags": entry.tags,
}
# §6.6: emit `models:` only when set. Absent in the frontmatter
# is meaningfully different from `models: []` per §6.6.
if entry.models is not None:
fm["models"] = entry.models
# §6.7: emit `funder:` only when set. Empty / None means absent —
# operator credentials are used. There is no "explicit opt-out"
# second meaning here as with `models:`; one set of semantics.
if entry.funder:
fm["funder"] = entry.funder
# §22.4c: emit unreviewed only when True (a super-draft / reviewed
# active entry leaves the key absent → frontmatter stays minimal).
if entry.unreviewed:
fm["unreviewed"] = True
if entry.reviewed_at:
fm["reviewed_at"] = entry.reviewed_at
if entry.reviewed_by:
fm["reviewed_by"] = entry.reviewed_by
# INV-7: forward-compat / unknown keys ride along after the known ones.
for k, v in entry.extra.items():
if k not in fm:
fm[k] = v
return fm
def serialize(entry: Entry) -> str:
"""Emit canonical entry file text — frontmatter then body."""
fm = to_frontmatter_dict(entry)
yaml_text = yaml.safe_dump(fm, sort_keys=False, default_flow_style=False).rstrip()
body = entry.body.lstrip("\n")
if body:
return f"---\n{yaml_text}\n---\n\n{body}\n"
return f"---\n{yaml_text}\n---\n"
def slugify(title: str) -> str:
"""Deterministic kebab-case per §9.1."""
s = title.lower().strip()
s = re.sub(r"[^a-z0-9]+", "-", s)
return s.strip("-")
def today() -> str:
return date.today().isoformat()
def is_valid_slug(slug: str) -> bool:
return bool(SLUG_RE.match(slug)) and len(slug) <= 80