2026-06-12-inc3-capability-grants-delegation
ADR / Ship Log: Capability Grants + Verified Delegation (Increment 3)
- Status: PROPOSED (chief-architect synthesis, post-adversarial panel — 3 angles × 9 attack lenses, all findings resolved or explicitly deferred below)
- Date: 2026-06-12
- Stacks on: Increment 2 (
2026-06-11-inc2-unified-scope-enforcement.md), Increment 1.5 (audit-chain crash safety), Increment 1 (scoped agent grants). - Winning angle: first-class-object (durable, BR-signed, DB-anchored grants with online attenuation), grafting from capability-token (published JWKS, exportable per-link-verifiable chain bundle, namespaced scope vocabulary) and from minimal-reuse (envelope-canonicalization discipline, rollout phasing). The pure offline-token angle is REJECTED: offline attenuation amputates the evidence half of the thesis (delegation events invisible to the audit chain), and its per-replica revocation/PoP caches reintroduce the exact deploy-mortal state class Inc 1 documented.
Context
Increment 2 unified model-scope enforcement behind a deny-wins composer but hard-deferred delegation: act_chain.scope is an unsigned, self-asserted string[] — enforcing it is a privilege-escalation primitive. Recon confirms there is no partial wiring to finish: act_chain is written [] once (open.ts:152), echoed once (close.ts:110), and the liaison/delegate.ts the store comment cites does not exist. Separately, the only scoped credential today — the liaison plan — is single-use (burns at guardian commit), HMAC-signed under the shared AGENT_JWT_SECRET, and lives in a per-replica in-memory LruMap that evicts under pressure and dies on deploy.
Increment 3 ships two things:
- Capability Grant — a durable, multi-use, revocable, tenant-scoped authority object ("subject S may use scope X until E, derived from parent P"), persisted in Postgres, Ed25519-signed by BR.
- Verified delegation — attenuation chains where every link is signed, chain-bound, and monotone non-widening (
child ⊆ parent ∩ delegator's live authority), finally emitting the reserveddelegation_scope_exceededreason code.
The panel surfaced one finding that reshapes the product semantics and must be stated up front: a grant that is only an intersection source grants nothing and its revocation revokes nothing — a holder with a broad apikey simply omits the header and reverts to broad base authority. Increment 3 therefore ships grant-bound credentials (deny-if-absent) in the same increment, so a grant can be the \_positive unit of authority without ever becoming a widening primitive (the composer still only intersects; "positive" is achieved by the bound credential contributing deny-all when no verified grant is present).
Decision
Delegation is enforceable only when every link is signed by a key only BR controls, anchored to a durable row only the tenant can reach, subset-checked with the same property-tested algebra enforcement uses, and evidenced in the same tamper-evident chain — and a grant is only a product when something can be made to require it.
Core commitments (each resolves named attack findings):
- BR-mediated delegation, honestly labeled. Principals do not hold signing keys today. "A delegated to B" = BR's Ed25519 signature over A's _authenticated_ attenuation request, with A's auth method recorded inside the signed envelope and in evidence, so the chain never claims more assurance than it verified. Holder-of-key/DID is deferred (below), not faked.
- Dedicated grant signing key ring — NOT the trust-envelope bundle. The envelope
EnvelopeKeyBundleis a 2-slot ring sized for 5-minute tokens (keys.ts:25-30: previous pruned ~10 min post-rotation); signing 24h–30d grants with it forces either mass grant invalidation on rotation or an envelope-rotation freeze. Grants get their own kid-indexed key ring with retention ≥ max outstanding grant lifetime. (Resolves the rotation-bricking finding raised independently by three lenses.) - DB-anchored verification, zero-lag revocation by default. The credential on the wire is an id, not a token; validity lives in the live row. No verify cache in enforce mode by default — revocation is a product promise, not a perf detail. (Resolves cold-cache fail-open and cross-replica staleness findings; the optional cache, if enabled, is tenant-keyed and bounded.)
- One verification site, one emission site. Grant resolution is memoized per-request; the chain-walk scope re-check lives in the composer branch (single
delegation_scope_exceededemitter); guardian handles credential structure only. (Resolves double-burn / dual-recording-site findings.) - Everything mode-gated, fail-safe to off.
off⇒ the header is _ignored entirely_ — no lookup, no lifecycle deny, no new 403 surface outside the rollout flag. Safe because an unbound grant is purely subtractive, and bound credentials have their own dial. (Resolves the "lifecycle deny has no kill switch" finding.) - No signed-but-unenforced fields.
ceiling_usdis OUT of the v1 envelope (a signed ceiling that meters nothing is false advertising in an evidence product). Every grant-bearing audit row stampsenforced_dimensions. (Resolves the evidence-honesty findings.)
Object model + canonicalization
Signed envelope
New module src/security/capability-grant/schema.ts. One exported field list; sign and verify iterate the SAME list — the canonicalizeEnvelope same-bytes discipline from liaison-contract-signer.ts:89-100, including its exact serialization scheme (JSON.stringify of an ordered object, scope arrays kept as JSON string-arrays re-run through normalizeScopeList at both sign and verify — NOT delimiter-joined, which would introduce separator-injection ambiguity).
export const GRANT_VERSION = 1;
// Canonicalization order. NEVER reorder; append + bump GRANT_VERSION.
export const GRANT_SIGNED_FIELDS = [
"v", // GRANT_VERSION
"grant_id", // uuidv7
"tenant_id", // uuid — inside the signature (anti confused-deputy)
"parent_grant_id", // uuid string, or literal "" for root (NULL column ⇔ "" signed — mapping
// specified HERE so root/non-root signatures cannot drift)
"depth", // root = 0; child = parent.depth + 1; ≤ GRANT_MAX_DEPTH
"issuer_principal", // canonical principal URI (scheme registry below)
"issuer_auth_method", // "api_key" | "jwt" | "mtls" — how issuance was authenticated (SIGNED)
"subject_principal", // canonical principal URI of the holder
"subject_auth_method", // minimum auth method the subject must present (SIGNED)
"scope", // namespaced strings: "model:{canonicalGrantLabel}", "tool:{name}".
// Reserved namespaces (parse-rejected v1): "resource:", "http:", "mcp:".
// normalizeScopeList'd at sign AND verify. tools omitted ⇒ no tool: entries
// ⇒ tool deny-all (fail-closed, mirrors buildScopedEnvelope).
"max_uses", // integer; 0 = unlimited within TTL
"issued_at", // epoch seconds
"expires_at", // epoch seconds, ABSOLUTE; child clamped ≤ parent (clamped value is signed)
"kid", // grant-ring key id used to sign (SIGNED — blocks key-confusion on rotation)
] as const;
Deliberately absent: ceiling_usd (deferred — see Deferred), prompt_fingerprint / pinned model / liaison_id (single-use plan concerns; the grant is standing authority).
Principal URI scheme registry (frozen v1, signed): br:apikey:{apiKeyId} | br:agent:{agentId} | SPIFFE URI verbatim. One exported formatPrincipal(ctx) / parsePrincipal(uri) pair used at issue, attenuate, check, and enforcement — asymmetry here is an auth bypass, so it lives in one place (src/security/capability-grant/principal.ts). The URI form means external schemes (did:, external SPIFFE trust domains, OIDC issuers) can be added later without an envelope version break.
Signing: detached Ed25519 (crypto.sign(null, bytes, key)) over canonicalizeGrant(envelope) using the grant key ring (below). Verify: reconstruct canonical bytes from the stored envelope_json — never from caller input and never from the relational columns — select the ring key by the signed kid, constant-time compare, fail-closed on version/kid/byte mismatch.
Grant signing key ring (new key class — explicit divergence from all three proposals' "reuse the envelope bundle")
src/security/capability-grant/keys.ts, cloning the Secrets-Manager loader _pattern_ from trust-envelope/keys.ts:113-173 (race-safe bootstrap, fail-closed-fatal in prod, ephemeral in local dev) at a new suffix ${secretsManagerPrefix}grant-signing-key. Shape:
{ currentKid: string,
keys: [{ kid, publicKey, privateKey, createdAt, retiredAt? }, ...] } // kid-indexed RING, not a 2-slot bundle
Retention invariant: a key may be pruned only when SELECT max(expires_at) FROM capability_grants WHERE kid = $kid AND status='active' has passed. Rotation = add new key + flip currentKid; old kids keep verifying. Compromise response = mark kid retired ⇒ every grant signed under it fails verification immediately (built-in targeted mass-revocation), per-tenant cleanup via cascade revoke. The bundle is injected explicitly from boot (src/gateway/boot/boot-api.ts), mirroring how the envelope middleware receives its keys — no phantom global accessor. Public halves published at GET /.well-known/brainstorm/grant-keys (JWKS, all unretired kids).
Persistence
Two tables in src/db/schema/security.ts, RLS blocks copied from completionAudit (four pgPolicy with tenantRlsUsing at :29 + system-admin bypass-select), access only via withTenant(getDb(), tenantId, ...):
grant_id uuid PK, tenant_id uuid NOT NULL,
parent_grant_id uuid NULL REFERENCES capability_grants(grant_id),
root_grant_id uuid NOT NULL, -- denormalized; = grant_id for roots (subtree ops)
depth int NOT NULL DEFAULT 0 CHECK (depth <= 8), -- hard cap; tenant dial may be lower
issuer_principal text, issuer_auth_method text,
subject_principal text, subject_auth_method text,
scope text[] NOT NULL, -- NON-AUTHORITATIVE query index (see below)
max_uses int NOT NULL DEFAULT 0, use_count int NOT NULL DEFAULT 0,
issued_at/expires_at timestamptz NOT NULL,
status text NOT NULL DEFAULT 'active', -- 'active' | 'revoked' | 'expired'
revoked_at timestamptz, revoked_by text, revocation_reason text,
kid text NOT NULL, signature text NOT NULL,
envelope_json jsonb NOT NULL, -- AUTHORITATIVE signed bytes
created_at timestamptz DEFAULT now()
INDEX (tenant_id, subject_principal, status); INDEX (tenant_id, root_grant_id); INDEX (tenant_id, parent_grant_id)
grant_events -- PLAIN append-only lifecycle ledger: id, tenant_id, grant_id, event_type,
-- actor_principal, actor_auth_method, detail jsonb, created_at. NO own hash chain.
envelope_json is authoritative; columns are derived indexes. Enforcement reads scope/expiry/principals from the verified envelope only. Column↔envelope drift fires an integrity alert + repair job and is recorded, but does not by itself deny — so a benign backfill/re-normalization cannot brick live grants, while actual tampering still fails the signature. (Resolves the dual-source-of-truth bricking finding.)
No second hash chain. grant_events is plain append-only; the _security-significant_ lifecycle events (issue / attenuate / revoke / kid-retire) are ALSO emitted through the existing crash-safe chained audit store (createCompletionAuditStore().logChained, advisory-lock serialization from Inc 1.5) with distinct outcomes. One chain, one verifier, zero re-run of Increment 1.5. (Resolves the "casually re-opens Inc 1.5" finding.)
TTL / usage / revocation semantics
expires_atabsolute, signed. Default 24h (security.grants.defaultTtlSeconds), max 30d (security.grants.maxTtlSeconds). Child clamped ≤ parent at mint (clamped value signed) AND re-checked per link at verify. Lazy expiry at verify + sweeper (modeled ondelegation-ttl.ts) flipsstatus='expired'+grant_eventsrow.max_uses(optional): check at verify (read-only), consume at the dispatch-commit point — immediately after the composer allows, in the same synchronous span where_planScopeContextis set, via atomicUPDATE ... SET use_count = use_count + 1 WHERE grant_id=$1 AND tenant_id=$2 AND status='active' AND (max_uses = 0 OR use_count < max_uses) RETURNING use_count; zero rows ⇒ denyuses_exhausted(concurrent race fails closed). Rollback mirror (rollbackGrantUse,_grantUseContext) for the late routerModelScopeErrorbranch only, exactly likerollbackPlanCommit(guardian.ts:832-846). Composer denials (which return BEFORE consume) never burn a use, and a late executed-modelModelScopeErrorrolls its use back. Correction (G4 finding 7/P3): post-consume failures OTHER thanModelScopeError— provider 5xx, outbound-guardrail sever, budget reservation kill — do NOT roll back and DO burn a use, exactly likerollbackPlanCommit(which also only un-commits onModelScopeError). The earlier blanket claim that guardrail/budget/provider-500 never burn was inaccurate; the impact is bounded — only the subject-bound grant holder can trigger it against their OWN finite-use grant (self-griefing, not a cross-principal DoS), and broadening the rollback was rejected as risk-disproportionate (distinguishing "did not execute" from "executed then failed mid-stream" is the same subtlety the plan precedent deliberately avoids). Warn mode increments observationally but never denies on exhaustion (recordswould_denyuses_exhausted) — so warn-soak burn-rate data is real (resolves the warn-no-consume finding). Verify exception on amax_uses-finite grant in enforce ⇒ deny (no fail-open keep-alive).- Revocation:
POST /v1/grants/{id}/revokeflips status + cascades to the subtree in one transaction (root_grant_idindex). Non-cascade is bookkeeping-only — the chain walk fails closed on any revoked ancestor regardless. Every revocation lands in the chained audit store, so a direct-DB un-revoke (statusis mutable and necessarily unsigned) is tamper-evident against the chain; a periodic verifier cross-checksstatustransitions against chained revocation events. Zero-lag by default: no verify cache in enforce mode. Optional dialsecurity.grants.verifyCacheTtlMs(≤ 5000, default 0); if enabled, the cache is keyed(tenant_id, grant_id)andresolveGrantassertsenvelope.tenant_id === request.tenantIdon every return path, cache hit or DB. (Resolves cache-poisoning + unsigned-lifecycle findings.)
Delegation: issuance and the verification algorithm
Root issuance — POST /v1/grants (api_key auth ONLY, perm grants.issue)
Agent-JWT root minting is forbidden in v1: an agent JWT carries no model allow-list, so normalizeApiKeyModels(undefined) ⇒ undefined ⇒ source skipped ⇒ [''] — the "⊆ minter's scope" bound degenerates to ⊆ everything, reintroducing self-issue escalation under a valid BR signature. (Resolves that finding; revisit when agent_profiles grows a model allow-list.) Root scope must satisfy rootModels ⊆ composeModelScope([{origin:'api_key', allowed: issuerKey.allowedModels}]) — unconditionally; an unrestricted (null-allowlist) key is the _only_ broad-mint path, and it is DB-trusted. model: permitted at root only for such keys; scope entries canonicalized via canonicalGrantLabel/parseModelRouterRef exactly as plan.ts:283-355; empty model scope rejected (grant_models_empty).
Attenuation — POST /v1/grants/{id}/attenuate (any auth; authorization = identity)
formatPrincipal(ctx)→ P (URI + auth method). RequireP == parent.subject_principalAND caller's auth method satisfies the parent's signedsubject_auth_methodfloor (or auditedgrants.attenuate_anyadmin).- Parent must be
active, unexpired, chain-verified (full walk, below). - Double subset bound (resolves the live-authority finding): child scope must satisfy
child == child ∩ parent(viacomposeModelScope/composeToolScopeequality — ⊆ expressed in the property-tested intersection itself, so mint and enforcement can never drift) ANDchild ⊆ caller's live effective scope(composed over the caller's current api_key/br_scope sources) — an over-minted parent or a since-narrowed delegator cannot launder stale breadth.*forbidden in attenuation requests; omitted scope ⇒ inherit parent's verbatim. depth = parent.depth + 1 ≤ min(security.grants.maxDepth / default 4 /, 8 / DB CHECK; both derived from one exported GRANT_MAX_DEPTH constant /).- Build envelope (fail-closed constructor mirroring
buildScopedEnvelope), sign withcurrentKid, insert row +grant_events('attenuate')in one transaction; emit the chained audit event AFTER that commit, best-effort (see G4 finding 5 below — the chained write crosses into the Inc 1.5 advisory-locked audit store and is NOT folded into the grant tx).
Verification — resolveGrant(ctx) in src/security/capability-grant/verify.ts
Memoized on ctx as a settled promise (_grantResolution) — guardian and completions handler share ONE resolution per request (the resolveScopeModeForRequest memo pattern; resolves the double-verify/double-burn finding). Mode is resolved FIRST:
0. mode := resolveScopeModeForRequest(store, "delegation", tenantId)
mode == "off" AND key not grant-bound → IGNORE header entirely (no lookup, no deny, no evidence).
1. id := X-BR-Grant-Id. Absent → { source: undefined } (composer skips; grant-bound keys handled in §enforcement).
2. Load (tenant_id, id) under withTenant; assert envelope.tenant_id == request tenant on EVERY return path.
Missing → INVALID(not_found).
3. Subject binding: formatPrincipal(ctx) == leaf.subject_principal AND caller auth method ≥
leaf.subject_auth_method AND ≥ tenant dial security.grants.minSubjectAuth. Else INVALID(subject_mismatch).
(A bare agentId reachable via both mTLS and forgeable HS256 JWT can no longer downgrade: the method
is part of the signed binding. Resolves the type-confusion finding.)
4. Single recursive-CTE walk leaf→root (≤ depth+1 rows, one round trip). For EVERY link:
a. Ed25519 verify over canonicalizeGrant(envelope_json), key selected by signed kid (ring lookup;
retired kid ⇒ INVALID(signature_invalid)).
b. status=='active', now < expires_at, link.depth == parent.depth + 1, link.expires_at <= parent.expires_at,
link.issuer_principal == parent.subject_principal (continuity; root exempt).
Any failure → INVALID(revoked | expired | chain_invalid | signature_invalid).
5. max_uses precheck (read-only; consumption happens post-composer-allow, see semantics above).
6. Compute runningScope = fold composeModelScope/composeToolScope root→leaf over envelope scopes
(strip namespaces at the type boundary via normalizeDelegationModels). Return
{ effectiveModels, effectiveTools, leaf, chain (ids + subjects, parent-first), depth, widenedAtHop? }.
NOTE: the ⊆ VIOLATION DECISION on widenedAtHop is NOT made here — it is handed to the composer
branch, the single delegation_scope_exceeded emission site. The effective scope handed onward is
the running intersection, so a stored widening is structurally inert either way.
INVALID handling: enforce → deny via the taxonomy below; warn → proceed without the source + would_deny
evidence; off → unreachable (step 0). DB error: enforce ⇒ deny verification_error for grant-bearing /
grant-bound requests; warn/off ⇒ proceed + evidence.
act_chain becomes derived-only output: populated from the verified chain's subject lineage (parent-first) into liaison sessions and the close summary — never an enforcement input. The phantom liaison/delegate.ts comment at liaison-store.ts:8 is corrected to point at /v1/grants/{id}/attenuate.
Enforcement integration (exact files + symbols)
v1 enforcement surface = completions + guardian-fronted liaison routes, explicitly. On any other guardian-fronted route (e.g. /v1/embeddings, which has no composer), a present X-BR-Grant-Id is rejected (unsupported_route) in warn(evidence-only)/enforce — never silently passed. Silent-pass is the one unacceptable outcome. (Resolves the split-chokepoint embeddings gap; full multi-route enforcement is deferred work, named below.)
Division of labor (one home per concern): guardian/middleware = credential structure (signature, chain continuity, revocation, expiry, subject binding → delegation_proof_invalid family, mode-gated). Composer = ALL scope decisions including the chain ⊆ re-check (→ delegation_scope_exceeded, single site).
Lockstep enum/closed-literal checklist — missing any one silently drops the source (Inc 2 Finding bug-class). All members derive from ONE shared source-list constant with an exhaustiveness test:
| File | Change | |
|---|---|---|
src/security/effective-scope.ts:49 | ScopeOrigin += "delegation"; add normalizeDelegationModels beside normalizeApiKeyModels:182 (strips model: namespace, re-canonicalizes via makeBareIdResolver — belt-and-suspenders). Composer algebra untouched. | |
src/security/scope-match.ts:42,45 | EnforceableScopeSource += "delegation"; SCOPE_SOURCE_FLAG += delegation: "enforce_delegation_models". resolveScopeMode/resolveScopeModeForRequest unchanged. | |
src/api/routes/completions/composed-scope.ts | ComposedScopeInput (:37-57) += delegationModels?, delegationChainCheck? (the widenedAtHop handoff); new branch parallel to api_key/br_scope (:401-591) emitting EnforcedScopeSource/ObserveScopeSource origin delegation, reason delegation_scope_exceeded with `stage: "chain" \ | "compose". Also extend the three closed literals the panel found: EnforcedScopeSource.origin (:86), ObserveScopeSource.origin (:105), ScopeModeResolver source param (:61). Add the delegation analog of apiKeyModelDecision (:263) and wire it into the prompt-cache admission guard (prompt-cache.ts` — the Inc 2 Finding-1 bypass class, closed day one). |
src/api/routes/completions/index.ts | :1132-1139 await resolveGrant(ctx), pass delegationModels; extend enforceComposedScopeModeGate (:493); insert "delegation" into SANDBOX_DENIAL_PRECEDENCE (:1776) as plan > delegation > br_scope > api_key AND rewrite the trust-ranking comment block above it (an Ed25519 grant outranks the HMAC plan on _signature_ strength, but the plan stays top as the narrowest single-request intent — the rationale must say so, not just the array). Consume max_uses post-allow (see semantics); set _grantUseContext beside _planScopeContext for the ModelScopeError rollback. | |
src/api/middleware/guardian.ts | Hoist tool extraction + matchToolScope + per-request checks out of the if (planIdHeader) block (:782-806) into a shared helper invoked when plan OR grant is present; grant tool enforcement gated by resolveScopeModeForRequest("delegation", …) — never by settings.guardian.mode (:240-246), so the two dials cannot fight. Grant tools ∩ plan tools via composeToolScope before matchToolScope:795. Structural denials via denyAndAudit:615. Unsupported-route rejection lives here. | |
src/router/model-router-select.ts, model-router.ts | No changes. Composed grant flows through RouteCompletionParams.allowedModels → assertModelInGrant:82 / observeModelGrant:132 / computeOutOfGrantExclusions:186. The boundary stays the single authoritative gate. | |
src/db/schema/api-keys.ts + src/api/middleware/auth.ts | Grant-bound credentials: new grantBound boolean on api_keys (+ per-agent equivalent later). A grant-bound key contributes [] (deny-all) as its api_key composer source unless a verified grant is present this request. Tenant dial security.grants.require ∈ {off,warn,enforce} (flag require_grant): missing/invalid grant on a bound credential ⇒ would_deny (warn) / 403 grant_required (enforce). This is what makes the grant the unit of authority and revocation real. (Resolves the header-omission / "grant never grants" findings.) | |
src/infra/feature-flags.ts | No code change; flag rows grants_api, enforce_delegation_models, require_grant — all fail-closed → false = fail-SAFE off. | |
src/security/caf/behavior-certificate.ts | Fence in this increment: delegation_chain issuance/consultation behind a default-off flag, deprecation note pointing at grants, remove acceptance of the dev-only-${pid} fallback key outside dev. Cheap; removes the fail-open zombie from the security story. |
Evidence taxonomy
All via recordAuthorizationDenial + the existing crash-safe chain. Reason codes (NON-hashed reasonCode, taxonomy registered at helpers.ts:364):
delegation_scope_exceeded— un-reserved, finally emitted. Single site: the composer delegation branch. Details:grant_id,root_grant_id, hop index, offending entries,stage: "chain"|"compose"|"boundary".delegation_proof_invalid— structural family, +1 code withdetails.cause ∈ {not_found, subject_mismatch, signature_invalid, chain_invalid, revoked, expired, uses_exhausted, depth_exceeded, unsupported_route, verification_error}(matches existing taxonomy granularity; avoids +9 codes).grant_required— bound credential, no valid grant presented.
Allow rows: new grant_context jsonb column on completion_audit (one honest migration — there is no spare column; toolVerdicts is semantically wrong to reuse): {grant_id, root_grant_id, depth, chain_subjects[], issuer/subject_auth_method, kid, enforced_dimensions: ["models"] | ["models","tools"], mode}. The enforced_dimensions stamp means evidence never overstates coverage (resolves the evidence-honesty finding). would_deny rows: identical payloads, outcome warned, Inc 2 pattern, including boundary onObserveWouldDeny for auto requests. Lifecycle: issue/attenuate/revoke/kid-retire → grant_events row + chained audit event (the delegation _act_ is itself tamper-evident proof — the CloudTrail half of the pitch).
Rollout (off|warn|enforce, fail-safe, kill switch, runbook)
Two independently dialed axes, both Inc 2 plumbing:
- Scope source: flag
enforce_delegation_modelscohort → tenant dialsecurity.scopeEnforcement.delegation→ default off = header ignored = zero behavior change. - Require-grant: flag
require_grantcohort → tenant dialsecurity.grants.require→ default off. Enforce here implies grant verification runs regardless of the scope dial.
Phases: 0. Dark: migrations (capability_grants, grant_events, completion_audit.grant_context, api_keys.grantBound), key-ring bootstrap, routes behind grants_api (off). Zero request-path change.
- Mint + observe:
grants_apion for design partners; header accepted, mode off-by-default; canonicalization/perf soak (target p99 verify < 5ms; one CTE + ≤5 Ed25519 verifies ≈ 50-100µs each). - Warn soak:
would_deny+delegation_proof_invaliddashboards; gating queries mirror the Inc 2 runbook (outcome='warned'over 7 days; stale-label audit of stored grant scopes against the live catalog). - Enforce by cohort:
rollout_percent/tenants_allowed; thensecurity.grants.require = warn → enforcefor grant-bound keys. - Convergence (separate flags): plan signer HMAC→Ed25519 (the
liaison-contract-signer.ts:28upgrade), plan mintable _under_ a grant.
Fail-safe: flag-store outage ⇒ both dials resolve off ⇒ header ignored / bound keys legacy — NEVER a mass-403. Load-bearing; do not "fix" to fail-open. Kill switches: per-tenant tenants_blocked on either flag (≤60s, no redeploy); security.grants.enabled=false freezes issuance independently of enforcement; kid-retire is the cryptographic kill. Runbook: deploy(off) → canary warn → run gating queries, fix findings → enforce dial → ramp → (separately) bind keys.
API surface + lockstep checklist
| Route | Auth / permission | Notes |
|---|---|---|
POST /v1/grants | api_key, grants.issue | root mint; ⊆ issuer's effective scope, unconditional |
POST /v1/grants/{id}/attenuate | any principal; identity == parent subject (+ method floor) | double subset bound |
POST /v1/grants/{id}/revoke | issuer, ancestor subject, or admin ROLE (audited) | cascade default true. grants.revoke only gates route entry — the handler requires issuer-or-ancestor-subject for every non-admin principal (Codex P1 amendment) |
GET /v1/grants/{id} / GET /v1/grants?subject=&status=&root= | grants.read | record + verified chain summary |
GET /v1/grants/{id}/chain | grants.read | export bundle: every link's envelope_json + signature + JWKS refs — third-party verifiable offline |
POST /v1/grants/{id}/check | api_key, grants.check | standalone PDP: {principal, requested:{models?,tools?}} → signed allow/deny verdict + evidence row. Makes the chokepoint _one client_ of the grant plane, so non-BR PEPs (customer gateways, MCP servers) can consume grants — the "plane, not router feature" fix. |
GET /.well-known/brainstorm/grant-keys | public | JWKS, all unretired kids |
| Request-side | — | X-BR-Grant-Id on completions + liaison routes |
Lockstep (same release train, pre-push hook enforced): TS SDK (grants.issue/attenuate/revoke/get/list/chain/check + grantId request option), Py SDK (sync + async same), MCP (grant_issue, grant_attenuate, grant_revoke, grant_inspect, grant_check; header pass-through), docs (concepts "Plans vs Grants vs Delegation", chain-format + canonicalization spec, revocation-latency statement, rollout guide), website (authorization-plane positioning), ship-log (this file → ACCEPTED).
Threat model (attack → defense)
| # | Attack | Defense |
|---|---|---|
| 1 | Self-issued / forged grant | Only BR mints; Ed25519 over envelope_json with ring key in Secrets Manager; header is an id, not a credential — no row, no authority. Root mint ⊆ issuer's effective scope unconditionally; agent-JWT root mint forbidden v1. |
| 2 | Chain forgery / splice / re-parent | Signature covers parent_grant_id("" mapping specified), depth, tenant_id, both principals + auth methods, scope, expiry, kid; continuity check issuer == parent.subject; envelope_json authoritative; lifecycle in the tamper-evident chain. Forging needs signing key AND tenant-scoped DB write — two compromises. |
| 3 | Scope widening at a hop | Triple, all one algebra: mint ⊆ (parent ∩ delegator-live); enforcement re-walk running intersection (widening stored ⇒ inert + delegation_scope_exceeded); deny-wins composer at request time. |
| 4 | Replay of revoked grant | Live status per request (no cache default in enforce); cascade revoke; chain walk checks every ancestor; un-revoke is tamper-evident vs the chained revocation event + periodic cross-check. Residual with optional cache: ≤ verifyCacheTtlMs per replica, documented. |
| 5 | Revocation bypass by header omission | Grant-bound credentials + security.grants.require: bound key without a verified grant composes deny-all / 403 grant_required. Unbound grants remain honestly subtractive. |
| 6 | Subject-binding downgrade (forged HS256 JWT for an mTLS agent) | subject_auth_method signed in the envelope; binding compares URI + method floor; tenant minSubjectAuth dial; evidence records the method actually presented. |
| 7 | Confused deputy cross-tenant (incl. cache poisoning) | tenant_id inside the signature; (tenant_id, grant_id) lookup under withTenant + RLS; tenant assert on EVERY return path; optional cache keyed (tenant_id, grant_id). |
| 8 | Signer key compromise | Dedicated ring (envelope rotation domain untouched); kid signed (no key-confusion); kid-retire = instant targeted mass-revocation; forged signature without a row is inert. Residual: key+DB compromise = game over, as with any root key — stated. |
| 9 | TTL races / clock skew | Child expiry clamped into signed bytes + per-link recheck; expiry/scope/consume in one chokepoint span (plan-commit discipline, guardian.ts:817-823); single verifier clock (BR). |
| 10 | Use-burn griefing / double-burn | Consume only post-composer-allow; per-request memoized resolution (one verify, one increment); ModelScopeError rollback mirror; composer denials never burn. Residual (G4 P3): a post-consume provider-5xx / guardrail-sever / budget-kill burns a use (no rollback, like rollbackPlanCommit) — bounded to self-griefing (subject-bound holder, own grant), not cross-principal. |
| 11 | Stolen grant id | Worthless alone: subject binding + method floor; the id only ever narrows (or, when bound, gates) the authenticated subject. |
| 12 | DoS via deep chains / mint flood | GRANT_MAX_DEPTH (signed + DB CHECK, one constant); per-tenant active-grant cap; single bounded CTE; 403s recorded once. |
Test obligations (adversarial proof set)
- Property: canonicalize→sign→verify round-trip for arbitrary envelopes; any single-byte mutation of any signed field ⇒ invalid; ∀ chains
effective ⊆ root.scopeandeffective ⊆ delegator-live-at-mint; NULL↔"" parent mapping stable across root/child. - Chain fuzz: splice across chains, reorder, re-parent, cross-tenant parent, depth skip, continuity break, expired/revoked ancestor, stored-widened hop ⇒ inert + evidenced.
- Enum exhaustiveness: one shared source-list constant covers
ScopeOrigin,EnforceableScopeSource,SCOPE_SOURCE_FLAG,ComposedScopeInput,EnforcedScopeSource.origin,ObserveScopeSource.origin,ScopeModeResolver,SANDBOX_DENIAL_PRECEDENCE. - Mode matrix: off ⇒ header ignored (zero behavior change, no lookup); warn ⇒ would_deny + proceed + observational use-count, never denies; enforce ⇒ 403 + consume-post-allow; flags outage ⇒ off;
tenants_blockedkill; DB outage posture per mode. - Single-site invariants: guardian+handler share one
_grantResolution(one verify, one increment per request);delegation_scope_exceededemitted exactly once; cache-admission guard parity (Inc 2 Finding-1 class); embeddings-route header rejection (warn=evidence, enforce=deny, off=ignore). - Bindings: auth-method downgrade rejected (JWT vs mTLS same agentId); grant-bound key without grant ⇒ deny-all source /
grant_required; revocation immediate with cache off, bounded with cache on; kid rotation (old kid verifies until retention; retired kid ⇒ invalid); use-burn rollback onModelScopeErroronly. - Boundary: out-of-grant executed model on every path (streaming/non-streaming/agentic/swarm/sandbox/cache), warn-mode auto
would_denyviaobserveModelGrant.
Deferred (explicit — no silent drops)
- Holder-of-key / DID / offline attenuation — no caller-side PKI exists; offline hops amputate delegation evidence (the panel's strongest thesis attack on the token angle). The URI principal registry,
kiddiscipline, and/chainexport bundle are the forward-compatible hooks. Revisit as "plan v2.2 / DID" perliaison-contract-signer.ts:28. ceiling_usdon grants — dropped from the v1 envelope: a signed-unmetered ceiling is false evidence. Spend bounds route through the already-enforced/v1/agent/delegatePostgres budget slice; convergence work = budget slices accept/record agrant_idso budget + scope delegation share an identity spine.- Agent-JWT root minting — until
agent_profilescarries a model allow-list, the ⊆-self bound is unbounded (escalation primitive). Attenuation by agents is supported (bounded by the parent + live scope). - External identity federation (customer SPIFFE domains, OIDC trust anchors) — scheme registry frozen now so it's an additive change, not an envelope break;
security.grants.trustAnchorsis named follow-on work. resource:/http:/mcp:scope namespaces — reserved and parse-rejected v1; algebra is string-based and needs no change when they land.- Non-completions enforcement routes (embeddings, images, audio) — v1 rejects the header there rather than silently passing; wiring each route through a composer is named follow-on.
- Plan signer HMAC→Ed25519 convergence; behavior-cert
delegation_chainremoval — fenced this increment, deleted later.
Honest risks
- DB on the hot path. Grant-bearing completions gain one indexed CTE read (+ one conditional write when
max_usesset, enforce only). Zero-lag revocation is bought with this coupling; the cache dial reintroduces bounded staleness as a _documented security property_ if anyone turns it on. - Two credential systems until convergence. HMAC/in-memory/single-use plans beside Ed25519/durable/multi-use grants: two signers, two lifecycles, a four-way precedence list. Operators carry both mental models through Phase 4.
- BR-mediated, not holder-of-key. Chain integrity is cryptographic; _issuance_ authenticity is only as strong as the recorded auth method — for
br:agent:+ JWT that floor is HS256 under a shared secret. The signed*_auth_methodfields andminSubjectAuthmake this visible and gateable, not gone. - Grant-bound adoption friction. Deny-if-absent is the product but also a foot-gun: a bound key whose grant expires hard-stops an agent. The warn tier on
security.grants.requireand expiry-horizon dashboards are the mitigation; expect support load. statusis necessarily mutable and unsigned. Un-revoke by a DB writer is tamper-_evident_ (chained event + cross-check), not tamper-_proof_. Stated residual; the alternative (CRL re-signing) was rejected as a worse availability trade. Refinement (G4 finding 5): the chained lifecycle event is emitted AFTER (not within) theinsert row + grant_eventstransaction and is best-effort — a crash between the commit and the chained write can leave a live grant / revoked subtree with the in-txgrant_eventsledger row but NO chained-chain link. The authoritative, crash-safe record of the act is the in-txgrant_eventsledger row; the chained event is the SUPPLEMENTARY tamper-evidence. The periodic status-vs-chain cross-check therefore reconciles against BOTH — agrant_eventsrow with a missing chained link is a benign crash gap (re-emittable), NOT tampering. Folding the chained write into the grant tx was rejected: it crosses store boundaries into the Inc 1.5 advisory-lock-serialized audit chain (the "no second hash chain / one verifier" reuse the ADR forbids re-opening).- The PDP endpoint (
/check) and JWKS expand the verifier surface — third parties will build on the published canonicalization spec, and any divergence between their bytes and ours becomes our security bug. The spec ships with test vectors for exactly this reason.
Must implement (independent Opus work items)
- W1 — Key ring:
src/security/capability-grant/keys.ts(Secrets Manager suffixgrant-signing-key, kid-indexed ring, retention query, retire op); boot DI insrc/gateway/boot/boot-api.ts; JWKS route. No request-path deps. - W2 — Schema/sign/verify core:
src/security/capability-grant/{schema,canonicalize,sign,verify,principal}.ts—GRANT_SIGNED_FIELDS,canonicalizeGrant(contract-signer JSON scheme),signGrant,verifyGrantSignature,formatPrincipal/parsePrincipal, recursive-CTE chain walk returning{effectiveModels, effectiveTools, chain, widenedAtHop?}. Pure + property tests. Depends: W1, W3 types only. - W3 — Persistence: Drizzle migration
capability_grants+grant_events(RLS blocks copied fromcompletionAudit),completion_audit.grant_contextcolumn,api_keys.grantBound; store module withwithTenantaccess, cascade revoke, atomic use-count UPDATE + rollback. - W4 — Composer/enum lockstep:
effective-scope.ts:49origin +normalizeDelegationModels;scope-match.ts:42,45;composed-scope.tsinput fields + delegation branch + per-source decision + cache-admission wiring + the three extra closed literals (:61,:86,:105); shared source-list constant + exhaustiveness test. Depends: W2 types. - W5 — Request integration:
resolveGrantctx memo;completions/index.ts:1132-1139threading,enforceComposedScopeModeGate,SANDBOX_DENIAL_PRECEDENCE:1776+ comment rewrite, consume-post-allow +_grantUseContext; guardian tool-check hoist out ofif (planIdHeader)(guardian.ts:782-806) + unsupported-route rejection +denyAndAuditwiring. Depends: W2-W4. - W6 — Grant-bound credentials:
grantBoundresolution inauth.ts, deny-all api_key source substitution,security.grants.requiredial +require_grantflag +grant_requiredevidence. Depends: W3-W5. - W7 — Capability routes:
src/api/capabilities/grants/{issue,attenuate,revoke,get,list,chain,check}.ts(RBAC perms, double subset bound at attenuate, root ⊆ issuer, lifecycle events throughgrant_events+ chained store). Depends: W2-W3. - W8 — Evidence:
helpers.ts:364taxonomy (+3 codes),grant_contextpopulation,enforced_dimensionsstamping, would_deny boundary observe parity. Depends: W4-W5. - W9 — act_chain derivation + fences: populate from verified chains (open/close), correct
liaison-store.ts:8; behavior-certdelegation_chainflag-fence + dev-key removal. Independent after W2. - W10 — Lockstep surfaces: TS SDK, Py SDK, MCP tools, docs (concepts + chain spec + test vectors + runbook), website. Depends: W7 API shapes.
- W11 — Adversarial test suite: the full proof set above as its own deliverable, runnable against W2-W8.
KILL SWITCH: add the tenantId to tenants_blocked on enforce_delegation_models (and/or require_grant) — wins all precedence, reverts within the 60s flag cache, no redeploy. Cryptographic kill: retire the signing kid. Issuance freeze: security.grants.enabled=false.
Post-G1 review (G4) — implementation deviations & fixes
The G4 review surfaced findings against the as-shipped implementation. The real ones were fixed (one commit each, tests green); the deviations below amend the ADR text where the shipped design is correct but diverges from the prose above.
Finding 1 (P2) — guardian tool gate: two sequential gates, NOT one composed gate (ADR AMENDED)
The W5 table row for guardian.ts committed to hoisting tool extraction + matchToolScope out of the if (planIdHeader) block into a shared helper invoked when plan OR grant is present, with "Grant tools ∩ plan tools via composeToolScope before matchToolScope". As shipped, the grant credential-structure gate (enforceGrantStructure, incl. the grant tool check) runs at the TOP of the guardian middleware — INDEPENDENTLY of guardian mode (the delegation/guardian dials must not fight) and BEFORE the guardian-mode early returns — which is also before the if (planIdHeader) block resolves the plan and sets _planAllowedTools. The plan's own tool check remains inline in that block.
Net enforcement is identical to the ADR's intent: a request must satisfy the grant tool gate AND the plan tool gate (two sequential matchToolScope checks), so the effective tool scope is grant ∩ plan — the same set a single composeToolScope([delegation, plan]) gate would produce. The composed-gate form was structurally unreachable (_planAllowedTools is always undefined when the grant gate reads it), so it was removed as dead code (it also mis-stamped enforced_dimensions from a never-plan-inclusive composedTools).
ADR amendment: the guardian tool-scope design is two sequential, independently-dialed gates (grant gate gated by security.scopeEnforcement.delegation; plan gate gated by the plan binding), whose AND yields grant ∩ plan — NOT a single hoisted composed gate. The grant gate keeping its position at the top of the middleware (independent of guardian mode, ahead of plan resolution) is load-bearing for the "the two dials cannot fight" invariant and is preferred over re-ordering the plan's synchronous single-use commit span (item 7) to feed a composed gate. enforced_dimensions stamps the GRANT chain's tool restriction; the plan's tool restriction rides the plan's own evidence.
Finding 2 (P2) — tenant minSubjectAuth now enforced at request-time verification
resolveGrantForRequest (the single request-path entry) did not thread the tenant security.grants.minSubjectAuth dial into the verifier, so the dial only bit at attenuation (threat #6 was half-implemented). Fixed: the dial is threaded as a LAZY resolver (resolveMinSubjectAuth, resolved only after a lookup commits so the no-grant fast path pays no extra I/O); the verifier floor is now max(per-link signed floor, tenant dial) at completions enforcement.
Finding 3/6 (P2) — verification_error cause emitted for availability failures
DB/ring/store failures during resolution were mis-mapped to cause not_found, conflating an infra outage with a forged/absent grant on the delegation_proof_invalid dashboards. verification_error was added to ChainInvalidCause and both the missing-ring/store and getChain-throw branches now emit it (deny posture unchanged — fail-closed; only the evidence attribution differs). A consumeUse throw at the dispatch-commit point is now caught and (in enforce) denies uses_exhausted instead of surfacing a 500 (ADR "verify exception ⇒ deny").
Finding 4 (P1) — grant-bound deny-if-absent now precedes agentic/swarm/sandbox
The require-grant decision lived only in the normal completion dispatch path, which runs AFTER the agentic/swarm short-circuits return. A grant-bound key (require=enforce) could run full underlying authority via {"mode":"agentic"}/{"mode":"swarm"} with an absent/revoked/expired grant, defeating threat #5. Fixed: a single enforceRequireGrantGate chokepoint inside enforceComposedScopeModeGate (which precedes every short-circuit) emits the 403 grant_required / warn would_deny exactly once; the normal path now only reads the resolved mode + verified-grant flag for the grantBoundApiKeyModels composer substitution.
Finding 5 (P2) — lifecycle chained-audit event is out-of-transaction + best-effort (ADR AMENDED)
recordGrantLifecycle (the chained tamper-evidence write) runs AFTER store.insertGrantWithEvent commits and is wrapped best-effort, so a crash between the commit and the chained write can leave a live grant / revoked subtree with the in-tx grant_events ledger row but NO chained link — contradicting the ADR's "insert row + grant_events + chained audit event in one transaction." Resolution: ADR amended (see §Attenuation step 5 and risk #5 above) rather than folding the write into the grant tx — the chained store is the Inc 1.5 advisory-lock-serialized audit chain, and pulling it into the grant store's transaction is exactly the cross-store "no second hash chain / one verifier" re-opening the ADR forbids. The authoritative crash-safe record of the act is the in-tx grant_events ledger row; the chained event is supplementary tamper-evidence, and the periodic status-vs-chain cross-check reconciles against BOTH (a grant_events row with a missing chained link is a benign, re-emittable crash gap, not tampering).
Finding 7 (P2) — root/attenuate security bounds extracted to ONE shared evaluator
issue.ts / attenuate.ts and the MCP adapter independently re-implemented the root ⊆ caller-live bound and the attenuate double-subset bound — the exact "missing any one silently drops the source" lockstep class. Fixed: the security-bound ARMS now live in one place — evaluateRootScopeBound + evaluateAttenuateScopeBound in grants/_shared.ts — and all three surfaces (REST issue, REST attenuate, MCP issue/attenuate) call them, mapping the discriminated {ok|code,message} result to their own response shape (HTTP capabilityResponse vs MCP {error,code}). A future tightening of either arm now cannot be applied to one surface and missed on the other. (The adapter's callerLiveModelScope(tenantId, caller) derivation stays surface-local — it reads the MCP caller, not a Hono ctx — but the BOUND it feeds is now shared.)
P3 review items — fixed-trivial + accepted/deferred
Fixed (trivial):
- Composer delegation evidence (grant_id/root_grant_id/hop/stage). The composer's delegation deny/would_deny rows now stamp the ADR chain context via
grantContextso the warn-soak dashboards see the same payload the allow rows carry. /checkunsigned-verdict degrade. AselectSigningKeyfailure now returns 503grant_check_signing_unavailableinstead of an unprovable unsigned verdict (the PDP's whole point is offline provability).- Cascade-revoke CTE bound. The
revokeGrantsubtree CTE gained a hop bound (s.hop < 8) so a direct-DB-tampered parent cycle cannot recurse unbounded; thegetChaincomment was corrected to say "hop bound" (not PostgresCYCLEdetection). max_usesburn claim corrected (above + threat #10): post-consume provider-5xx/guardrail/budget failures burn a use (likerollbackPlanCommit); only composer denials +ModelScopeErroravoid the burn. Self-griefing, bounded.
Accepted / deferred (ADR amended — fail-safe in direction, named here rather than silently dropped):
- W1 boot key-ring load is non-fatal, not fatal.
boot-api.tscatches a grant key-ring load error and continues withgrantKeyRingundefined (downstream stays fail-closed: mint 503s, verification ⇒verification_error). This contradicts W1's "fail-closed-FATAL in prod" prose. Deferred: the degraded-boot posture is fully fail-closed (no grant traffic succeeds), and a hard boot-fail on an enforce-dialed tenant trades a silent mass-deny for a hard outage; revisit when boot wires a prod/dev distinction. The code comment documents it; the ADR now does too. security.grants.verifyCacheTtlMsdial. Not implemented — the shipped default (zero-lag, DB on every verify) IS the ADR default and the fail-safe direction. Deferred (named, not a silent drop): the optional bounded cache + its "revocation immediate with cache off, bounded with cache on" test obligation land when a perf need appears.- Lifecycle/maintenance commitments (expiry sweeper, column↔envelope drift alert+repair, status-vs-chain periodic verifier, per-tenant active-grant cap, audited
grants.attenuate_anyadmin). Each is deferred: verify denies onexpires_atlazily (a row staysstatus='active'but never verifies past expiry — fail-safe);store.markExpiredexists but is sweeper-less; the cap/drift/cross-check/attenuate_any are named follow-on. All fail-safe in direction. - Grant-bound deny-if-absent on non-completions routes (embeddings/images/audio). The W6 require-grant gate lives on the completions path; a grant-bound key presenting NO header on a guardian-fronted non-completions route is not gated (it runs underlying authority). This is partially within the documented non-completions deferral (v1 enforcement surface = completions + liaison; the header is rejected when PRESENT on other routes). Deferred with the rest of multi-route enforcement; the deny-if-absent property holds on the completions path that matters for v1.
/checkrecords evidence only on deny. An ALLOW consult leaves no row (the consuming PEP's own enforcement records the eventual use). Accepted as a deliberate volume trade — a PDP allow is not itself a security-significant event; documented here.- PDP
/checkverdict canonicalization is ad-hocJSON.stringifyof a fixed-order literal, not the exportedcanonicalizeGrantordered-field discipline. Deterministic in practice (frozen literal field order) but un-vectored for external verifiers. Accepted for v1 with the field order treated as a frozen contract; routing it through a shared ordered-field canonicalizer + test vectors is named follow-on (risk #6). - Adversarial "property"/"fuzz" suites are enumerated examples, not
fast-checkgeneration. Nofast-checkdependency exists;crypto-coreexhaustively iteratesGRANT_SIGNED_FIELDS(single-byte mutation of every signed field) andchain-fuzzcovers splice/re-parent/depth-skip/continuity/expiry/cross-tenant/forged/corrupted systematically — NOT vacuous, but enumerated. Accepted: the ADR's "Property"/"Chain fuzz" labels are read as enumerated-adversarial; addingfast-checkfor the round-trip/subset invariants is named follow-on.
Post-G4 review — kid-retire kill-switch propagation (ADR honesty fix, F7)
The ADR markets kid-retire as the cryptographic kill switch ("retire the signing kid" — §Rollout KILL SWITCH, threat #8, §key ring). As originally shipped the ring was loaded ONCE at boot (boot-api.ts) and never reloaded, and retireKid/rotateRing/pruneKid had ZERO non-test callers — so a retiredAt written into the Secrets-Manager ring out-of-band could not reach a running fleet without a full redeploy, and the doc overclaimed an operationally-wired kill switch.
Fix (the cheap concrete propagation step): boot-api.ts now schedules a guarded periodic ring re-fetch from Secrets Manager (default 5 min, GRANT_KEY_RING_RELOAD_MS override) that mutates the boot ring object in place via applyRingUpdate (the JWKS route + per-request grant middleware close over that reference, so the new currentKid + freshly-retiredAt kids propagate on the next read). It runs ONLY with a real Secrets-Manager prefix in play — a dev/test ephemeral ring is single-process and deliberately NOT reloaded (re-fetch would churn it into a different ephemeral ring). The interval is .unref()'d, best-effort (a transient SM error keeps the last good ring — fail-safe, never a mass-deny), and cleared on shutdown.
Still follow-up (named, not silently dropped): the ADMIN RETIRE PATH — an authenticated operation that writes retiredAt into the SM ring (the act that the re-fetch then propagates) — is not yet exposed as a route; today a kid is retired by editing the Secrets-Manager secret directly. The pure ring ops (retireKid/rotateRing/pruneKid) + the retention query are in place and unit-tested; wiring an audited admin retire/rotate route is the remaining work. The ADR claim is therefore corrected to: kid-retire is cryptographically supported and now PROPAGATES to a running fleet via the ring re-fetch; the operator-facing retire trigger is an out-of-band Secrets-Manager edit pending an audited admin route.