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:
@@ -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,
|
||||
|
||||
@@ -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
|
||||
# ---------------------------------------------------------------
|
||||
|
||||
@@ -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
@@ -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>' "
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
@@ -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(
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
@@ -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
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user