"""Meta-repo entry file shape per §2.1. One markdown file per RFC under rfcs/.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 `.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