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