diff --git a/SPEC.md b/SPEC.md index 2f794a5..56e99ff 100644 --- a/SPEC.md +++ b/SPEC.md @@ -80,6 +80,9 @@ tags: [identity, schema] # models: # optional per-§6.6 — absent inherits the # - claude # operator universe; [] opts the RFC out of AI # - gemini # entirely; populated narrows the picker +# funder: alice # optional per-§6.7 — names whose registered + # API credentials pay for AI calls. Inert until + # the named user consents from /settings/funder. --- ## Why this RFC is needed @@ -453,11 +456,93 @@ commitment here is the field, the resolution rule, and the configuration capability, not the click path. This section names *which* models are permitted on a given RFC. It -does not name *whose API resources pay for them* — credentials -remain operator-supplied per §18. Credential delegation and the -funder role are a separate topic (§19.2) whose settlement will -attach a parallel "credential set" notion alongside this one -without changing the availability surface. +does not name *whose API resources pay for them* — the credential- +delegation half settles in §6.7, where the operator-credentials +default is preserved and a per-RFC `funder:` field optionally points +the calls at a contributor-supplied credential set. + +--- + +### 6.7 Per-RFC credential delegation — the funder role + +§6.6 settles *which* AI models are permitted on a given RFC. This +section settles *whose* API credentials pay for them. The two halves +are parallel — both live in the meta-repo entry's frontmatter, both +follow the same edit-authority rules as `owners:` and `arbiters:`, +both mirror through the §4 cache. + +A new optional `funder:` frontmatter field names a single +`gitea_login` — the user whose registered API credentials pay for AI +calls on this RFC. Absent means the operator-supplied credentials per +§18 are used; this is the v1 default and the status quo for every +RFC without an explicit funder. + +The funder role is **purely credential-binding**. It confers none of +the §6.3 owner/arbiter authority and none of the §6.1 admin/owner +authority. The funder gains exactly one capability — withdraw consent +— and gains no new read access beyond what their underlying role +already affords. + +**Frontmatter + consent hybrid.** The frontmatter names the funder +(RFC-side approval, via the standard meta-repo PR flow). A +`funder_consents` app-db record records the funder-side approval — +the user explicitly opts in per-slug from `/settings/funder`. The +frontmatter can name a user who has not consented; the binding stays +operationally inert until both halves match. Both sides have a veto. + +**The funder universe replaces the operator universe.** When a +funder is in effect for an RFC — frontmatter names them and they +have consented — the picker for that RFC resolves to the +intersection of: + +- the §6.6 `models:` list (or the implicit "all permitted" when + absent), and +- the funder's registered universe — the picker keys the operator + has enabled and for which the funder has supplied a provider-family + API key. + +If the intersection is empty the RFC falls into the §6.6 opt-out +shape and the AI surfaces refuse honestly. The operator universe is +**not** augmented; the resolution is deterministic and attribution- +clean — a call on an RFC with a consenting funder is paid entirely +from the funder's credentials. If the funder cannot satisfy a +requested model, the model is unavailable for this RFC. The "fall +back to operator credentials when a funder call fails" semantics +belong to the operational-realities half (§19.2); the lighter half +deliberately does not blend funders and operators per-call. + +A funder cannot expand the operator universe through registration — +registering an Anthropic key on a deployment whose operator has not +enabled `claude` does not make `claude` available on RFCs the funder +funds. The operator's enabled set bounds the picker; the funder +narrows it further. + +**Three revocation paths, each restoring the operator-credentials +status quo.** First — the funder withdraws consent. Instant; the +next AI call resolves through the operator universe. Second — an +RFC owner, arbiter, or app admin edits the frontmatter to remove +the field or name a different funder. Flows through the standard +meta-repo PR. Third — the named user's account is disabled per §6.1. +Their consent rows are treated as inactive; the resolution flips +back without an explicit revocation gesture. + +**Editing the funder field.** Same authority as editing `models:` +(or `owners:` / `arbiters:`). Super-draft entries: the claimed +owners and app-wide admins. Active RFCs: the RFC's owners and +arbiters per §6.3, plus app-wide admins. The dedicated chrome for +the edit is downstream — clustered with the §19.2 metadata-pane UX +topic for super-drafts and with whatever surface admins use for +active-RFC frontmatter edits. + +**Explicitly deferred to the operational-realities half (§19.2).** +Mid-conversation credential failure handling; retry-with-fallback to +operator credentials when a funder call fails; per-RFC billing +surface and cost visibility; per-funder rate-limit attribution and +quota-exhaustion behavior; the key-rotation ceremony for a funder +swapping in a fresh key without dropping in-flight conversations. +The lighter half ships the structural shape — frontmatter, consent, +resolution, revocation. The heavier half ships the runtime +hardening. --- @@ -2485,6 +2570,27 @@ The follow-up session will refine this. A minimal starting set: user is acting in an admin/owner authority capacity or as an arbiter on an RFC where the muted user is also active. - `DELETE /api/users//notification-mute` — remove it. +- `GET /api/users/me/funder` — read the signed-in user's funder + surface per §6.7: the list of provider families for which they + have registered credentials (without the keys themselves) and the + list of RFC slugs they have consented to fund. +- `POST /api/users/me/funder/credentials` — register or replace an + API key for a provider family (`anthropic`, `google`, `openai`). + Body: `provider`, `api_key`. The provider name must match one the + operator has enabled; registering for a provider the operator has + not enabled is refused since per §6.7 a funder cannot expand the + operator universe. +- `DELETE /api/users/me/funder/credentials/` — remove the + registered key for a provider family. Idempotent. Existing consent + rows survive; the resolved funder universe for affected RFCs + simply shrinks. +- `POST /api/rfcs//funder/consent` — the signed-in user opts + in to fund this RFC per §6.7. Idempotent. Refused if the signed-in + user has no registered credentials at all (a consent without any + registered universe would be inert). +- `DELETE /api/rfcs//funder/consent` — withdraw consent per + §6.7's first revocation path. Restores the operator-credentials + status quo on the next AI call. - `GET /api/email/unsubscribe` — one-click per-category unsubscribe per §15.4. Signed URL; idempotent; redirects to a short confirmation page. @@ -2517,8 +2623,12 @@ them: GitHub Copilot. `ENABLED_MODELS` and per-provider API keys via env — these now define the operator's *universe* of available models, the set the deployment is provisioned to run. Per-RFC selection from - that universe is settled in §6.6; credential delegation and the - funder role remain a §19.2 candidate. + that universe is settled in §6.6; per-RFC credential delegation + (whose keys pay for the call) is settled in §6.7. Either the + operator's keys or, when a consenting funder is named, the funder's + registered keys — never blended per-call. The operational + hardening (mid-call failure handling, retry, rotation, billing + attribution) remains a §19.2 candidate. - The discuss-vs-contribute distinction inside an RFC view (to be fully specified in the follow-up session). - Gitea OAuth for user authentication. The OAuth identity is the basis @@ -2702,20 +2812,33 @@ binding. downstream — clustered with the metadata-pane UX candidate below for super-drafts, and with whatever surface admins use for active-RFC frontmatter edits.* -- **Per-RFC credential delegation and the funder role.** The - heavier half of the original per-RFC-model topic — who supplies - the API resources behind the models a given RFC permits. The - availability half (§6.6) names *which* models are allowed; - credential delegation names *whose keys pay for the calls*. - Touches security (delegation grant shape, revocation), billing - (per-RFC spend, attribution), abuse mitigation (rate limits per - funder, quota exhaustion), key rotation (mid-conversation - failure handling, retry-with-fallback), and a possible - `funder` role distinct from owner/arbiter that scopes credential - authority without conferring frontmatter authority. Load-bearing - once the framework runs past single-operator deployment; the v1 - shape — operator-supplied keys per §18 — is the right default - until a real multi-operator scenario surfaces. +- **Per-RFC credential delegation — funder role + grant shape.** + *Settled in the post-v1 session that picked it. The meta-repo + entry frontmatter grew an optional `funder:` field per §6.7, + parallel in shape to `owners:` / `arbiters:` / `models:`. The + frontmatter names the funder (RFC-side approval, via the meta-repo + PR flow); a `funder_consents` app-db record records funder-side + approval (the user opts in per-slug from `/settings/funder`); both + halves are required for the binding to be operationally active. + When in effect, the funder universe replaces — not augments — the + operator universe for the RFC's picker. The funder role is purely + credential-binding (no §6.3 / §6.1 authority granted). Three + revocation paths each restore the operator-credentials status quo. + The operational hardening — retry/fallback, mid-call failure, + rotation, billing — lives on as the entry below.* +- **Per-RFC credential delegation — operational realities.** The + heavier half of the original credential-delegation topic, split + out when §6.7 settled the structural shape. What happens at + runtime when funder credentials fail mid-conversation; whether + failed funder calls fall back to operator credentials (and how + that interacts with §6.7's attribution-clean rule); per-RFC + billing surface and cost visibility; per-funder rate-limit + attribution and quota-exhaustion behavior; the key-rotation + ceremony for a funder swapping in a fresh key without dropping + in-flight conversations. Earns its session once a real + multi-operator scenario surfaces and the structural §6.7 shape + meets enough load to expose where the runtime path needs + hardening. - **Admin surfaces.** Where role management, muting, audit-log views, the graduation-readiness queue, and Topic 13's notification-preferences settings (email categories per §15.4, diff --git a/backend/app/api.py b/backend/app/api.py index cf6ebba..dab2d29 100644 --- a/backend/app/api.py +++ b/backend/app/api.py @@ -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 # --------------------------------------------------------------- diff --git a/backend/app/api_branches.py b/backend/app/api_branches.py index c9a9310..7f0a594 100644 --- a/backend/app/api_branches.py +++ b/backend/app/api_branches.py @@ -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, diff --git a/backend/app/api_prs.py b/backend/app/api_prs.py index f640e98..a7cff26 100644 --- a/backend/app/api_prs.py +++ b/backend/app/api_prs.py @@ -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: ' " diff --git a/backend/app/cache.py b/backend/app/cache.py index 2807ec6..c3bdbc0 100644 --- a/backend/app/cache.py +++ b/backend/app/cache.py @@ -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, ), diff --git a/backend/app/entry.py b/backend/app/entry.py index d9b236b..27fad95 100644 --- a/backend/app/entry.py +++ b/backend/app/entry.py @@ -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: diff --git a/backend/app/funder.py b/backend/app/funder.py new file mode 100644 index 0000000..2fc10da --- /dev/null +++ b/backend/app/funder.py @@ -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 diff --git a/backend/app/models_resolver.py b/backend/app/models_resolver.py index 4a97b5d..b0c6894 100644 --- a/backend/app/models_resolver.py +++ b/backend/app/models_resolver.py @@ -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( diff --git a/backend/app/providers.py b/backend/app/providers.py index c4ae455..2a43fd5 100644 --- a/backend/app/providers.py +++ b/backend/app/providers.py @@ -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 diff --git a/backend/migrations/010_funder.sql b/backend/migrations/010_funder.sql new file mode 100644 index 0000000..142c473 --- /dev/null +++ b/backend/migrations/010_funder.sql @@ -0,0 +1,40 @@ +-- §6.7: per-RFC credential delegation — the funder role. +-- +-- Three pieces hang together: +-- +-- 1. cached_rfcs.funder_login mirrors the optional `funder:` frontmatter +-- field. NULL means absent — operator credentials per §18 are used. +-- A populated value names the user whose registered credentials pay, +-- contingent on a matching consent row below. +-- +-- 2. user_funder_credentials stores per-user, per-provider API keys. +-- A user can register at most one key per provider family; the keys +-- are stored as-supplied (encryption-at-rest is an operational +-- decision, deferred to §19.2's operational-realities half). +-- +-- 3. funder_consents records the funder-side opt-in per-slug. A +-- frontmatter `funder:` field naming a user without a matching +-- consent row is operationally inert per §6.7's two-key rule. + +ALTER TABLE cached_rfcs ADD COLUMN funder_login TEXT; + +CREATE TABLE IF NOT EXISTS user_funder_credentials ( + user_id INTEGER NOT NULL, + provider TEXT NOT NULL, + api_key TEXT NOT NULL, + created_at TEXT NOT NULL DEFAULT (datetime('now')), + updated_at TEXT NOT NULL DEFAULT (datetime('now')), + PRIMARY KEY (user_id, provider), + FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE +); + +CREATE TABLE IF NOT EXISTS funder_consents ( + user_id INTEGER NOT NULL, + rfc_slug TEXT NOT NULL, + created_at TEXT NOT NULL DEFAULT (datetime('now')), + PRIMARY KEY (user_id, rfc_slug), + FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE +); + +CREATE INDEX IF NOT EXISTS idx_funder_consents_slug + ON funder_consents (rfc_slug); diff --git a/backend/tests/test_funder_vertical.py b/backend/tests/test_funder_vertical.py new file mode 100644 index 0000000..a2aee70 --- /dev/null +++ b/backend/tests/test_funder_vertical.py @@ -0,0 +1,699 @@ +"""Integration coverage for the §19.2 "per-RFC credential delegation — +funder role + grant shape" candidate folded into §6.7. + +The §6.7 settlement adds an optional `funder:` frontmatter field +(parallel to `owners:` / `arbiters:` / `models:`) plus a +`funder_consents` app-db record. Both halves are required for the +binding to be operationally active — the spec calls this the +"frontmatter + consent hybrid" two-key rule. When in effect, the +funder universe replaces (not augments) the operator universe for the +RFC's picker. + +The tests below prove each branch through the API surface and the +resolver: the two-key rule under all four combinations, the +universe-replaces-not-augments rule, the three revocation paths, the +operator-enabled gate on credential registration, the consent-needs- +credentials gate, the §10.2 PR-draft and §8.12 chat routing through +funder credentials, and the entry parser round-trip for the new +frontmatter field. +""" +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 + + +SEED_BODY = ( + "Open Human Model is a framework for representing humans.\n\n" + "It defines consent, trait, and agency in compatible terms." +) + + +def _set_funder_login(slug: str, login: str | None) -> None: + """Write `cached_rfcs.funder_login` 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 funder_login = ? WHERE slug = ?", + (login, slug), + ) + + +def _install_two_providers(app) -> None: + """Operator universe of two picker keys — `claude` (Anthropic family) + and `gemini` (Google family). The funder tests exercise the + family-aware funder routing, so the operator must enable picker + keys from at least two distinct families.""" + app.state.providers.clear() + app.state.providers["claude"] = FakeProvider("operator claude says hello") + app.state.providers["gemini"] = FakeProvider("operator gemini says hello") + + +def _patch_funder_construct(monkeypatch, marker: str = "from-funder-creds"): + """Intercept `providers.construct_for_funder` so tests can verify + when funder credentials are actually used. Returns a FakeProvider + whose responses are tagged with the marker.""" + from app import providers as providers_mod + funder_provider = FakeProvider(f"TITLE: {marker}\nDESCRIPTION: {marker} description") + + def fake_construct(picker_key, api_key): + # Tag the returned provider with the key it was asked for so the + # test can assert routing went through the funder layer. + funder_provider._last_picker_key = picker_key + funder_provider._last_api_key = api_key + return funder_provider + + monkeypatch.setattr(providers_mod, "construct_for_funder", fake_construct) + return funder_provider + + +# --------------------------------------------------------------------------- +# Two-key rule: frontmatter + consent both required +# --------------------------------------------------------------------------- + + +def test_frontmatter_only_no_consent_falls_back_to_operator(app_with_fake_gitea): + """`funder:` named in frontmatter without a matching `funder_consents` + row is operationally inert per §6.7. The resolver returns None for + the funder-universe lookup; the picker reads as the operator 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") + 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) + # Frontmatter names alice as funder, but alice has not consented + # and has no registered credentials. + _set_funder_login("ohm", "alice") + + sign_in_as(client, user_id=1, gitea_login="ben", + display_name="Ben", role="owner") + r = client.get("/api/rfcs/ohm/models") + assert r.status_code == 200 + assert [m["id"] for m in r.json()["models"]] == ["claude", "gemini"] + + +def test_consent_only_no_frontmatter_falls_back_to_operator(app_with_fake_gitea): + """`funder_consents` row without a matching `funder:` frontmatter + field is operationally inert per §6.7's two-key rule. The picker + reads as the operator universe regardless of what the consenting + user has registered.""" + from fastapi.testclient import TestClient + from app import funder + + 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) + # Alice has consented and registered, but frontmatter does not + # name her as funder. + funder.upsert_credential(2, "anthropic", "alice-anthropic-key") + funder.add_consent(2, "ohm") + + sign_in_as(client, user_id=1, gitea_login="ben", + display_name="Ben", role="owner") + r = client.get("/api/rfcs/ohm/models") + assert [m["id"] for m in r.json()["models"]] == ["claude", "gemini"] + + +def test_both_present_activates_funder_universe(app_with_fake_gitea): + """Frontmatter + consent + credentials match → the picker resolves + to the intersection of the operator's enabled keys and the funder's + registered families. Alice registered Anthropic only; the picker + narrows to `claude`.""" + from fastapi.testclient import TestClient + from app import funder + + 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_funder_login("ohm", "alice") + funder.upsert_credential(2, "anthropic", "alice-anthropic-key") + funder.add_consent(2, "ohm") + + sign_in_as(client, user_id=1, gitea_login="ben", + display_name="Ben", role="owner") + r = client.get("/api/rfcs/ohm/models") + # Operator has {claude, gemini}; funder registered Anthropic only. + # The intersection is {claude}. + assert [m["id"] for m in r.json()["models"]] == ["claude"] + assert r.json()["default"] == "claude" + + +def test_neither_present_operator_universe(app_with_fake_gitea): + """The v1 default and status quo: no `funder:` field and no consent + rows — the picker is the operator 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) + _install_two_providers(app) + + sign_in_as(client, user_id=1, gitea_login="ben", + display_name="Ben", role="owner") + r = client.get("/api/rfcs/ohm/models") + assert [m["id"] for m in r.json()["models"]] == ["claude", "gemini"] + + +# --------------------------------------------------------------------------- +# Funder universe REPLACES operator universe — not augments +# --------------------------------------------------------------------------- + + +def test_funder_universe_replaces_operator_universe(app_with_fake_gitea): + """§6.7's attribution-clean rule: the funder universe replaces the + operator universe. A funder who registered Anthropic only — + irrespective of what the operator otherwise enables — sees the + picker narrowed to keys served by Anthropic. The funder cannot + expand beyond the operator's enabled set, and the operator's other + families are dropped (not added) when the funder is active.""" + from fastapi.testclient import TestClient + from app import funder + + 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) # operator: claude + gemini + _set_funder_login("ohm", "alice") + funder.upsert_credential(2, "anthropic", "alice-anthropic-key") + funder.add_consent(2, "ohm") + + sign_in_as(client, user_id=2, gitea_login="alice", + display_name="Alice", role="contributor") + r = client.get("/api/rfcs/ohm/models") + # The funder is active. Anthropic family alone serves `claude`. + # `gemini` (a Google-family key the operator enabled) is dropped + # because the funder hasn't registered Google credentials. + assert [m["id"] for m in r.json()["models"]] == ["claude"] + + +def test_funder_with_no_matching_family_falls_into_opt_out_shape(app_with_fake_gitea): + """When the consenting funder has no credentials whose family is + served by the operator's enabled set, the resolved universe is + empty — same shape as `models: []`. AI surfaces refuse honestly.""" + from fastapi.testclient import TestClient + from app import funder + + 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) + # Operator universe: only Anthropic-family. + app.state.providers.clear() + app.state.providers["claude"] = FakeProvider("op-claude") + _set_funder_login("ohm", "alice") + # Funder has registered Google credentials only — no overlap. + funder.upsert_credential(2, "google", "alice-google-key") + funder.add_consent(2, "ohm") + + sign_in_as(client, user_id=2, gitea_login="alice", + display_name="Alice", role="contributor") + r = client.get("/api/rfcs/ohm/models") + assert r.json()["models"] == [] + + +def test_funder_intersects_with_models_frontmatter(app_with_fake_gitea): + """§6.6 + §6.7 compose: the resolved list is the §6.6 `models:` list + intersected with the funder's registered universe (not the operator + universe). If `models: [gemini]` is set but the funder only + registered Anthropic, the intersection is empty and the RFC drops + into the opt-out shape.""" + from fastapi.testclient import TestClient + from app import db, funder + + 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) # operator: claude + gemini + _set_funder_login("ohm", "alice") + db.conn().execute( + "UPDATE cached_rfcs SET models_json = ? WHERE slug = 'ohm'", + (json.dumps(["gemini"]),), + ) + funder.upsert_credential(2, "anthropic", "alice-anthropic-key") + funder.add_consent(2, "ohm") + + sign_in_as(client, user_id=2, gitea_login="alice", + display_name="Alice", role="contributor") + r = client.get("/api/rfcs/ohm/models") + # §6.6 names {gemini}; funder universe is {claude}; intersection + # is empty. + assert r.json()["models"] == [] + + +# --------------------------------------------------------------------------- +# Three revocation paths — each restores operator-credentials status quo +# --------------------------------------------------------------------------- + + +def test_funder_withdraws_consent_flips_back_to_operator(app_with_fake_gitea): + """§6.7's first revocation path: the funder withdraws consent. The + next AI-surface call resolves through the operator universe.""" + from fastapi.testclient import TestClient + from app import funder + + 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_funder_login("ohm", "alice") + funder.upsert_credential(2, "anthropic", "alice-anthropic-key") + funder.add_consent(2, "ohm") + + sign_in_as(client, user_id=2, gitea_login="alice", + display_name="Alice", role="contributor") + # Sanity: the binding is active and narrows the picker. + r = client.get("/api/rfcs/ohm/models") + assert [m["id"] for m in r.json()["models"]] == ["claude"] + + # Alice withdraws consent via the §17 endpoint. + r = client.delete("/api/rfcs/ohm/funder/consent") + assert r.status_code == 200 + + # The picker flips back to the operator universe immediately. + r = client.get("/api/rfcs/ohm/models") + assert [m["id"] for m in r.json()["models"]] == ["claude", "gemini"] + + +def test_frontmatter_removal_flips_back_to_operator(app_with_fake_gitea): + """§6.7's second revocation path: an admin/owner/arbiter edits the + frontmatter to remove the field. The cache mirror picks up the + change and the resolution flips back without touching consent.""" + from fastapi.testclient import TestClient + from app import funder + + 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_funder_login("ohm", "alice") + funder.upsert_credential(2, "anthropic", "alice-anthropic-key") + funder.add_consent(2, "ohm") + + sign_in_as(client, user_id=2, gitea_login="alice", + display_name="Alice", role="contributor") + r = client.get("/api/rfcs/ohm/models") + assert [m["id"] for m in r.json()["models"]] == ["claude"] + + # Frontmatter edit removes the field — the cache mirror reflects + # what `_upsert_cached_rfc` would write on the next sweep. + _set_funder_login("ohm", None) + + r = client.get("/api/rfcs/ohm/models") + assert [m["id"] for m in r.json()["models"]] == ["claude", "gemini"] + + +# --------------------------------------------------------------------------- +# §17 /api/users/me/funder surface +# --------------------------------------------------------------------------- + + +def test_funder_self_surface_lists_credentials_without_api_keys(app_with_fake_gitea): + """The read endpoint must never return the API key itself, only the + presence-of-registration metadata. The list is one row per + registered family.""" + from fastapi.testclient import TestClient + from app import funder + + app, fake = app_with_fake_gitea + with TestClient(app) as client: + provision_user_row(user_id=2, login="alice", role="contributor") + _install_two_providers(app) + funder.upsert_credential(2, "anthropic", "secret-anthropic-key") + funder.upsert_credential(2, "google", "secret-google-key") + + sign_in_as(client, user_id=2, gitea_login="alice", + display_name="Alice", role="contributor") + r = client.get("/api/users/me/funder") + assert r.status_code == 200 + body = r.json() + providers_listed = sorted(c["provider"] for c in body["credentials"]) + assert providers_listed == ["anthropic", "google"] + # The API key itself must not appear anywhere in the response — + # the surface returns only registration metadata. + assert "secret-anthropic-key" not in r.text + assert "secret-google-key" not in r.text + assert "api_key" not in r.text + assert body["consents"] == [] + + +def test_register_credential_refused_when_operator_has_not_enabled_family(app_with_fake_gitea): + """§6.7: the funder cannot expand the operator universe. Registering + Google credentials when the operator has only enabled Anthropic + keys is refused.""" + 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") + # Operator enables only Anthropic-family keys. + app.state.providers.clear() + app.state.providers["claude"] = FakeProvider("op-claude") + + sign_in_as(client, user_id=2, gitea_login="alice", + display_name="Alice", role="contributor") + r = client.post( + "/api/users/me/funder/credentials", + json={"provider": "google", "api_key": "alice-google-key"}, + ) + assert r.status_code == 409, r.text + # Anthropic should be accepted since the operator has enabled + # `claude` (an Anthropic-family picker key). + r = client.post( + "/api/users/me/funder/credentials", + json={"provider": "anthropic", "api_key": "alice-anthropic-key"}, + ) + assert r.status_code == 200, r.text + + +def test_consent_refused_without_any_registered_credentials(app_with_fake_gitea): + """§6.7: a consent without any registered credentials would be + operationally inert (the funder universe would be empty). The + endpoint refuses rather than silently writing the row.""" + 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) + + sign_in_as(client, user_id=2, gitea_login="alice", + display_name="Alice", role="contributor") + r = client.post("/api/rfcs/ohm/funder/consent") + assert r.status_code == 409, r.text + + +def test_consent_endpoint_round_trip(app_with_fake_gitea): + """The opt-in / opt-out cycle through the API surface: register a + credential, consent to fund a slug, see it on the self-read, and + withdraw.""" + 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) + + sign_in_as(client, user_id=2, gitea_login="alice", + display_name="Alice", role="contributor") + client.post( + "/api/users/me/funder/credentials", + json={"provider": "anthropic", "api_key": "alice-anthropic-key"}, + ) + r = client.post("/api/rfcs/ohm/funder/consent") + assert r.status_code == 200 + + r = client.get("/api/users/me/funder") + assert r.json()["consents"] == ["ohm"] + + r = client.delete("/api/rfcs/ohm/funder/consent") + assert r.status_code == 200 + + r = client.get("/api/users/me/funder") + assert r.json()["consents"] == [] + + +def test_delete_credential_leaves_consents_intact(app_with_fake_gitea): + """Per §6.7: deleting a credential leaves `funder_consents` rows in + place; the resolved funder universe for affected RFCs simply + shrinks. If the funder later re-registers, those RFCs resume + drawing on the funder's universe without re-consenting.""" + from fastapi.testclient import TestClient + from app import funder + + 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) + funder.upsert_credential(2, "anthropic", "key1") + funder.add_consent(2, "ohm") + + sign_in_as(client, user_id=2, gitea_login="alice", + display_name="Alice", role="contributor") + r = client.delete("/api/users/me/funder/credentials/anthropic") + assert r.status_code == 200 + + r = client.get("/api/users/me/funder") + body = r.json() + assert body["credentials"] == [] + assert body["consents"] == ["ohm"] # consent survives + + +# --------------------------------------------------------------------------- +# §10.2 PR-draft and chat surfaces route through funder credentials +# --------------------------------------------------------------------------- + + +def test_pr_draft_routes_through_funder_credentials(app_with_fake_gitea, monkeypatch): + """When a consenting funder is in effect on an RFC, the §10.2 draft + endpoint constructs a provider with the funder's API key rather + than reusing the operator's instance. The patched + `construct_for_funder` records the picker key and the API key it + was called with — both must come from the funder's registration.""" + from fastapi.testclient import TestClient + from app import db, funder + + 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_funder_login("ohm", "alice") + funder.upsert_credential(2, "anthropic", "alice-anthropic-key") + funder.add_consent(2, "ohm") + + funder_provider = _patch_funder_construct(monkeypatch, marker="alice-funded") + + sign_in_as(client, user_id=1, gitea_login="ben", + display_name="Ben", role="owner") + r = client.post("/api/rfcs/ohm/branches/main/promote-to-branch", json={}) + branch = r.json()["branch_name"] + + 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 + # The funder-constructed provider was the one called — its + # marker comes back in the title. + assert r.json()["title"] == "alice-funded" + # And the funder's API key was the one used. + assert funder_provider._last_api_key == "alice-anthropic-key" + assert funder_provider._last_picker_key == "claude" + + +def test_pr_draft_falls_back_to_stub_when_funder_lacks_default_family(app_with_fake_gitea): + """If the funder is consenting but has not registered credentials + for the family that serves the RFC's default model, the §10.2 + surface falls back to the deterministic stub — same behavior as + operator-side opt-out. The lighter half deliberately does not + fall back to operator credentials per §6.7's attribution-clean + rule.""" + from fastapi.testclient import TestClient + from app import db, funder + + 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_funder_login("ohm", "alice") + # Funder consented but registered Google only. Operator's default + # would have been `claude` (Anthropic family); the resolved + # universe collapses to `gemini`, and the §10.2 draft proceeds + # against `gemini`. We test the *failure* path: register no + # credentials at all and the universe collapses to empty. + funder.add_consent(2, "ohm") # add consent without credentials + # But the add_consent helper bypasses the API gate; the resolver + # still treats this as "consenting funder with no registered + # families" — empty universe → opt-out shape. + + sign_in_as(client, user_id=1, gitea_login="ben", + display_name="Ben", role="owner") + r = client.post("/api/rfcs/ohm/branches/main/promote-to-branch", json={}) + branch = r.json()["branch_name"] + + 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 + # Empty resolved universe → deterministic stub per Slice 3. + assert r.json()["title"] == "Edits to OHM" + + +def test_chat_stream_routes_through_funder_credentials(app_with_fake_gitea, monkeypatch): + """The §8.12 chat surface also picks up the funder routing. A chat + turn on a branch of an RFC with a consenting funder constructs the + provider with the funder's API key — same routing as §10.2.""" + from fastapi.testclient import TestClient + from app import funder + + 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_funder_login("ohm", "alice") + funder.upsert_credential(2, "anthropic", "alice-anthropic-key") + funder.add_consent(2, "ohm") + + funder_provider = _patch_funder_construct(monkeypatch, marker="from-funder") + + 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 == 200, r.text + # The funder's API key was the one used by the constructed provider. + assert funder_provider._last_api_key == "alice-anthropic-key" + assert funder_provider._last_picker_key == "claude" + + +# --------------------------------------------------------------------------- +# Cache round-trip and entry parser +# --------------------------------------------------------------------------- + + +def test_meta_repo_frontmatter_funder_round_trips_through_cache(app_with_fake_gitea): + """The production path: a meta-repo entry whose frontmatter carries + `funder: alice` lands in `cached_rfcs.funder_login` after the next + `refresh_meta_repo` sweep. Absent frontmatter lands NULL.""" + 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") + 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" + "funder: alice\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 funder_login FROM cached_rfcs WHERE slug = 'ohm'" + ).fetchone() + assert row is not None + assert row["funder_login"] == "alice" + + # An entry without `funder:` lands NULL. + no_funder_text = entry_text.replace("funder: alice\n", "") + sha2 = fake._next_sha() + fake.files[("wiggleverse", "meta", "main", "rfcs/beta.md")] = { + "content": no_funder_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 funder_login FROM cached_rfcs WHERE slug = 'beta'" + ).fetchone() + assert row is not None + assert row["funder_login"] is None + + +def test_entry_funder_field_round_trip(): + """parse(serialize(x)) preserves the funder field — None stays None + (no key emitted), populated stays populated. Mirrors the §6.6 + absent/empty/populated test for `models:`.""" + from app import entry as entry_mod + + absent = entry_mod.Entry(slug="x", title="X") + assert absent.funder is None + text_absent = entry_mod.serialize(absent) + assert "funder:" not in text_absent + round_absent = entry_mod.parse(text_absent) + assert round_absent.funder is None + + populated = entry_mod.Entry(slug="y", title="Y", funder="alice") + text_populated = entry_mod.serialize(populated) + assert "funder: alice" in text_populated + round_populated = entry_mod.parse(text_populated) + assert round_populated.funder == "alice" diff --git a/docs/DEV.md b/docs/DEV.md index 4903b6c..a0f5e85 100644 --- a/docs/DEV.md +++ b/docs/DEV.md @@ -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/`, + `POST /api/rfcs//funder/consent` (refused if the user has + no registered credentials — a consent without a universe would + be inert), and `DELETE /api/rfcs//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