2026-06-10-inc1-scoped-agent-grants
ADR / Ship Log: Provably-Scoped Agent Action — Capability-Scoped Liaison Grants (Increment 1)
- Status: ACCEPTED (locked, go-with-changes) — hardened by a 5-lens adversarial design panel (G1) before any code.
- Date: 2026-06-10
- Milestone: Pivot Increment 1 — first proof that BrainstormRouter is an authorization-and-evidence plane, not just a router.
Context
BR already has the strongest authorization primitive in the codebase — the single-use, HMAC-signed, replay-proof liaison plan contract — but it scopes only _cost_ (ceiling_usd). Every other authority dimension in BR (br_scope, behavior-cert resource_scopes, agent-JWT scopes) is modeled-but-not-enforced. Increment 1 generalizes the plan contract to scope which models and which tools an agent may use, enforces it on the executed request, and records every allow and deny in the tamper-evident completion_audit hash chain — proven adversarially.
Decision
Ride capability scope on the existing liaison plan (not a new object) for the fastest _provable_ spine. Default semantics are fail-closed. The G1 panel returned go-with-changes; the must-fix items below are the locked design.
Locked design (must-fix, all required before/within G2)
- Signature same-bytes. Eliminate the hand-built 8-field envelope literal at
guardian.ts:439-451. Verify over the exact persisted record includingallowed_models/allowed_tools. Export one sharedENVELOPE_FIELDSlist fromliaison-contract-signer.tsthat bothsignPlanEnvelopeand the guardian verify-call iterate, so adding a field without updating both sites is impossible. Add a sign→persist→verify round-trip test with non-empty scopes. - Canonical array normalization.
normalizeScopeList(x) = [...new Set(x.map(s=>s.trim()))].sort(), applied identically at sign and verify and on every store/Redis round-trip inplan.ts. Bump an explicitv:2version field into the signed payload. Never omit-when-undefined (omission enables scope-downgrade-by-omission forgery — plans live only in the in-memory LRU with no integrity beyond the HMAC). - Fail-closed absence semantics. Do NOT inherit
checkResourceScope's empty==unrestricted branch. At mint inplan.ts: undefinedallowed_models⇒[modelLabel]; undefinedallowed_tools⇒[](no tools). NewmatchCapabilityScope(allowed, requested)insrc/security/scope-match.tswhere[]⇒ false (deny-all) and unrestricted requires an affirmative['*']sentinel. Guardian treats undefined as DENY. Lock withmatchCapabilityScope([],'gpt-4')===false. - Canonical model identity. Enforce
allowed_modelsagainst the SAME resolved identity the executor uses: runbody.modelthroughparseModelRouterRef(+resolveBareModelId), resolve viaregistry.findEndpoints, compare each resolved${provider}/${modelId}againstallowed_modelsas canonical exact-set membership (NOT glob; a variant suffix must not satisfy a non-variant grant). Storeallowed_modelsas canonical catalog labels at mint. Rejectbody.modelresolving to >1 endpoint when a plan is in play. - Enforce on the EXECUTED model, not the requested one. Thread
allowedModelsintoRouteCompletionParamsfromplanRecord; reject any resolved${provider}/${modelId}not in the grant atresolveEndpointAND on every fallback/auto re-entry (model-router.ts:932,:1194) and pool expansion (model-router-select.ts). When a plan is in play, forceallowFallback=false; rejectbody.models[]against a single-model grant. Abort with a hard scope error instead of swapping tobrainstormrouter/auto; the completions handler callsrecordAuthorizationDenialwhen the router throws it. - Tool extraction across all channels. Extract names = union of
body.tools[].function?.name ?? .name,body.functions[].name(legacy), forcedbody.tool_choice.function.name, and built-in tool.type(computer_,bash_,web_search). Fail-closed on any tool entry lacking an extractable name. Match via a dedicated name-glob inscope-match.ts(drop the path-only[^/]*semantics). - Order + single-use. Scope checks BEFORE
store.updatemarkscommitted=true; aplan_scope_exceeded403 must leavecommitted=false(re-committable until TTL — no self-DoS, no probing oracle). Keep the whole check synchronous inside the existing no-await span (419→557): static top-level import of scope-match, precompute regex/canonical resolution synchronously — any introducedawaitreopens the single-use replay race. - Fail-closed on body-parse when a plan is present. At
guardian.ts:250-257, whenX-BR-Plan-Idis present and body JSON parse fails, return 400plan_invalidand do NOTnext()(current fall-through is fail-open and skips the scope block entirely). - Deny-path chain integrity. Split
computeHashinto a purecompute(prevHash, payload)+commitHead(tenantId, prevHash, eventHash)called ONLY after the durable insert succeeds.recordAuthorizationDenialmustawaitthe insert andcommitHeadon success; on failure do NOT advance the head and emit a high-severitysecurity_event(audit.deny_record_failed). Advance through the Redis Lua CAS as source of truth (loop-recompute on conflict), not fire-and-forget. - Verify ordering by monotonic key. Chain-verify reconstruction uses
orderBy(asc(completionAudit.id)); droptoReversed()overdesc(createdAt)(completion-audit-store.ts:189). KeepcreatedAtonly for thesincefilter. Test: interleave allow+deny rows within one millisecond, assertverifyChainstays valid. - Deny payload shape.
recordAuthorizationDenialbuilds its hash input EXCLUSIVELY viabuildAuditChainPayload(same 10-field funnel),costUsd/inputTokens/outputTokens/durationMs=null,isStreamingfrombody.stream,outcome:'denied',model= the SAME canonical resolved string the allowlist compared. Widen the store union (completion-audit-store.ts:31) to include'denied'. Synthesize a deterministic non-forgeablerequestId(deny_${randomUUID}) used in BOTH the insert and the hash input. Store deny reason / violated scope in NON-hashed columns only. Roundtrip test foroutcome:'denied'with all-null cost/tokens. - Deny coverage. Centralize all plan-block denials behind one
denyAndAudit(c, {code, status, body, model})helper that records THEN returns. Add a grep/lint test asserting no barec.json4xx return inside the plan-enforcement block bypasses it (~8 denial returns; any un-instrumented one is a silent evidence hole).
Open questions — resolved for Increment 1
- Pre-deploy v1 plan compatibility: accept mass-invalidation of in-flight v1 plans on deploy (TTL ≤ 300s, in-memory only, low blast radius). Documented intentional choice; no legacy canonicalizer.
- Single-model grant + fallback: hard-disable fallback when a plan is in play. Multi-model grants are a later increment.
- Tool names in
prompt_fingerprint: defer the double-binding; document thatallowed_toolsHMAC is currently the sole binding, so any extraction bug is a direct bypass (covered by tests). - Per-tenant monotonic sequence column: scope the schema change now (so suppressed denials surface as a numbering gap); gap-verification logic lands next increment.
Adversarial test obligations (G2 must produce, all green)
Signature tamper of scope fields fails verify; round-trip sign→persist→verify with non-empty scopes; matchCapabilityScope([], x)===false; out-of-grant tool → 403 across all tool channels; out-of-grant model incl. alias/variant/fallback-swap → 403; replay of a committed scoped plan → plan_already_committed; a denied 403 leaves committed=false; every denial appears in completion_audit with outcome:'denied' and /v1/audit/verify reports an unbroken chain (incl. sub-millisecond allow/deny interleave); body-parse failure under a plan → 400, not fail-open.
Lockstep obligations (G3)
The liaison plan request gains allowed_models/allowed_tools. Propagate to: TS SDK, Py SDK (sync+async), MCP tool schema + agents.json, docs, website, and regenerate llms-full.txt/OpenAPI.
Known limitation & follow-up: audit-chain crash-safety
Residual risk (pre-existing, surfaced by Codex pass 3, P1). The tamper-evident completion-audit chain stores its per-tenant head in Redis (Lua CAS, source of truth) while the audited rows live in Postgres. This Redis-head / Postgres-rows split means head-advance and durable-row-insert are not a single atomic unit on either path. The deny-path (recordAuthorizationDenial in src/api/routes/completions/helpers.ts) advances the head via commitHead before the durable store.log insert (CAS-then-insert ordering, which is deliberate — it prevents the double-insert/fork an earlier review flagged). If the insert failed after the head had advanced, the tenant head would point at an event hash with no backing row, permanently breaking verifyChain for that tenant. The allow-path (recordCompletionAudit) is best-effort/fire-and-forget and has the same underlying split.
Bounded mitigation applied (this increment, deny-path only). On a durable-insert failure after a successful commitHead, the deny-path now attempts a best-effort, guarded head ROLLBACK: it CASes the head from the just-committed eventHash back to the prevHash it advanced from, only if the head still equals that eventHash. The primitive is rollbackChainHeadCas(tenantId, fromEventHash, toPrevHash) in src/security/audit-chain.ts, which reuses the same Redis Lua CAS machinery as commitHead (no second mechanism). The CAS-then-insert ordering and the critical audit.deny_record_failed security event on failure are both retained.
- What it guarantees: in the common single-writer transient-insert-failure
case the head returns to prevHash, leaving no dangling gap — the next denial or completion chains cleanly and verifyChain stays valid.
- What it does NOT guarantee (residual caveat): in the rare concurrent case
where another writer advanced the head past the just-committed eventHash before the rollback runs, the guarded CAS declines (it does not force the head, which would fork the chain) and leaves the head advanced. In that case the deny-path degrades to exactly the same best-effort level as the pre-existing allow path: a detectable gap behind an advanced head, surfaced by the critical audit.deny_record_failed event. This bounded fix is not full crash-safety: a process crash between commitHead and the insert, or a Redis-unreachable rollback, still leaves the gap (still detectable, never a silent fork).
Locked by tests: src/api/routes/completions/helpers-deny-cas.test.ts (commit-success → insert-failure asserts the critical event AND the guarded rollback to prevHash, single-writer recovery; plus no-rollback on the happy path and no double-insert on CAS conflict), src/security/audit-chain-deny.test.ts (in-memory single-process rollback restores the head and a subsequent deny verifies), and src/security/audit-chain-rollback.test.ts (Redis-Lua-CAS guard: rolls back when the head still equals the committed hash, declines without forcing when a concurrent writer advanced past it).
Related — concurrent-denial ordering (Codex follow-up, document-only). Codex also flagged that under concurrency two denials can land out of id order because the Redis chain head advances (commitHead) before the durable Postgres row insert (recordAuthorizationDenial, helpers.ts:~403). This is the same Redis-head / Postgres-rows split root cause as the crash-safety risk above — not a separate bug — and is therefore also covered by the planned DB-transactional chain-head increment (Increment 1.5), not by Inc1's bounded mitigation. Inc1 does not attempt to order concurrent denials; chain-verify already reconstructs by orderBy(asc(completionAudit.id)) (locked design item 10), so the durable rows remain verifiable once written.
Planned next increment (own design gate). Full crash-safety: a DB-transactional chain head covering BOTH allow + deny, removing the Redis-head / Postgres-rows split so the head-advance and the durable row commit or roll back atomically. That redesign is intentionally out of scope here and is scheduled as its own increment behind a dedicated design gate. It subsumes both the crash-safety gap and the concurrent-denial ordering note above.
Known limitation & follow-up: durable chain recording requires Postgres
Durable, tamper-evident chain recording of denials requires Postgres; without it (airgapped / Postgres-less deployments), denials are still enforced (the request is denied, fail-closed) and logged — recordAuthorizationDenial (src/api/routes/completions/helpers.ts) now emits a critical audit.deny_record_failed security event, falling through to a CRITICAL structured-log floor when no event store is reachable — but they are not chain-recorded. Previously this no-Postgres path returned null with no event at all, so on those deployments the "every denial is recorded in the tamper-evident chain" guarantee degraded silently to enforcement-only. The denial is now never silently invisible: the evidence degradation is observable. Locked by src/api/routes/completions/helpers-deny-no-postgres.test.ts (no-Postgres denial still returns null/denies, writes no chain row, and emits the critical event / CRITICAL log floor). Full durable chain recording on Postgres-less deployments is out of scope for Inc1.
Hardening: chain verification now detects row suppression, not just mutation
verifyChain (src/security/audit-chain.ts) previously validated only each row's eventHash against its own stored prevHash + payload — detecting content mutation but not row suppression/fork: a privileged mid-chain DELETE left the surviving rows internally self-consistent and verify still returned valid:true. It now also asserts adjacent-row link continuity — entries[i].prevHash === entries[i-1].eventHash — between every pair of consecutive present rows, reporting valid:false with brokenAt set to the discontinuity. The window boundary is handled by asserting continuity only between two rows both present in the verified set (from i=1 onward); the first row of a fetched window is never failed on continuity, because the chain-recent window legitimately starts mid-chain with a prevHash pointing at a row outside the window. Locked by src/security/audit-chain.test.ts (suppressed mid-chain row → valid:false at the gap; intact chain → valid:true; recent-window starting mid-chain does not false-fail on its first row) and the existing sub-millisecond allow/deny interleave in src/security/audit-chain-deny.test.ts.