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
+68
View File
@@ -27,7 +27,9 @@ from . import (
db,
entry as entry_mod,
cache,
funder,
philosophy,
providers as providers_mod,
)
from .bot import Bot
from .config import Config
@@ -46,6 +48,11 @@ class DeclineBody(BaseModel):
comment: str = Field(min_length=1, max_length=4000)
class FunderCredentialBody(BaseModel):
provider: str = Field(min_length=1, max_length=40)
api_key: str = Field(min_length=1, max_length=2048)
def make_router(
config: Config,
gitea: Gitea,
@@ -381,6 +388,67 @@ def make_router(
await cache.refresh_meta_pulls(config, gitea)
return {"ok": True}
# ---------------------------------------------------------------
# §6.7 / §17: per-RFC credential delegation — the funder surface.
# /api/users/me/funder is the self-serve read; the two write
# endpoints register credentials and the two slug-scoped endpoints
# toggle consent. Per §6.7, the funder cannot expand the operator
# universe: registering for a provider family the operator has not
# enabled is refused. A consent without registered credentials is
# also refused since it would be operationally inert.
# ---------------------------------------------------------------
@router.get("/api/users/me/funder")
async def get_funder_self(request: Request) -> dict[str, Any]:
user = auth.require_user(request)
return {
"credentials": funder.list_credentials(user.user_id),
"consents": funder.list_consents(user.user_id),
}
@router.post("/api/users/me/funder/credentials")
async def register_funder_credential(payload: FunderCredentialBody, request: Request) -> dict[str, Any]:
user = auth.require_contributor(request)
provider = payload.provider.strip().lower()
if provider not in providers_mod.FUNDER_PROVIDER_FAMILIES:
raise HTTPException(422, f"Unknown provider `{provider}`")
# §6.7: the funder cannot expand the operator universe. The
# provider family must back at least one operator-enabled key.
operator_family_keys = providers_mod.picker_keys_for_family(provider, list(providers.keys()))
if not operator_family_keys:
raise HTTPException(409, f"Operator has not enabled any `{provider}` models")
funder.upsert_credential(user.user_id, provider, payload.api_key.strip())
return {"ok": True, "provider": provider}
@router.delete("/api/users/me/funder/credentials/{provider}")
async def delete_funder_credential(provider: str, request: Request) -> dict[str, Any]:
user = auth.require_user(request)
provider = provider.strip().lower()
funder.delete_credential(user.user_id, provider)
return {"ok": True, "provider": provider}
@router.post("/api/rfcs/{slug}/funder/consent")
async def add_funder_consent(slug: str, request: Request) -> dict[str, Any]:
user = auth.require_contributor(request)
rfc = db.conn().execute(
"SELECT 1 FROM cached_rfcs WHERE slug = ?", (slug,)
).fetchone()
if rfc is None:
raise HTTPException(404, "RFC not found")
# §6.7: refuse consent from a user with no registered credentials
# — a consent without a universe would be inert and the surface
# should fail loudly rather than silently.
if not funder.has_any_credentials(user.user_id):
raise HTTPException(409, "Register credentials before consenting to fund")
funder.add_consent(user.user_id, slug)
return {"ok": True, "slug": slug}
@router.delete("/api/rfcs/{slug}/funder/consent")
async def remove_funder_consent(slug: str, request: Request) -> dict[str, Any]:
user = auth.require_user(request)
funder.remove_consent(user.user_id, slug)
return {"ok": True, "slug": slug}
# ---------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------
+11 -3
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, models_resolver
from . import auth, cache, chat as chat_layer, db, entry as entry_mod, funder, models_resolver
from .bot import Bot
from .config import Config
from .gitea import Gitea, GiteaError
@@ -576,7 +576,11 @@ def make_router(
fetched = await gitea.read_file(owner, repo, path, ref=branch)
body_text = _extract_body(rfc, fetched[0], branch) if fetched else ""
provider = providers[reask_model]
# §6.7: pick the funder-credentialed instance when a consenting
# funder is in effect on this RFC; otherwise the operator's.
provider = funder.provider_for_rfc(slug, reask_model, providers)
if provider is None:
raise HTTPException(503, "No AI providers configured")
system = chat_layer.build_system_prompt(title=rfc["title"], body=body_text)
history = chat_layer.build_history(thread_id)
reask_prompt = (
@@ -907,7 +911,11 @@ def make_router(
if not resolved:
raise HTTPException(503, "No AI providers configured")
model_key = body.model if body.model in resolved else resolved[0]
provider = providers[model_key]
# §6.7: pick the funder-credentialed instance when a consenting
# funder is in effect on this RFC; otherwise the operator's.
provider = funder.provider_for_rfc(slug, model_key, providers)
if provider is None:
raise HTTPException(503, "No AI providers configured")
# Fetch the live branch body so the prompt is anchored to
# what's in Gitea right now, not the cache. For super-draft,
+10 -3
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, models_resolver
from . import auth, cache, chat as chat_layer, db, entry as entry_mod, funder, models_resolver
from .bot import Bot
from .config import Config
from .gitea import Gitea, GiteaError
@@ -92,6 +92,7 @@ def make_router(
chat_messages = _branch_chat_excerpt(slug, branch)
rfc_default_model = models_resolver.default_model_for_rfc(slug, providers)
title, description = _draft_with_provider(
slug=slug,
providers=providers,
default_model=rfc_default_model,
rfc_title=rfc["title"],
@@ -786,6 +787,7 @@ def _branch_chat_excerpt(slug: str, branch: str, limit: int = 40) -> list[dict]:
def _draft_with_provider(
*,
slug: str,
providers: dict[str, BaseProvider],
default_model: str,
rfc_title: str,
@@ -799,11 +801,16 @@ def _draft_with_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.
surface still works; the contributor edits the text. Per §6.7,
when a consenting funder is in effect, the provider instance is
built from funder credentials; if the funder cannot serve the
default model, we also fall back to the stub.
"""
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()))
provider = funder.provider_for_rfc(slug, default_model, providers)
if provider is None:
return _stub_draft(rfc_title=rfc_title, main_body=main_body, branch_body=branch_body)
system = (
"You are summarizing a contributor's proposed change to an RFC for an arbiter audience. "
"Output exactly two sections in this order: 'TITLE: <one line, structural, spec-voice>' "
+7 -2
View File
@@ -79,13 +79,16 @@ 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
# §6.7: funder_login mirrors the optional `funder:` frontmatter
# field. NULL means absent — operator credentials are used.
funder_login = entry.funder or 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,
models_json, body, body_sha, last_entry_commit_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, datetime('now'), datetime('now'))
models_json, funder_login, 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,
@@ -99,6 +102,7 @@ def _upsert_cached_rfc(entry: entry_mod.Entry, body_sha: str) -> None:
arbiters_json = excluded.arbiters_json,
tags_json = excluded.tags_json,
models_json = excluded.models_json,
funder_login = excluded.funder_login,
body = excluded.body,
body_sha = excluded.body_sha,
last_entry_commit_at = datetime('now'),
@@ -118,6 +122,7 @@ def _upsert_cached_rfc(entry: entry_mod.Entry, body_sha: str) -> None:
json.dumps(entry.arbiters),
json.dumps(entry.tags),
models_json,
funder_login,
entry.body,
body_sha,
),
+13
View File
@@ -45,6 +45,11 @@ class Entry:
# 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
body: str = ""
@@ -59,6 +64,8 @@ def parse(text: str) -> Entry:
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
return Entry(
slug=str(fm.get("slug") or ""),
title=str(fm.get("title") or ""),
@@ -73,6 +80,7 @@ def parse(text: str) -> Entry:
arbiters=list(fm.get("arbiters") or []),
tags=list(fm.get("tags") or []),
models=models,
funder=funder,
body=body,
)
@@ -97,6 +105,11 @@ def serialize(entry: Entry) -> str:
# 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
yaml_text = yaml.safe_dump(fm, sort_keys=False, default_flow_style=False).rstrip()
body = entry.body.lstrip("\n")
if body:
+244
View File
@@ -0,0 +1,244 @@
"""§6.7: per-RFC credential delegation — the funder role.
Three responsibilities live here:
1. The runtime resolver. Given a (slug, picker_key) tuple, return the
`BaseProvider` instance that should serve calls. When no consenting
funder is in effect, this is the operator's pre-built provider.
When a consenting funder is named on the RFC and has registered
credentials for the picker_key's provider family, a fresh provider
instance is constructed with the funder's credentials.
2. The universe resolver. `resolve_funder_universe(slug, operator)`
returns the funder's per-RFC universe — the subset of operator-
enabled picker keys served by provider families the funder has
registered. Returns None when no funder is in effect; an empty
list when a consenting funder has no registrations intersecting
the operator's enabled set.
3. The consent + credential CRUD. Small helpers that the §17
`/api/users/me/funder` endpoints in api.py call into.
The lighter half (this module) deliberately does NOT handle:
- Mid-call failure with fallback. Per §6.7, the resolution is
attribution-clean: a consenting-funder RFC is paid entirely from
funder credentials. If construction fails or the call fails,
that's a failure — no silent fallback to operator credentials.
- Per-instance caching. Constructs fresh on every call. The cost is
real (HTTP client setup) but bounded; caching across calls is
deferred until evidence shows the cost bites.
- Billing attribution surface, rotation ceremony, rate-limit
attribution. All deferred to the operational-realities §19.2
candidate.
"""
from __future__ import annotations
from . import db, providers as providers_mod
from .providers import BaseProvider
# ---------------------------------------------------------------------------
# Universe + provider resolution
# ---------------------------------------------------------------------------
def resolve_funder_universe(
slug: str, operator_providers: dict[str, BaseProvider]
) -> list[str] | None:
"""The funder's registered universe for this RFC, or None.
None means no consenting funder is in effect caller falls back to
the operator universe per §6.7. A list (possibly empty) means a
consenting funder is in effect; the list is the subset of operator-
enabled picker keys for which the funder has supplied a provider-
family API key. An empty list means the named-and-consenting
funder's registered providers don't intersect the operator's
enabled set the RFC drops into the §6.6 opt-out shape.
"""
funder_user_id = consenting_funder_user_id(slug)
if funder_user_id is None:
return None
registered = registered_provider_families(funder_user_id)
return [
picker_key
for picker_key in operator_providers
if (family := providers_mod.provider_family_for_picker_key(picker_key))
and family in registered
]
def provider_for_rfc(
slug: str,
picker_key: str,
operator_providers: dict[str, BaseProvider],
) -> BaseProvider | None:
"""The `BaseProvider` that should serve calls for (slug, picker_key).
When a consenting funder is named on the RFC and has registered
credentials for picker_key's provider family, returns a fresh
funder-credentials provider instance. Otherwise returns the
operator's pre-built provider. Returns None when picker_key isn't
available in the resolved universe caller refuses per §6.6/§6.7.
"""
funder_user_id = consenting_funder_user_id(slug)
if funder_user_id is None:
return operator_providers.get(picker_key)
family = providers_mod.provider_family_for_picker_key(picker_key)
if family is None:
return None
row = db.conn().execute(
"SELECT api_key FROM user_funder_credentials WHERE user_id = ? AND provider = ?",
(funder_user_id, family),
).fetchone()
if not row:
return None
return providers_mod.construct_for_funder(picker_key, row["api_key"])
# ---------------------------------------------------------------------------
# Consenting-funder lookup (the two-key §6.7 rule)
# ---------------------------------------------------------------------------
def consenting_funder_user_id(slug: str) -> int | None:
"""Resolve the consenting funder for an RFC, or None.
The two-key rule per §6.7: `cached_rfcs.funder_login` names a user
AND a `funder_consents` row exists for the (user, slug) pair. The
third revocation path (account-disabled) is a structural commitment
when the disable affordance lands per §6.1, it joins this check
as a third AND clause; until then, the two-key rule stands alone.
"""
row = db.conn().execute(
"""
SELECT u.id AS user_id
FROM cached_rfcs r
JOIN users u ON u.gitea_login = r.funder_login
WHERE r.slug = ? AND r.funder_login IS NOT NULL
""",
(slug,),
).fetchone()
if not row:
return None
consent = db.conn().execute(
"SELECT 1 FROM funder_consents WHERE user_id = ? AND rfc_slug = ?",
(row["user_id"], slug),
).fetchone()
if not consent:
return None
return row["user_id"]
# ---------------------------------------------------------------------------
# Credential + consent CRUD (called by the §17 funder endpoints)
# ---------------------------------------------------------------------------
def registered_provider_families(user_id: int) -> set[str]:
"""The provider families this user has registered credentials for."""
rows = db.conn().execute(
"SELECT provider FROM user_funder_credentials WHERE user_id = ?",
(user_id,),
).fetchall()
return {r["provider"] for r in rows}
def list_credentials(user_id: int) -> list[dict[str, str]]:
"""Per-provider registration metadata for the funder surface.
The API key itself is never returned to the client. The list shape
is one row per registered family, with the registration timestamp
for the settings page's display.
"""
rows = db.conn().execute(
"""
SELECT provider, created_at, updated_at
FROM user_funder_credentials
WHERE user_id = ?
ORDER BY provider
""",
(user_id,),
).fetchall()
return [
{"provider": r["provider"], "created_at": r["created_at"], "updated_at": r["updated_at"]}
for r in rows
]
def upsert_credential(user_id: int, provider: str, api_key: str) -> None:
"""Register or replace an API key for a provider family.
Idempotent. Callers gate the provider against
`providers_mod.FUNDER_PROVIDER_FAMILIES` and against the operator's
enabled set (per §6.7, the funder cannot expand the operator
universe) before calling here.
"""
db.conn().execute(
"""
INSERT INTO user_funder_credentials (user_id, provider, api_key)
VALUES (?, ?, ?)
ON CONFLICT(user_id, provider) DO UPDATE SET
api_key = excluded.api_key,
updated_at = datetime('now')
""",
(user_id, provider, api_key),
)
def delete_credential(user_id: int, provider: str) -> bool:
"""Remove a registered key. Returns True if a row was deleted.
Existing `funder_consents` rows are intentionally left in place
the resolved funder universe for affected RFCs simply shrinks. If
the funder later re-registers the family, those RFCs resume
drawing on the funder's universe without re-consenting.
"""
cur = db.conn().execute(
"DELETE FROM user_funder_credentials WHERE user_id = ? AND provider = ?",
(user_id, provider),
)
return cur.rowcount > 0
def list_consents(user_id: int) -> list[str]:
"""The RFC slugs this user has consented to fund. Sorted for stable display."""
rows = db.conn().execute(
"SELECT rfc_slug FROM funder_consents WHERE user_id = ? ORDER BY rfc_slug",
(user_id,),
).fetchall()
return [r["rfc_slug"] for r in rows]
def add_consent(user_id: int, slug: str) -> None:
"""Idempotent funder-side opt-in to fund this RFC.
Callers gate with `has_any_credentials` so a consent without any
registered universe (which would be inert) is refused at the API
layer rather than written.
"""
db.conn().execute(
"""
INSERT INTO funder_consents (user_id, rfc_slug) VALUES (?, ?)
ON CONFLICT(user_id, rfc_slug) DO NOTHING
""",
(user_id, slug),
)
def remove_consent(user_id: int, slug: str) -> bool:
"""Withdraw consent per §6.7's first revocation path. Returns True
if a row was removed."""
cur = db.conn().execute(
"DELETE FROM funder_consents WHERE user_id = ? AND rfc_slug = ?",
(user_id, slug),
)
return cur.rowcount > 0
def has_any_credentials(user_id: int) -> bool:
row = db.conn().execute(
"SELECT 1 FROM user_funder_credentials WHERE user_id = ? LIMIT 1",
(user_id,),
).fetchone()
return row is not None
+25 -14
View File
@@ -1,4 +1,5 @@
"""§6.6 per-RFC model availability — the resolver.
"""§6.6 per-RFC model availability — the resolver, extended in §6.7
with the funder-universe layer.
The meta-repo entry's optional `models:` frontmatter and the operator's
provisioned providers (the §18 `ENABLED_MODELS` universe) combine into a
@@ -8,16 +9,22 @@ invocation, the §9.1 tag suggestions when graduation is in scope.
Rule, in one place:
- The base universe is normally the operator's provisioned providers.
When a consenting funder is in effect for the RFC per §6.7, the base
universe is replaced (not augmented) by the funder's registered
universe the subset of operator-enabled picker keys the funder has
supplied credentials for.
- Cache row's `models_json` is NULL → the field is absent on the entry.
Resolved list = the operator's provisioned universe.
Resolved list = the base 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.
entry. Resolved list = intersection of the array with the base
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 empty case folds in naturally: `models: []` yields empty, an entry
whose listed models aren't in the operator's enabled set yields empty,
a consenting funder whose registrations don't intersect the operator's
enabled set yields empty. Callers treat all four the same 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
@@ -27,32 +34,36 @@ from __future__ import annotations
import json
from . import db
from . import db, funder
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.
"""Return the per-RFC resolved model keys per §6.6, extended by §6.7.
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())
# §6.7: the funder universe (if any) replaces the operator universe
# as the base set the §6.6 frontmatter intersects against.
funder_universe = funder.resolve_funder_universe(slug, providers)
base_universe = funder_universe if funder_universe is not None else 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
return list(base_universe)
try:
listed = [str(m) for m in json.loads(row["models_json"])]
except (json.JSONDecodeError, TypeError):
return universe
return list(base_universe)
if not listed:
return []
return [m for m in listed if m in providers]
base_set = set(base_universe)
return [m for m in listed if m in base_set]
def default_model_for_rfc(
+53
View File
@@ -193,3 +193,56 @@ def load_from_config(config) -> dict[str, BaseProvider]:
"OPENAI_API_KEY": config.openai_api_key,
}
return load_providers(env)
# ---------------------------------------------------------------------------
# §6.7 helpers — per-RFC funder credential plumbing.
#
# A picker key (the operator-enabled identifier like "claude" or
# "gemini-flash") names exactly one provider family (Anthropic, Google,
# OpenAI). The funder registers a key per family; the resolver in
# funder.py picks the right family for a requested picker key, then
# constructs a fresh provider instance keyed on the funder's API key.
# ---------------------------------------------------------------------------
FUNDER_PROVIDER_FAMILIES = ("anthropic", "google", "openai")
def provider_family_for_picker_key(picker_key: str) -> str | None:
"""Map a picker key to its provider-family name per §6.7.
Returns 'anthropic' / 'google' / 'openai' or None for keys outside
the known variant tables.
"""
if picker_key in _CLAUDE_VARIANTS:
return "anthropic"
if picker_key in _GEMINI_VARIANTS:
return "google"
if picker_key == "openai":
return "openai"
return None
def picker_keys_for_family(family: str, enabled_picker_keys: list[str]) -> list[str]:
"""Subset of `enabled_picker_keys` served by `family`. Preserves the
operator's enabled order so the picker reads stably."""
return [k for k in enabled_picker_keys if provider_family_for_picker_key(k) == family]
def construct_for_funder(picker_key: str, api_key: str) -> BaseProvider | None:
"""Instantiate a provider for a funder-supplied API key per §6.7.
Mirrors the variant table `load_providers` uses, without the env
contract. Returns None for picker keys outside the known families
the funder cannot serve them and the caller treats the (slug,
picker_key) request as unavailable.
"""
if picker_key in _CLAUDE_VARIANTS:
default_model, default_name = _CLAUDE_VARIANTS[picker_key]
return AnthropicProvider(api_key=api_key, model=default_model, display_name=default_name)
if picker_key in _GEMINI_VARIANTS:
default_model, default_name = _GEMINI_VARIANTS[picker_key]
return GeminiProvider(api_key=api_key, model=default_model, display_name=default_name)
if picker_key == "openai":
return OpenAIProvider(api_key=api_key)
return None