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
+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 ""