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>
This commit is contained in:
Ben Stull
2026-05-25 05:42:15 -07:00
parent 36635049c7
commit a255429e57
11 changed files with 755 additions and 57 deletions
+27 -15
View File
@@ -29,7 +29,7 @@ from fastapi import APIRouter, HTTPException, Request
from fastapi.responses import StreamingResponse
from pydantic import BaseModel, Field
from . import auth, cache, chat as chat_layer, db, entry as entry_mod
from . import auth, cache, chat as chat_layer, db, entry as entry_mod, models_resolver
from .bot import Bot
from .config import Config
from .gitea import Gitea, GiteaError
@@ -111,21 +111,23 @@ def make_router(
) -> APIRouter:
router = APIRouter()
default_model = next(iter(providers)) if providers else ""
# -------------------------------------------------------------------
# §17: model picker (the prototype carryover, scoped here since
# Slice 2 is where chat lights up).
# §6.6 / §17: per-RFC model picker. The option list and default are
# resolved per RFC by intersecting the meta-repo entry's optional
# `models:` frontmatter with the operator's provisioned universe.
# An empty resolved list means the RFC has opted out of AI per
# §6.6 and the picker surfaces no options.
# -------------------------------------------------------------------
@router.get("/api/models")
async def list_models() -> dict[str, Any]:
@router.get("/api/rfcs/{slug}/models")
async def list_models_for_rfc(slug: str) -> dict[str, Any]:
resolved = models_resolver.resolve_models_for_rfc(slug, providers)
return {
"models": [
{"id": key, "name": p.display_name}
for key, p in providers.items()
{"id": key, "name": providers[key].display_name}
for key in resolved
],
"default": default_model,
"default": resolved[0] if resolved else "",
}
# -------------------------------------------------------------------
@@ -560,15 +562,21 @@ def make_router(
thread_id = row["thread_id"]
if thread_id is None:
raise HTTPException(409, "Change has no originating thread")
if not providers:
# §6.6: refuse cleanly if the RFC's resolved model list is empty —
# either the operator has no providers, or the RFC opted out
# (`models: []`), or the entry names only models the operator
# no longer provisions. Same shape; same honest refusal.
resolved = models_resolver.resolve_models_for_rfc(slug, providers)
if not resolved:
raise HTTPException(503, "No AI providers configured")
reask_model = resolved[0]
owner, repo = _repo_for(rfc, branch)
path = _file_path_for(rfc, branch)
fetched = await gitea.read_file(owner, repo, path, ref=branch)
body_text = _extract_body(rfc, fetched[0], branch) if fetched else ""
provider = next(iter(providers.values()))
provider = providers[reask_model]
system = chat_layer.build_system_prompt(title=rfc["title"], body=body_text)
history = chat_layer.build_history(thread_id)
reask_prompt = (
@@ -581,7 +589,7 @@ def make_router(
thread_id=thread_id, author_user_id=viewer.user_id, text=reask_prompt, quote=None
)
assistant_id = chat_layer.append_assistant_placeholder(
thread_id=thread_id, model_id=default_model
thread_id=thread_id, model_id=reask_model
)
text = provider.send(system, history + [{"role": "user", "content": reask_prompt}])
@@ -892,9 +900,13 @@ def make_router(
thread = _require_thread(slug, branch, thread_id)
if not _can_read_branch(slug, branch, viewer):
raise HTTPException(403, "Branch is private")
if not providers:
# §6.6: option list and default come from the per-RFC resolved set,
# not the operator universe. body.model is honored only if it sits
# inside the resolved set; otherwise we fall to the RFC default.
resolved = models_resolver.resolve_models_for_rfc(slug, providers)
if not resolved:
raise HTTPException(503, "No AI providers configured")
model_key = body.model if body.model in providers else default_model
model_key = body.model if body.model in resolved else resolved[0]
provider = providers[model_key]
# Fetch the live branch body so the prompt is anchored to
+10 -9
View File
@@ -23,7 +23,7 @@ from typing import Any
from fastapi import APIRouter, HTTPException, Request
from pydantic import BaseModel, Field
from . import auth, cache, chat as chat_layer, db, entry as entry_mod
from . import auth, cache, chat as chat_layer, db, entry as entry_mod, models_resolver
from .bot import Bot
from .config import Config
from .gitea import Gitea, GiteaError
@@ -68,13 +68,13 @@ def make_router(
) -> APIRouter:
router = APIRouter()
default_model = next(iter(providers)) if providers else ""
# -------------------------------------------------------------------
# §10.2: AI-drafted PR title and description.
# Returned ahead of submit so the modal renders with prefilled values
# the contributor can edit. The contributor's gesture is what
# produces the open-pr call; the draft is just a starting point.
# Per §6.6 the model used is the RFC's resolved default; an empty
# resolved list falls back to the deterministic stub.
# -------------------------------------------------------------------
@router.post("/api/rfcs/{slug}/branches/{branch:path}/pr-draft")
@@ -90,9 +90,10 @@ def make_router(
if not branch_fetched:
raise HTTPException(404, f"Branch {path} not found")
chat_messages = _branch_chat_excerpt(slug, branch)
rfc_default_model = models_resolver.default_model_for_rfc(slug, providers)
title, description = _draft_with_provider(
providers=providers,
default_model=default_model,
default_model=rfc_default_model,
rfc_title=rfc["title"],
main_body=_extract_body(rfc, (main_fetched or ("", ""))[0]),
branch_body=_extract_body(rfc, branch_fetched[0]),
@@ -795,12 +796,12 @@ def _draft_with_provider(
"""Per §10.2: AI-drafted title (spec voice) and description (24
sentences pulling from chat).
When no provider is configured we fall back to a deterministic
stub — the surface still works; the contributor just edits the
text. The fallback also matches the test seam where Slice 3
integration tests don't always inject a fake provider.
When no provider is configured — or per §6.6 the RFC's resolved
list is empty (operator universe empty, frontmatter opt-out, or
intersection empty) — we fall back to a deterministic stub. The
surface still works; the contributor edits the text.
"""
if not providers:
if not providers or not default_model:
return _stub_draft(rfc_title=rfc_title, main_body=main_body, branch_body=branch_body)
provider = providers.get(default_model) or next(iter(providers.values()))
system = (
+7 -2
View File
@@ -76,13 +76,16 @@ async def refresh_meta_repo(config: Config, gitea: Gitea) -> None:
def _upsert_cached_rfc(entry: entry_mod.Entry, body_sha: str) -> None:
# §6.6: models_json stays NULL when the frontmatter key is absent
# (inherit operator universe) and '[]' for the explicit opt-out.
models_json = json.dumps(entry.models) if entry.models is not None else None
db.conn().execute(
"""
INSERT INTO cached_rfcs
(slug, title, state, rfc_id, repo, proposed_by, proposed_at,
graduated_at, graduated_by, owners_json, arbiters_json, tags_json,
body, body_sha, last_entry_commit_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, datetime('now'), datetime('now'))
models_json, body, body_sha, last_entry_commit_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, datetime('now'), datetime('now'))
ON CONFLICT(slug) DO UPDATE SET
title = excluded.title,
state = excluded.state,
@@ -95,6 +98,7 @@ def _upsert_cached_rfc(entry: entry_mod.Entry, body_sha: str) -> None:
owners_json = excluded.owners_json,
arbiters_json = excluded.arbiters_json,
tags_json = excluded.tags_json,
models_json = excluded.models_json,
body = excluded.body,
body_sha = excluded.body_sha,
last_entry_commit_at = datetime('now'),
@@ -113,6 +117,7 @@ def _upsert_cached_rfc(entry: entry_mod.Entry, body_sha: str) -> None:
json.dumps(entry.owners),
json.dumps(entry.arbiters),
json.dumps(entry.tags),
models_json,
entry.body,
body_sha,
),
+17
View File
@@ -23,6 +23,8 @@ 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:
@@ -38,6 +40,11 @@ class Entry:
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 = ""
@@ -47,6 +54,11 @@ def parse(text: str) -> Entry:
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 ""),
@@ -60,6 +72,7 @@ def parse(text: str) -> Entry:
owners=list(fm.get("owners") or []),
arbiters=list(fm.get("arbiters") or []),
tags=list(fm.get("tags") or []),
models=models,
body=body,
)
@@ -80,6 +93,10 @@ def serialize(entry: Entry) -> str:
"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:
+68
View File
@@ -0,0 +1,68 @@
"""§6.6 per-RFC model availability — the resolver.
The meta-repo entry's optional `models:` frontmatter and the operator's
provisioned providers (the §18 `ENABLED_MODELS` universe) combine into a
single resolved list per RFC. Every AI surface picks against that list:
the §8.12 chat picker, the §10.2 PR-draft, the §8.13 flag-resolution
invocation, the §9.1 tag suggestions when graduation is in scope.
Rule, in one place:
- Cache row's `models_json` is NULL → the field is absent on the entry.
Resolved list = the operator's provisioned universe.
- Cache row's `models_json` is a JSON array → the field is set on the
entry. Resolved list = intersection of the array with the operator's
provisioned universe, preserving the entry's stated order.
The empty case folds in naturally: an entry with `models: []` yields an
empty intersection, and an entry whose listed models are no longer
provisioned by the operator also yields an empty intersection. Callers
treat both the same surfaces refuse cleanly per §6.6.
The function is slug-aware and provider-aware; it does not depend on
the FastAPI request lifecycle, which keeps it cheap to call inside any
endpoint that knows the slug and has the providers dict in scope.
"""
from __future__ import annotations
import json
from . import db
from .providers import BaseProvider
def resolve_models_for_rfc(
slug: str, providers: dict[str, BaseProvider]
) -> list[str]:
"""Return the per-RFC resolved model keys per §6.6.
The first entry is the RFC's default model. An empty list means
AI is unavailable on this RFC and callers refuse the AI surface.
"""
universe = list(providers.keys())
row = db.conn().execute(
"SELECT models_json FROM cached_rfcs WHERE slug = ?",
(slug,),
).fetchone()
if row is None or row["models_json"] is None:
return universe
try:
listed = [str(m) for m in json.loads(row["models_json"])]
except (json.JSONDecodeError, TypeError):
return universe
if not listed:
return []
return [m for m in listed if m in providers]
def default_model_for_rfc(
slug: str, providers: dict[str, BaseProvider]
) -> str:
"""The first entry in the resolved list, or empty string if none.
Callers that need a deterministic single choice §10.2's draft,
§9.1's tag suggestions — use this. An empty return signals the
refusal path per §6.6.
"""
resolved = resolve_models_for_rfc(slug, providers)
return resolved[0] if resolved else ""
@@ -0,0 +1,7 @@
-- §6.6: per-RFC model availability. The frontmatter field is
-- optional; NULL in this column means absent (inherit the operator
-- universe), '[]' means explicit opt-out, '[...]' is the populated
-- list. The distinction is load-bearing — we cannot collapse to a
-- NOT NULL DEFAULT '[]' the way the other *_json columns do.
ALTER TABLE cached_rfcs ADD COLUMN models_json TEXT;
+408
View File
@@ -0,0 +1,408 @@
"""Integration coverage for the §19.2 "per-RFC model availability — UX
half" candidate folded into §6.6.
The meta-repo entry's optional `models:` frontmatter narrows what AI
models contributors can pick from on a given RFC. The resolution rule
is uniform across every AI surface:
- Absent (`models:` key omitted) inherit the operator universe.
- Populated list intersection with the operator's provisioned
providers, preserving the entry's order. First entry is the RFC
default.
- Empty list (`models: []`) AI surfaces refuse honestly.
The tests below prove each branch through the API surface and verify
the cache round-trip from meta-repo frontmatter to picker option list.
"""
from __future__ import annotations
import asyncio
import json
from test_propose_vertical import ( # noqa: F401
FakeGitea,
app_with_fake_gitea,
provision_user_row,
sign_in_as,
tmp_env,
)
from test_rfc_view_vertical import FakeProvider, seed_active_rfc # noqa: F401
from test_super_draft_vertical import seed_super_draft # noqa: F401
SEED_BODY = (
"Open Human Model is a framework for representing humans.\n\n"
"It defines consent, trait, and agency in compatible terms."
)
def _set_models_json(slug: str, value: str | None) -> None:
"""Write the resolved cached_rfcs.models_json directly. Mirrors what
`_upsert_cached_rfc` would do on the next reconciler sweep, without
requiring a full meta-repo refresh in tests focused on the resolver."""
from app import db
db.conn().execute(
"UPDATE cached_rfcs SET models_json = ? WHERE slug = ?",
(value, slug),
)
def _install_two_providers(app) -> None:
"""Swap two fake providers — `claude` and `gemini` — into the
shared providers dict. The dict is held by reference inside the
router closures (see Slice 2's pattern in test_rfc_view_vertical),
so the mutation propagates."""
app.state.providers.clear()
app.state.providers["claude"] = FakeProvider("TITLE: A\nDESCRIPTION: B")
app.state.providers["gemini"] = FakeProvider("TITLE: G\nDESCRIPTION: H")
# ---------------------------------------------------------------------------
# /api/rfcs/<slug>/models — the picker option-list surface
# ---------------------------------------------------------------------------
def test_absent_frontmatter_inherits_operator_universe(app_with_fake_gitea):
"""When the meta-repo entry has no `models:` field, the picker's
option list is the operator's full provisioned universe."""
from fastapi.testclient import TestClient
app, fake = app_with_fake_gitea
with TestClient(app) as client:
provision_user_row(user_id=1, login="ben", role="owner")
seed_active_rfc(fake, slug="ohm", title="OHM", body=SEED_BODY)
sign_in_as(client, user_id=1, gitea_login="ben",
display_name="Ben", role="owner", email="ben@test")
_install_two_providers(app)
# models_json is NULL — set_models_json not called.
r = client.get("/api/rfcs/ohm/models")
assert r.status_code == 200, r.text
body = r.json()
ids = [m["id"] for m in body["models"]]
assert ids == ["claude", "gemini"]
assert body["default"] == "claude"
def test_populated_frontmatter_narrows_picker_to_intersection(app_with_fake_gitea):
"""A populated `models:` list narrows the picker. Operator order
does not matter the entry's stated order is preserved."""
from fastapi.testclient import TestClient
app, fake = app_with_fake_gitea
with TestClient(app) as client:
provision_user_row(user_id=1, login="ben", role="owner")
seed_active_rfc(fake, slug="ohm", title="OHM", body=SEED_BODY)
sign_in_as(client, user_id=1, gitea_login="ben",
display_name="Ben", role="owner", email="ben@test")
_install_two_providers(app)
_set_models_json("ohm", json.dumps(["gemini"]))
r = client.get("/api/rfcs/ohm/models")
body = r.json()
assert [m["id"] for m in body["models"]] == ["gemini"]
assert body["default"] == "gemini"
def test_empty_frontmatter_disables_ai_surfaces(app_with_fake_gitea):
"""`models: []` — the RFC opts out of AI per §6.6. The picker
surface returns an empty list and an empty default."""
from fastapi.testclient import TestClient
app, fake = app_with_fake_gitea
with TestClient(app) as client:
provision_user_row(user_id=1, login="ben", role="owner")
seed_active_rfc(fake, slug="ohm", title="OHM", body=SEED_BODY)
sign_in_as(client, user_id=1, gitea_login="ben",
display_name="Ben", role="owner", email="ben@test")
_install_two_providers(app)
_set_models_json("ohm", json.dumps([]))
r = client.get("/api/rfcs/ohm/models")
body = r.json()
assert body["models"] == []
assert body["default"] == ""
def test_intersection_with_provisioned_providers(app_with_fake_gitea):
"""An entry that lists models the operator no longer provisions
they're silently hidden from the picker. The frontmatter list
is preserved so a later operator change can restore them; the
cache row is untouched by this test."""
from fastapi.testclient import TestClient
app, fake = app_with_fake_gitea
with TestClient(app) as client:
provision_user_row(user_id=1, login="ben", role="owner")
seed_active_rfc(fake, slug="ohm", title="OHM", body=SEED_BODY)
sign_in_as(client, user_id=1, gitea_login="ben",
display_name="Ben", role="owner", email="ben@test")
_install_two_providers(app)
# Frontmatter names a model the operator doesn't have plus one
# it does — only the intersection comes out.
_set_models_json("ohm", json.dumps(["llama-3", "claude"]))
r = client.get("/api/rfcs/ohm/models")
body = r.json()
assert [m["id"] for m in body["models"]] == ["claude"]
assert body["default"] == "claude"
def test_intersection_empty_same_as_opt_out(app_with_fake_gitea):
"""When the frontmatter names only models the operator doesn't
provision, the resolved list is empty and the surface refuses
cleanly same shape as the explicit `models: []` opt-out."""
from fastapi.testclient import TestClient
app, fake = app_with_fake_gitea
with TestClient(app) as client:
provision_user_row(user_id=1, login="ben", role="owner")
seed_active_rfc(fake, slug="ohm", title="OHM", body=SEED_BODY)
sign_in_as(client, user_id=1, gitea_login="ben",
display_name="Ben", role="owner", email="ben@test")
_install_two_providers(app)
_set_models_json("ohm", json.dumps(["llama-3", "gpt-5"]))
r = client.get("/api/rfcs/ohm/models")
assert r.json()["models"] == []
# ---------------------------------------------------------------------------
# §10.2 PR draft: model selection follows the RFC default
# ---------------------------------------------------------------------------
def test_pr_draft_uses_rfc_default_model(app_with_fake_gitea):
"""The §10.2 draft picks the RFC's default — the first entry of
the resolved list. We rig the providers so `claude` returns one
title and `gemini` returns a different one, then prove the entry's
pinned model is the one we get back."""
from fastapi.testclient import TestClient
app, fake = app_with_fake_gitea
with TestClient(app) as client:
provision_user_row(user_id=1, login="ben", role="owner")
provision_user_row(user_id=2, login="alice", role="contributor")
seed_active_rfc(fake, slug="ohm", title="OHM", body=SEED_BODY)
# Two fakes, distinct outputs.
app.state.providers.clear()
app.state.providers["claude"] = FakeProvider(
"TITLE: from claude\nDESCRIPTION: claude wrote this."
)
app.state.providers["gemini"] = FakeProvider(
"TITLE: from gemini\nDESCRIPTION: gemini wrote this."
)
_set_models_json("ohm", json.dumps(["gemini"]))
sign_in_as(client, user_id=2, gitea_login="alice",
display_name="Alice", role="contributor")
r = client.post("/api/rfcs/ohm/branches/main/promote-to-branch", json={})
branch = r.json()["branch_name"]
# Make the branch differ from main so the draft endpoint passes
# its "commits ahead" precondition.
from app import db
from test_rfc_view_vertical import FakeGitea # noqa: F401
# Promote-to-branch starts a branch off main with main's content;
# the simplest way to give it a commit-ahead is via a manual edit
# flush. But for this test, an inline commit on the fake is
# cleaner.
repo_full = "wiggleverse/rfc-0001-ohm"
owner, repo = repo_full.split("/", 1)
new_body = SEED_BODY + "\n\nA further sentence."
fake.files[(owner, repo, branch, "RFC.md")] = {
"content": new_body, "sha": fake._next_sha(),
}
fake.branches[(owner, repo)][branch]["sha"] = fake.files[
(owner, repo, branch, "RFC.md")
]["sha"]
db.conn().execute(
"""
UPDATE cached_branches SET head_sha = ?
WHERE rfc_slug = 'ohm' AND branch_name = ?
""",
(fake.branches[(owner, repo)][branch]["sha"], branch),
)
r = client.post(f"/api/rfcs/ohm/branches/{branch}/pr-draft")
assert r.status_code == 200, r.text
# `gemini` is the resolved default — its title comes back.
assert r.json()["title"] == "from gemini"
def test_pr_draft_falls_back_to_stub_on_opt_out(app_with_fake_gitea):
"""When the RFC opts out of AI (`models: []`), §10.2 falls back to
its deterministic stub the title is the stub format, not an
LLM-generated string."""
from fastapi.testclient import TestClient
app, fake = app_with_fake_gitea
with TestClient(app) as client:
provision_user_row(user_id=1, login="ben", role="owner")
provision_user_row(user_id=2, login="alice", role="contributor")
seed_active_rfc(fake, slug="ohm", title="OHM", body=SEED_BODY)
_install_two_providers(app)
_set_models_json("ohm", json.dumps([]))
sign_in_as(client, user_id=2, gitea_login="alice",
display_name="Alice", role="contributor")
r = client.post("/api/rfcs/ohm/branches/main/promote-to-branch", json={})
branch = r.json()["branch_name"]
from app import db
repo_full = "wiggleverse/rfc-0001-ohm"
owner, repo = repo_full.split("/", 1)
new_body = SEED_BODY + "\n\nA further sentence."
fake.files[(owner, repo, branch, "RFC.md")] = {
"content": new_body, "sha": fake._next_sha(),
}
fake.branches[(owner, repo)][branch]["sha"] = fake.files[
(owner, repo, branch, "RFC.md")
]["sha"]
db.conn().execute(
"""
UPDATE cached_branches SET head_sha = ?
WHERE rfc_slug = 'ohm' AND branch_name = ?
""",
(fake.branches[(owner, repo)][branch]["sha"], branch),
)
r = client.post(f"/api/rfcs/ohm/branches/{branch}/pr-draft")
assert r.status_code == 200, r.text
body = r.json()
# The stub uses "Edits to <RFC title>" as the title (per Slice 3).
assert body["title"] == "Edits to OHM"
# ---------------------------------------------------------------------------
# Chat surface refuses cleanly when AI is unavailable for the RFC
# ---------------------------------------------------------------------------
def test_chat_refuses_when_rfc_opted_out_of_ai(app_with_fake_gitea):
"""A chat turn on a branch of an RFC with `models: []` refuses
with 503 same shape as "no providers configured." The refusal
surfaces as a property of the RFC, not a server failure."""
from fastapi.testclient import TestClient
app, fake = app_with_fake_gitea
with TestClient(app) as client:
provision_user_row(user_id=2, login="alice", role="contributor")
seed_active_rfc(fake, slug="ohm", title="OHM", body=SEED_BODY)
_install_two_providers(app)
_set_models_json("ohm", json.dumps([]))
sign_in_as(client, user_id=2, gitea_login="alice",
display_name="Alice", role="contributor")
r = client.post("/api/rfcs/ohm/branches/main/promote-to-branch", json={})
branch = r.json()["branch_name"]
view = client.get(f"/api/rfcs/ohm/branches/{branch}").json()
thread_id = view["main_thread_id"]
r = client.post(
f"/api/rfcs/ohm/branches/{branch}/threads/{thread_id}/chat",
json={"text": "tighten this", "model": "claude"},
)
assert r.status_code == 503
# ---------------------------------------------------------------------------
# Cache round-trip: frontmatter `models:` flows into models_json
# ---------------------------------------------------------------------------
def test_meta_repo_frontmatter_models_round_trips_through_cache(app_with_fake_gitea):
"""The production path: a meta-repo entry whose frontmatter carries
`models: [claude]` lands in `cached_rfcs.models_json` after the
next `refresh_meta_repo` sweep."""
from fastapi.testclient import TestClient
from app import cache as cache_mod, db
app, fake = app_with_fake_gitea
with TestClient(app) as client:
provision_user_row(user_id=1, login="ben", role="owner")
# Seed a super-draft entry directly on meta-main with a
# `models:` frontmatter field. seed_super_draft doesn't take
# this field, so we write the meta-repo file ourselves and
# let the cache reconciler parse it.
entry_text = (
"---\n"
"slug: ohm\n"
"title: OHM\n"
"state: super-draft\n"
"id: null\n"
"repo: null\n"
"proposed_by: ben\n"
"proposed_at: 2026-05-23\n"
"graduated_at: null\n"
"graduated_by: null\n"
"owners: []\n"
"arbiters: []\n"
"tags: []\n"
"models:\n"
" - claude\n"
"---\n\n"
"The pitch goes here.\n"
)
sha = fake._next_sha()
fake.files[("wiggleverse", "meta", "main", "rfcs/ohm.md")] = {
"content": entry_text, "sha": sha,
}
fake.branches[("wiggleverse", "meta")]["main"]["sha"] = sha
asyncio.run(cache_mod.refresh_meta_repo(app.state.config, app.state.gitea))
row = db.conn().execute(
"SELECT models_json FROM cached_rfcs WHERE slug = 'ohm'"
).fetchone()
assert row is not None
assert row["models_json"] is not None
assert json.loads(row["models_json"]) == ["claude"]
# And an entry without `models:` lands NULL.
no_models_text = entry_text.replace("models:\n - claude\n", "")
sha2 = fake._next_sha()
fake.files[("wiggleverse", "meta", "main", "rfcs/beta.md")] = {
"content": no_models_text.replace("slug: ohm", "slug: beta")
.replace("title: OHM", "title: Beta"),
"sha": sha2,
}
fake.branches[("wiggleverse", "meta")]["main"]["sha"] = sha2
asyncio.run(cache_mod.refresh_meta_repo(app.state.config, app.state.gitea))
row = db.conn().execute(
"SELECT models_json FROM cached_rfcs WHERE slug = 'beta'"
).fetchone()
assert row is not None
assert row["models_json"] is None
# ---------------------------------------------------------------------------
# entry.py: absent / empty / populated round-trip the parser+serializer
# ---------------------------------------------------------------------------
def test_entry_models_field_absent_vs_empty_distinguished():
"""The absent vs. empty distinction is load-bearing per §6.6.
parse(serialize(x)) preserves None as None and [] as []."""
from app import entry as entry_mod
absent = entry_mod.Entry(slug="x", title="X")
assert absent.models is None
text_absent = entry_mod.serialize(absent)
assert "models:" not in text_absent
round_absent = entry_mod.parse(text_absent)
assert round_absent.models is None
opted_out = entry_mod.Entry(slug="y", title="Y", models=[])
text_empty = entry_mod.serialize(opted_out)
assert "models: []" in text_empty
round_empty = entry_mod.parse(text_empty)
assert round_empty.models == []
populated = entry_mod.Entry(slug="z", title="Z", models=["claude", "gemini"])
text_full = entry_mod.serialize(populated)
round_full = entry_mod.parse(text_full)
assert round_full.models == ["claude", "gemini"]