Capability Grant Chain — v1

Normative schema for BrainstormRouter's Ed25519-signed capability grants and attenuation chains. Defines the signed envelope, the br-grant-canonical-v1 canonicalization scheme, chain verification, revocation latency, and reproducible test vectors.

Status: Draft 1 · published 2026-06-12 · canonical URL https://docs.brainstormrouter.com/specs/grant-chain-v1

Authors: BrainstormRouter License: CC BY 4.0 (specification) — reference implementations under their respective package licenses

This specification is normative. The keywords MUST, MUST NOT, SHOULD, SHOULD NOT, and MAY are used per RFC 2119.

---

1. Abstract

A Capability Grant is a durable, multi-use, revocable authority object — "subject S may use scope X until E, derived from parent P" — persisted in Postgres and Ed25519-signed by BrainstormRouter. A chain is a sequence of grants where each link is signed, chain-bound, and monotone non-widening (child ⊆ parent). This spec defines the signed envelope, the br-grant-canonical-v1 byte scheme the signature covers, the per-link verification algorithm, the revocation model, and reproducible test vectors so a third-party PEP can verify a chain offline and reach the same decision the inline enforcer does.

The credential on the wire is an id, not a token: validity lives in the live row. The exported chain bundle carries each link's authoritative envelope_json + signature + a JWKS reference for offline verification.

2. Object model

2.1 Signed envelope

Every field below is covered by the signature. Sign and verify iterate the same field list in the same order — it is structurally impossible to persist an authority field that the signature does not cover.

FieldTypeNotes
vintegerGRANT_VERSION = 1. Append fields + bump; NEVER reorder.
grant_idstring (uuidv7)
tenant_idstring (uuid)INSIDE the signature — anti confused-deputy.
parent_grant_idstringParent uuid, or the literal "" (the root sentinel; SQL NULL column ⇔ "" signed).
depthintegerroot = 0; child = parent.depth + 1; ≤ 8 (GRANT_MAX_DEPTH).
issuer_principalstring (URI)Canonical principal URI of the issuer (§2.2).
issuer_auth_methodenum"api_key" \"jwt" \"mtls" — how issuance was authenticated.
subject_principalstring (URI)Canonical principal URI of the holder.
subject_auth_methodenumMinimum auth method the subject MUST present at use.
scopestring[]Namespaced: model:{canonicalLabel}, tool:{name}. Re-normalized at sign AND verify.
max_usesinteger0 = unlimited within TTL.
issued_atintegerEpoch seconds.
expires_atintegerEpoch seconds, ABSOLUTE; a child's value MUST be the parent's (clamped value is signed).
kidstringGrant-ring key id used to sign — signed, so rotation cannot be confused.

Reserved scope namespaces (resource:, http:, mcp:) are parse-rejected in v1: the canonicalizer fail-closes if any scope entry uses one, so such an entry can never ride a signature. Tools omitted ⇒ no tool: entries ⇒ tool deny-all (fail-closed).

2.2 Principal URI scheme registry (frozen v1)

br:apikey:{apiKeyId}   |   br:agent:{agentId}   |   <SPIFFE URI verbatim>

One formatPrincipal/parsePrincipal pair is used at issue, attenuate, check, and enforcement — asymmetry here would be an auth bypass. External schemes (did:, external SPIFFE trust domains, OIDC issuers) are an additive future change, not an envelope version break.

3. Canonicalization — br-grant-canonical-v1

The signature covers the UTF-8 bytes of JSON.stringify over an object built by iterating the signed-field list in order. Rules:

  1. Field order is the spec order in §2.1, not the input object's key order.

Third-party verifiers MUST NOT depend on V8 insertion order.

  1. scope is normalized (trim each entry, drop empties, de-duplicate, sort

ascending) at BOTH sign and verify. It is kept as a JSON string-array — NOT delimiter-joined (a separator join would introduce injection ambiguity: "a,b" vs ["a","b"]).

  1. parent_grant_id: a nullish or empty parent canonicalizes to the root

sentinel "", so a root signature is stable regardless of null / undefined / "" input.

  1. No whitespace; standard JSON.stringify number and string encoding.

Signing is detached Ed25519 (crypto.sign(null, canonicalBytes, privKey)) under the grant key ring, selected by the signed kid. Verification reconstructs the canonical bytes from the stored envelope_jsonnever from caller input and never from the relational columns — selects the ring key by the signed kid, and constant-time compares. Fail-closed on version / kid / byte mismatch.

4. Test vectors

The following vectors are generated by the reference implementation under a fixed Ed25519 seed and are reproducible. Regenerate with npx tsx scripts/generate-grant-vectors.ts.

Signing key (fixed seed = 32 × 0x01):

{ "kty": "OKP", "crv": "Ed25519", "x": "iojj3XQJ8ZX9UtstPLpdcspnCb8dlBIb83SIAbQPb1w" }

kid = "grant-vector-2026", GRANT_VERSION = 1.

4.1 Vector 1 — ROOT grant (parent_grant_id = "")

Canonical bytes (the exact UTF-8 string the signature covers):

{"v":1,"grant_id":"0192a8b0-0000-7000-8000-000000000001","tenant_id":"0192a8b0-0000-7000-8000-0000000000aa","parent_grant_id":"","depth":0,"issuer_principal":"br:apikey:key_root","issuer_auth_method":"api_key","subject_principal":"br:agent:researcher","subject_auth_method":"jwt","scope":["model:anthropic/claude-sonnet-4","tool:web_search"],"max_uses":100,"issued_at":1700000000,"expires_at":1700086400,"kid":"grant-vector-2026"}

Signature (Ed25519 detached, base64url):

3Xk8yrPgzeAg4jhTojTYlOjN4bxsGYANqh1Tzn40kupb0MbHROIZVh3fAoPhQ94AKCxXNn6c_mkjoGXZXQlVBw

4.2 Vector 2 — CHILD grant (depth 1, narrowed scope, clamped expiry)

{"v":1,"grant_id":"0192a8b0-0000-7000-8000-000000000002","tenant_id":"0192a8b0-0000-7000-8000-0000000000aa","parent_grant_id":"0192a8b0-0000-7000-8000-000000000001","depth":1,"issuer_principal":"br:agent:researcher","issuer_auth_method":"jwt","subject_principal":"br:agent:sub-worker","subject_auth_method":"jwt","scope":["model:anthropic/claude-sonnet-4"],"max_uses":0,"issued_at":1700000100,"expires_at":1700003700,"kid":"grant-vector-2026"}
4sr6rXxfTPKTrP-pefsK4gT4YSrpyU5fIrsnEeeOwi4_WR5ohlfkXCPG_59rRiarVzs0hAehl6yD4NYUsscoDQ

4.3 Vector 3 — scope normalization (unsorted + duplicate input ⇒ sorted, de-duped)

Input scope ["tool:write_file","model:openai/gpt-4o","model:anthropic/claude-sonnet-4","tool:write_file"] canonicalizes to the sorted, de-duplicated array below:

{"v":1,"grant_id":"0192a8b0-0000-7000-8000-000000000003","tenant_id":"0192a8b0-0000-7000-8000-0000000000aa","parent_grant_id":"","depth":0,"issuer_principal":"br:apikey:key_root","issuer_auth_method":"api_key","subject_principal":"br:agent:researcher","subject_auth_method":"jwt","scope":["model:anthropic/claude-sonnet-4","model:openai/gpt-4o","tool:write_file"],"max_uses":0,"issued_at":1700000000,"expires_at":1700086400,"kid":"grant-vector-2026"}
qSGZaZ7BptnD3_Z1aOdiTNhZSkZ8kcykBxxgEvTYQ0Y9QZB-TCM8Bl5Rxc8NkmCM91CHtYdlAmZezbSXypQNBA

A verifier that produces different canonical bytes (e.g. preserves input scope order, or joins the scope array) will fail to reproduce these signatures — that divergence is the bug the vectors exist to catch.

5. Chain verification

Given the leaf grant id, the verifier loads the chain leaf→root (one bounded recursive query, ≤ depth + 1 rows) and, for every link:

  1. Signature — Ed25519 verify over canonicalizeGrant(envelope_json), key

selected by the signed kid. A retired or unknown kid ⇒ invalid.

  1. Status / expirystatus == "active" and now < expires_at.
  2. Continuitydepth == parent.depth + 1, expires_at ≤ parent.expires_at,

and issuer_principal == parent.subject_principal (root exempt).

  1. Subject binding (leaf only) — the caller's formatPrincipal equals

leaf.subject_principal AND the caller's auth method satisfies leaf.subject_auth_method AND the tenant security.grants.minSubjectAuth floor.

Any failure ⇒ the chain is invalid with a cause ∈ {not_found, subject_mismatch, signature_invalid, chain_invalid, revoked, expired, uses_exhausted, depth_exceeded, unsupported_route, verification_error}.

The effective scope is the running intersection folded root→leaf via the same composeModelScope/composeToolScope algebra enforcement uses, so a stored widening at any hop is structurally inert (the intersection ignores it; the composer separately emits delegation_scope_exceeded).

5.1 Attenuation (mint a child)

POST /v1/grants/{id}/attenuate requires the caller to BE the parent's subject (URI + auth-method floor). The child is bound by the double subset: child == child ∩ parent AND child ⊆ caller's live effective scope. * is forbidden in an attenuation request; omitted scope inherits the parent verbatim; expires_at is clamped parent.

5.2 Standalone PDP (/check)

POST /v1/grants/{id}/check evaluates {principal, requested:{models?,tools?}} against the chain's effective scope using the identical matchers and returns a signed allow/deny verdict. A non-BR PEP can therefore consult the grant plane and reach the same decision an inline enforcement call would.

5.3 Chain export bundle

GET /v1/grants/{id}/chain returns the links root-first, each carrying its envelope_json + signature + a jwks_ref ({url, kid}) pointing at the public JWKS, plus canonicalization: "br-grant-canonical-v1". A third party can verify the whole chain offline against §3 and these vectors.

6. Revocation and latency

  • POST /v1/grants/{id}/revoke flips status and, by default, cascades to

the whole subtree (root_grant_id index) in one transaction.

  • Zero-lag by default. There is no verify cache in enforce mode: validity

is read from the live row on every request, so a revoke takes effect on the very next request — revocation is a product promise, not a perf detail.

  • Optional bounded cache. The dial security.grants.verifyCacheTtlMs

(≤ 5000, default 0) MAY enable a (tenant_id, grant_id)-keyed cache. When enabled, the residual staleness is verifyCacheTtlMs per replica — a documented security property, not a default.

  • Cryptographic kill. Retiring a kid invalidates every grant signed

under it immediately (targeted mass-revocation).

  • Tamper-evidence. status is necessarily mutable and unsigned; a direct-DB

un-revoke is tamper-evident (every revocation lands in the crash-safe chained audit, cross-checked by a periodic verifier), not tamper-proof.

7. Rollout runbook

Two independently dialed axes, both fail-safe to off:

AxisFlagTenant dialDefault
Scope sourceenforce_delegation_modelssecurity.scopeEnforcement.delegationoff
Require-grantrequire_grantsecurity.grants.requireoff
API surfacegrants_apioff

Phases:

  1. Dark — migrations, key-ring bootstrap, routes behind grants_api (off).

Zero request-path change.

  1. Mint + observegrants_api on for design partners; header accepted,

mode off-by-default; canonicalization + perf soak (target p99 verify < 5 ms).

  1. Warn soakwould_deny + delegation_proof_invalid dashboards; gate on

outcome='warned' over 7 days; stale-label audit of stored scopes vs the live catalog.

  1. Enforce by cohortrollout_percent / tenants_allowed, then

security.grants.require = warn → enforce for grant-bound keys.

  1. Convergence (separate flags) — plan signer HMAC→Ed25519; plan mintable

under a grant.

Fail-safe: a flag-store outage resolves both dials to off ⇒ the header is ignored / bound keys behave legacy — NEVER a mass-403. This is load-bearing; do not "fix" it to fail-open. Kill switches: add the tenant to tenants_blocked on either flag (≤ 60 s, no redeploy); security.grants.enabled = false freezes issuance independently of enforcement; kid-retire is the cryptographic kill.

8. Security considerations

  • The signature covers tenant_id, both principals + auth methods, depth,

parent_grant_id (with the "" root mapping), scope, expiry, and kid — forging a chain needs the signing key AND a tenant-scoped DB write (two compromises).

  • A stolen grant id is worthless alone: subject binding + the auth-method floor

mean it only ever narrows (or, when bound, gates) the authenticated subject.

  • The grant key ring is a separate key class from the trust-envelope bundle,

with retention ≥ the longest outstanding grant lifetime, so rotation never bricks live grants.

  • ceiling_usd is deliberately absent — a signed-but-unmetered ceiling would

be false evidence; spend bounds route through the enforced budget slice.

9. References

Changelog

  • Draft 1 (2026-06-12) — initial publication: envelope, br-grant-canonical-v1, chain verification, revocation latency, rollout runbook, test vectors.