Post-v1: per-RFC credential delegation (funder role) folded into §6.7

Second §19.2 settlement after v1. New §6.7 alongside §6.6: optional
`funder:` frontmatter field names a single gitea_login; a
`funder_consents` app-db row records funder-side opt-in; both halves
required for the binding to activate (two-key rule). Funder universe
replaces — does not augment — the operator universe per-RFC for
attribution-clean resolution. Funder role grants zero §6.1/§6.3
authority. Three revocation paths each restore the operator-credentials
status quo.

§19.2's credential-delegation entry is split: lighter half marked
settled with a pointer to §6.7; operational-realities half (mid-call
failure, rotation, billing, rate-limit attribution) lives on as its
own entry. Test suite is 125/125 green (106 prior + 19 new).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Ben Stull
2026-05-25 06:08:43 -07:00
parent a255429e57
commit 55a8be051a
12 changed files with 1437 additions and 43 deletions
+123
View File
@@ -44,6 +44,129 @@ rare and surgical and live in the appropriate numbered section per
## State of the codebase
### Post-v1: §6.7 per-RFC credential delegation (funder role + grant shape)
The second §19.2 candidate settled after v1 shipped. The original
per-RFC credential-delegation topic explicitly subdivided into "funder
role + delegation grant" (schema/permissions-shaped) and "operational
realities" (mid-call failure, rotation, billing, rate-limit
attribution); this session folded in the lighter half and left the
heavier one as its own §19.2 entry. The natural follow-on to §6.6 —
§6.6 names *which* models are permitted, §6.7 names *whose credentials
pay*.
The settlement: a new optional `funder:` frontmatter field on the
meta-repo entry, naming a single gitea_login (parallel in shape to
`owners:` / `arbiters:` / `models:`). The frontmatter is RFC-side
approval (edited via the meta-repo PR flow). A `funder_consents`
app-db row is the funder-side approval (the user opts in per-slug
from `/settings/funder`). Both halves are required for the binding to
be operationally active — the spec calls this the "frontmatter +
consent hybrid" two-key rule. Either side can veto by withdrawing
their half.
When a funder is in effect, the funder universe **replaces** the
operator universe for the RFC's picker — it does not augment.
Resolution is attribution-clean: a call on a funder-active RFC is
paid entirely from funder credentials, never blended per-call. If
the funder cannot serve the resolved default model, the §10.2
draft falls back to its deterministic stub (same shape as §6.6's
opt-out); the §8.12 chat refuses with 503. Three revocation paths
each restore the operator-credentials status quo: funder withdraws
consent (instant), frontmatter edit removes/changes the field
(PR flow), funder's account disabled (the §6.1 future affordance —
when it lands, the two-key rule extends to a three-key check).
§6.7 carries the structural rule. §2.1's frontmatter example shows
the field. §6.6's last paragraph now points at §6.7 for the
credentials half. §17 grows five funder endpoints (self-read,
credential register/delete, consent add/withdraw). §18 reframes the
operator-credentials default as one of two cases (the other being a
named funder per §6.7). §19.2's credential-delegation entry is split
— the lighter half is marked *settled* with a pointer to §6.7; the
operational-realities half lives on as its own entry.
Code changes:
- [`backend/migrations/010_funder.sql`](../backend/migrations/010_funder.sql)
adds `cached_rfcs.funder_login` (nullable, NULL meaning absent),
the `user_funder_credentials` table (per-user, per-provider-family
API key, primary key `(user_id, provider)`), and the
`funder_consents` table (per-user, per-slug opt-in, primary key
`(user_id, rfc_slug)`).
- [`backend/app/entry.py`](../backend/app/entry.py) grows an optional
`funder: str | None` field on the `Entry` dataclass. The parser
treats empty strings and `None` as absent (one set of semantics —
unlike `models:`, there is no second "explicit opt-out" meaning).
The serializer emits `funder:` only when truthy.
- [`backend/app/cache.py`](../backend/app/cache.py)'s
`_upsert_cached_rfc` writes `funder_login` from frontmatter on the
round-trip; the existing `refresh_meta_repo` and reconciler sweep
carry the column through transparently.
- [`backend/app/funder.py`](../backend/app/funder.py) is the new
module that holds the §6.7 runtime — three responsibilities:
the consenting-funder lookup (`consenting_funder_user_id(slug)`
enforces the two-key rule); the universe + per-(slug,
picker_key) provider resolver (`resolve_funder_universe`,
`provider_for_rfc`); the credential and consent CRUD that the
§17 endpoints in `api.py` call into. The module deliberately
does not handle per-call fallback, instance caching, or
rotation — those are the operational-realities §19.2 candidate.
- [`backend/app/providers.py`](../backend/app/providers.py) grows
three §6.7 helpers: `FUNDER_PROVIDER_FAMILIES` enumerates the
family names (`anthropic` / `google` / `openai`),
`provider_family_for_picker_key` maps a picker key to its family,
and `construct_for_funder` instantiates a fresh provider from a
funder-supplied API key (mirrors the variant table
`load_providers` uses, without the env contract).
- [`backend/app/models_resolver.py`](../backend/app/models_resolver.py)'s
resolver consults `funder.resolve_funder_universe` first; when a
consenting funder is in effect, the funder universe replaces the
operator universe as the base set the §6.6 frontmatter intersects
against. The replace-not-augment rule lives here.
- [`backend/app/api_branches.py`](../backend/app/api_branches.py)'s
chat-stream and reask paths route their provider lookup through
`funder.provider_for_rfc(slug, model_key, providers)` instead of
the direct `providers[model_key]` access. Same shape as before
when no funder is named; constructs fresh on each call when one is.
- [`backend/app/api_prs.py`](../backend/app/api_prs.py)'s
`_draft_with_provider` takes the slug and routes through
`funder.provider_for_rfc`. The §6.6 stub-fallback path now also
catches the case where the funder cannot serve the default model.
- [`backend/app/api.py`](../backend/app/api.py) adds the five
§17 endpoints: `GET /api/users/me/funder` (lists registered
provider families without exposing the API keys themselves plus
the consented slugs), `POST /api/users/me/funder/credentials`
(refused if the provider family is not in the operator's enabled
set — the §6.7 cannot-expand-operator rule),
`DELETE /api/users/me/funder/credentials/<provider>`,
`POST /api/rfcs/<slug>/funder/consent` (refused if the user has
no registered credentials — a consent without a universe would
be inert), and `DELETE /api/rfcs/<slug>/funder/consent`.
Settlement ships covered by
[`test_funder_vertical.py`](../backend/tests/test_funder_vertical.py)
— nineteen integration tests across the two-key rule's four
combinations (frontmatter-only inert, consent-only inert, both
present active, neither present operator), the universe-replaces-
not-augments rule, the §6.6 + §6.7 composition with an empty
intersection falling into the opt-out shape, the funder-with-no-
matching-family case, the consent-withdrawal and frontmatter-removal
revocation paths, the API surface's read-without-API-key contract,
the cannot-expand-operator gate, the consent-needs-credentials gate,
the full opt-in/opt-out cycle, the delete-credential-leaves-consents-
intact rule, the §10.2 PR-draft routing through funder credentials
(with the API key the funder registered), the §10.2 fallback to
stub when the funder cannot serve the default, the §8.12 chat
routing through funder credentials, the cache round-trip from
meta-repo frontmatter through `refresh_meta_repo`, and the entry
parser/serializer round-trip preserving the field.
The full test suite is 125/125 green (106 prior + 19 new). No
behavioral change for RFCs without `funder:` in frontmatter — the
operator-credentials path is preserved as the default, so existing
deployments see no surface change until a funder field lands.
### Post-v1: §6.6 per-RFC model availability (UX half)
The first §19.2 candidate settled after v1 shipped. The heavier