Files
rfc-app/backend/app/entry.py
T
Ben Stull a255429e57 Post-v1: per-RFC model availability (UX half) folded into §6.6
First §19.2 candidate settled after v1. The heavier per-RFC-model
topic subdivided into UX (this) and credential delegation + funder
role (still §19.2). New §6.6 carries the rule: an optional `models:`
frontmatter field on the meta-repo RFC entry; absent inherits the
operator universe, populated narrows the picker to the intersection
with provisioned providers, `[]` opts the RFC out of AI entirely.
The first resolved entry is the RFC default. §18's ENABLED_MODELS is
reframed as the operator universe.

Code: migration 009 adds nullable cached_rfcs.models_json (NULL ≠ []
is load-bearing); entry.py grows the optional field with absent-vs-
empty round-tripping in parse/serialize; new models_resolver module
holds the rule; api_branches replaces /api/models with the slug-aware
/api/rfcs/{slug}/models and threads the chat + reask paths through
the resolver; api_prs §10.2 uses the resolver and extends the stub
fallback to the opt-out case; frontend passes slug to listModels.

Tests 106/106 green (96 prior + 10 in test_per_rfc_models.py). No
behavioral change for entries without `models:` — operator universe
preserved as default.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-25 05:42:15 -07:00

120 lines
3.9 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
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
body: str = ""
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")
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]
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,
body=body,
)
def serialize(entry: Entry) -> str:
"""Emit canonical entry file text — frontmatter then body."""
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
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