ADR-014: Authority Tree — Session-Rooted, Sender-Constrained Delegation
This is a rendered copy of docs/adr/014-agent-authority-tree.md, read in place from the repository — see therecords index for how to read it. If this page and the source file ever disagree, the source file is correct.
ADR-014: Authority Tree — Session-Rooted, Sender-Constrained Delegation
Status: Accepted Date: 2026-09-21 Authors: QAuth Team
Proposed 2026-09-21, accepted 2026-10-11. Nothing below is implemented. Every fork takes the fail-closed option, every new surface is inert until an operator provisions it, and the whole record sits behind
AUTHORITY_TREE_ENABLED(defaultfalse, Decision), so a default deployment’s behaviour is byte-for-byte unchanged by this record. No existing token-exchange gate is loosened; with the flag on, two are added — GATE 4d on every exchange, GATE 3d on every exchange whose subject token carriescnf, with 3d’s ledger check on every exchange (§4). The composition rule for OS-level authority managers (§10) is a proposal into their open questions, not a description of what any such manager does. Vitrin is its one worked example, read as of 2026-09-21, in the last block of that rule’s section.Amended 2026-09-22 (before any implementation): a durable agent principal with an owner (§13), agent-transmitted action events and the members that name the agent and the model it reports (§7), and a provenance convention for commits (§9). Every addition sits behind the same switch; the rows it adds — an agent, its bindings, its transmitters — are the owner’s to create and exist for nobody until an owner creates them, the way a client registration is a developer’s (ADR-012). QAuth holds the identity, the ledger and the log, and nothing about what an agent does with its identity elsewhere; those are the owner’s own systems, out of QAuth’s scope and not specified here (Explicitly out of scope).
Amended 2026-09-24 (before any implementation): remote approval (§14) — a refused request the owner approves from a phone with a passkey, as a separate elevation leaf that never widens a token or a tree — with its threat, T8, and parked decisions 14–16; a clause in T3 on why an OS-level authority manager is listed beside PostgreSQL; and the 2026-09-22 text its first commit dropped. Remote approval sits behind a second switch,
REMOTE_APPROVAL_ENABLED.Amended 2026-09-30 (before any implementation): the maintainer decided four parked questions. Authority trees survive sign-out, and a separate revoke-all ends them (decision 1). History keeps the owner of its time, and a transfer ends the agent’s live trees (decision 10). Five of six lasting approval answers are settled (decision 14). A passkey is the only approval factor, in every profile (decision 15). Decision 12 stayed open then. “Always allow” became decision 17, which the maintainer decided on 2026-10-06 (no “always allow”). Four statements are corrected against main: GATE 3a already refuses a revoked subject or actor token (GHSA-6fcx-34r3-24v4); a refused subject token or spawn assertion answers
invalid_request(RFC 8693 §2.2.2); a loopback redirect URI takes any port (PR #415); and draft-ietf-oauth-rfc7523bis-11 is in the RFC Editor queue.Amended 2026-10-07 (before any implementation): the record is restated for any platform. A git host, an agent harness and an OS-level authority manager are examples here, never dependencies. A platform enters through a credential adapter (§9, §13). A harness enters through a harness adapter (the Harness adapter contract section). An OS-level authority manager is served by the composition rule (§10). The platforms and products that remain are labelled examples: GitHub, Claude Code, PostgreSQL and vitrin. No gate, threshold or switch changed. Two
agent_bindingscolumns were renamed (§13), the third example seed type is now calledworker(decision 7), and the 300 s rule for a manager’s leaf became a per-resource cap that an operator sets (§6).Amended 2026-10-09 (before any implementation): the maintainer decided a rename on 2026-10-08. “Agent Authority Tree” is now “Authority Tree”. The mechanism is generic machine-to-machine and process delegation. AI agents are one client class, so the title no longer ends “for AI Agents”. Two switches are renamed with it:
AGENT_TREE_ENABLEDis nowAUTHORITY_TREE_ENABLED, andAGENT_APPROVAL_ENABLEDis nowREMOTE_APPROVAL_ENABLED. On 2026-10-09 the maintainer also renamed the two approval settings of §14:AGENT_APPROVAL_EXPIRYis nowREMOTE_APPROVAL_EXPIRY, andAGENT_APPROVAL_BUDGETis nowREMOTE_APPROVAL_BUDGET. The agent principal (§13), the agent client type, agent identities and every identifier that names an agent keep their names. The file name is kept, so links stay stable. No rule changed; the root of a tree is still always a humansub(§1). QAuth-defined identifiers will carry a version when implemented (ADR-018, a separate PR).Amended 2026-10-09, decisions (before any implementation): the maintainer decided parked decisions 2 to 9, 11 to 13 and 16. Each is marked decided below, with its date. The seed manifest’s example root type is now
lead(decision 7). Decision 17, decided 2026-10-06, is now in the body: the agent’s root ceiling (§13, §11), no “always allow” (§14) and the portal setting (P5). Two 2026-10-06 answers recorded in ADR-015 are written into §1 and §11. The DPoP verifier, the WebAuthn provider andprivate_key_jwtat revocation and introspection are shared with ADR-017, as its question 10 proposed (§2, §3, §14, P1a, P5).On 2026-10-09 the maintainer also decided that this record, ADR-015 and ADR-016 would be approved together, so their answers that change this record were written in at once. From ADR-015: sign-out ends a
tree_idfamily whose CIMD client stopped declaringis_agent(decision 1; question 1). Agent types authenticate by client assertion only (§4(e), §9, T2; question 2). An agent type cannot mint an ID-JAG in 1.0; GATE 2 refuses it (Explicitly out of scope; question 3). A GATE 4d refusal is final (§14, step 1; question 4). A window renews by a CIBA request matched to it, with no exception to GATE 4a (§14, step 6; question 5). A subject token with notree_idthat is older than the user’s last revoke-all is refused (§1, §6; question 12). From ADR-016: theAgent:trailer carries theagent_idandhandle@issuer-host, with a version marker (§9; question 5). The maintainer also decided that a renewal matched to an open window does not count againstREMOTE_APPROVAL_BUDGET; QAuth accepts one only in the last 60 seconds of the live leaf (§14, step 6).Amended 2026-10-10 (before any implementation): fail-closed rules added where the record left a choice to the caller. The broker, not the registering process, decides a new node’s parent and type, and the socket’s permission model is stated (§12, Harness adapter contract). An agent type has registered rights beside its registered scopes, and only the grants a tree uses (§1, §5). The root consent shows the root’s rights (§11). A binding’s upstream identity belongs to one owner (§13). The broker signs an action event with the reporting node’s own key (§7). A family with no
tree_idgains none by refresh (§1). A resume rebinds its root only through the consent screen, and only a process under the broker’s own process joins a host’s one root per broker start (§1). The broker resolves a caller by a pidfd, on the socket and on the loopback listener alike (§12). A token the ledger does not know is refused on every route this record adds (§2). The STS sends explicit lists and refuses when an upstream read fails (§9), and mints only while the binding’s upstream identity is assigned to the agent’s current owner (§13). A pass-through leg’s scope is a dangerous scope (§11). No maintainer decision changed.Amended 2026-10-19: GATE 4d’s scope check (§4(d)) already runs, flag or no flag, on the ID-JAG mint branch of token exchange; ADR-011 §3 records that decision. On the delegation branch it still arrives with this switch.
Amended 2026-10-19, tree id (before any implementation): the maintainer decided on 2026-10-11 that a tree’s identifier is
tree_id. The record had called itsid, which OpenID Connect and ADR-019 give to the browser session on ID and logout tokens, and one name cannot carry both. The claim, the introspection member, the ledger andrefresh_tokenscolumns, the CAEP subject, the identifier API’s path parameter, the commit trailer and the broker’s state file are renamed with it (§1).sidnow appears in this record only for the browser session. No rule changed.
Context
A session’s main agent — an agent harness such as Claude Code on a
developer’s machine, a process built on an agent SDK, a worker started by a
spawner (any runtime helper that starts nodes and sandboxes them) —
authenticates to QAuth and receives a token. That token is the first gate
on what the agent may do, at an MCP server and equally at the CLI tools the
agent can run (for example gh, psql) — on a host where the model’s uid
holds no other credential (§9). The agent then
spawns helpers: teammates, sub-agents, one process per task. Today QAuth can
express one thing about that spawn — the RFC 8693 act chain of client_id
values — and nothing else. The deployment needs five more things:
- Recursive, inspectable delegation. Every agent has its own scope, and
the record shows from which agent it inherited what. Today
actcarries onlysub(ActClaim,libs/server/jwt/src/types/jwt-service.ts:15): no instance, no parent token, no timestamp. - A hard, cryptographic gate before any judgement. A write-capable agent spawns a read-only agent, and the child cannot write — enforced by the token the child holds, not by a hook, a classifier or a model’s restraint.
- Live observation. Which agent, in which session, with which scopes, for what purpose, did what — as a tree on the dashboard, while it runs.
- A durable agent identity with an owner. Every name this record had for
an agent is either shared or ephemeral: a
client_idnames a harness type (for exampleclaude-code) that every user and every box runs, acnf.jktnames a process that dies with it, atree_idnames one grant. Nothing names “the agent that belongs to this person”, so nothing can be attributed to it across sessions, bound to a platform identity, or shown to the public with its owner’s name on it (§13). - Approval from a distance. The owner sometimes drives a session from a remote or mobile owner client, such as a phone, with no shell and no SSH. When an agent hits its ceiling, the owner needs to approve one step from there — once, or for a while — without widening what the tree was granted (§14).
The CLI leg is designed with OS-level authority managers in mind: local components below QAuth that verify QAuth tokens and enforce grants, with a token as a ceiling on what may be petitioned (§10). Their rules are the posture adopted here: a scope is a ceiling on what may be asked for, never a grant; the verifier canonicalises identity, the agent does not assert it; expiry refreshes and revocation kills; the authorization server is never on the actuation hot path. For example, Vitrin OS is one such manager (§10 carries it as the worked example); it is not the only one. The standards cover the grammar of a hop (RFC 8693), sender constraint (RFC 9449), typed rights (RFC 9396), introspection and revocation (RFC 7662, RFC 7009), events (RFC 8417, SSF/CAEP), decisions (AuthZEN 1.0) and, since June 2026, the shape of an agent identity record (SCIM Agent resource, an individual draft) — and nothing about sessions, instances, spawn, or a tree. Three 2026 individual drafts partition the rest (McGuinness: actor identity and chain shape; Liu: signed per-hop records; Niyikiza: offline attenuation); none is adopted by a working group. QAuth defines the seams itself and says so (Standards position).
What exists today and what this record adds
| Concern | Today (re-verified 2026-09-30) | This record adds |
|---|---|---|
| Delegation hop | handleTokenExchange, apps/auth-server/src/app/routes/oauth/token.ts:1291 — confidential-only, gates 1–4c, act = { sub: client_id, act? }, depth ≤ MAX_DELEGATION_DEPTH (apps/auth-server/src/app/helpers/agent-audit.ts:22), lifetime capped by the subject’s remaining life, no refresh token issued | A spawn assertion signed by the parent’s key, a DPoP proof from the child, an instance thumbprint, and a ledger row per hop |
| Session root | None. jti is randomUUID() with no issued-token row in Postgres and no parent link (libs/server/jwt/src/lib/jwt-service.ts:80); Redis holds it only as a TTL’d denylist key (and, with hybrid signing on, the PQC sidecar key); no tree_id in code; sessions is the browser login session (libs/infra/db/src/lib/schema/sessions.ts:7) | tree_id on every agent access token, inherited unchanged by every exchange |
| Instance identity | None. No cnf, no DPoP, no mTLS; every agent token is a plain bearer | DPoP-bound agent tokens (cnf.jkt), key held by a local helper |
| Chain record | audit_logs.delegation_chain (flattened client_id list, libs/infra/db/src/lib/schema/audit.ts:54); findByRealmAndActorClientId has no HTTP caller (libs/infra/db/src/lib/repositories/audit-logs.repository.ts:218) | An agent-token ledger keyed by jti with parent_jti and a stable node id, served by introspection and the dashboard |
| Introspection | POST /oauth/introspect returns no act, jti or token_use (response built at apps/auth-server/src/app/routes/oauth/introspect.ts:229); secret-based client auth only | act, jti, tree_id, token_use, cnf, authorization_details, qauth_delegation; private_key_jwt accepted |
| Revocation | Per-jti Redis denylist with TTL (revokeJti, apps/auth-server/src/app/helpers/token-revocation.ts:30); ownership = client_id of the token (apps/auth-server/src/app/routes/oauth/revoke.ts:164); family_id cascade for refresh tokens only; token exchange refuses a revoked subject or actor token (GATE 3a, apps/auth-server/src/app/routes/oauth/token.ts:1431-1449, GHSA-6fcx-34r3-24v4) | Revocation by tree_id (tree) and jti (subtree) with cascade; the user and any live ancestor node in the tree (by its key) may revoke; CAEP session-revoked emitted |
| Purpose / rights | RFC 9396 absent; the only reference rejects inbound authorization_details on the ID-JAG path (apps/auth-server/src/app/helpers/id-jag.ts:433) | One RAR type, narrow-only across hops, returned in introspection |
| Consent | consentPage has no agent input; agent:* renders raw (apps/auth-server/src/app/routes/ui/consent.ts:138, describeScope in apps/auth-server/src/app/helpers/consent.ts:114) | The tree ceiling on the consent screen |
| Resource side | McpGuard validates iss/aud/exp/scope exactly, normalises no act/jti, emits nothing back (libs/fastify/plugins/mcp-guard/src/lib/core.ts:78, ValidatedToken in libs/fastify/plugins/mcp-guard/src/types.ts:31) | act/tree_id/jti normalised, DPoP verified per resource, optional event emission and online decisions |
| Events | None — no SSF, SET, CAEP or webhooks | An SSF transmitter (revocation) and an RFC 8935 push endpoint (resource-side and agent-side actions), with a registered-transmitter roster the owner can extend |
| CLI | Nothing | qauth-broker (keys, spawn, git credentials and, through adapters, platform and database credentials), a QAuth-side STS whose first credential adapter is github, and a PostgreSQL 18 validator module as the example database adapter |
| Agent identity | None. oauth_clients.is_agent is a self-asserted flag on a client registration (libs/infra/db/src/lib/schema/core.ts:234); developer_id is the only ownership signal and is NULL for every anonymous DCR client (ADR-012); logo_uri and client_uri land in the metadata jsonb at registration (apps/auth-server/src/app/routes/oauth/register.ts:178) | An agents table — a principal with an owner, a name, an avatar and per-platform bindings — served as a SCIM Agent resource and a public profile (§13) |
| Human approval | None. The only step-up is a fresh browser login during authorization (evaluateStepUp, apps/auth-server/src/app/helpers/step-up.ts:196); no passkey, TOTP or CIBA code | A CIBA poll-mode approval request, a passkey-confirmed approval page and a separate elevation leaf for the approved step (§14) |
Two facts in that table shape everything below. GATE 3c requires the subject
token’s aud to contain the exchanging client’s client_id
(apps/auth-server/src/app/routes/oauth/token.ts:1458), and resolveAudience
sets aud to the RFC 8707 resource when one is present, else the client’s
audience allowlist, else the client’s own id
(apps/auth-server/src/app/helpers/client-auth.ts:521) — so a child with a
distinct client_id can exchange only if the root token already named it, and
an MCP client that sends resource, as MCP 2026-07-28 requires, gets a root
token that names nobody. And exchange is confidential-only at three layers
(allowPublic: false at apps/auth-server/src/app/routes/oauth/token.ts:204-205,
DCR refusal at apps/auth-server/src/app/helpers/dynamic-client-registration.ts:224,
CIMD drop at apps/auth-server/src/app/helpers/cimd.ts:368) — so a child needs
a credential something other than the model holds.
Threat model
T1 — Chain splicing. A compromised intermediary presents a subject_token
and an actor identity from different contexts and the STS mints a chain that
never happened (OAuth WG thread, Feb–Mar 2026). Closed at every hop: the spawn
assertion verifies under the subject token’s own cnf.jkt, hashes the exact
subject token, and names the child’s key, which must equal the DPoP proof’s.
There is no actor_token to splice; GATE 3c stays as the second binding.
T2 — Key exfiltration via environment. A model that can run env or a
platform CLI’s token command (for example gh auth token) reads any secret
placed where the process can. Closed by
custody for per-process keys and node tokens: they live in qauth-broker’s
memory behind a unix socket, never on disk or in env. Three credentials
outlive a process on the CLI leg. The upstream app identity’s private key
(for the github adapter, the GitHub App’s) lives QAuth-side — in QAuth’s
configuration, or for an agent’s own identity in QAuth’s store, uploaded by
its one owner (§13) — beside the vend policy and never on the developer box,
so an agent with a shell cannot edit its own ceiling (§9). The other two are
on the box: the agent type’s private_key_jwt key (never a client secret,
§4(e)), which the broker must load from storage — the user keyring or a 0600
file readable by the broker’s uid only — because its public half is operator-provisioned in
the type’s jwks (§3, §9); and the root refresh token, which the broker keeps
in memory only — a broker restart means a new login — and which RFC 9449 §5
leaves bound to client authentication, not to the DPoP key (§3). A same-uid
process that reads both can authenticate as the type and refresh the root
from any host, minting DPoP-bound tokens under its own key with the same
tree_id, until the user revokes the tree_id (§6). The type key alone buys client
assertions and nothing they mint on their own: an agent type is registered
without the client_credentials and JWT-bearer grants (§1), so the key
mints no token by itself; it cannot spawn (GATE 3d needs the parent’s DPoP
key, held only in broker memory), cannot root a tree (a root needs a browser
consent the user sees, §1), cannot sign an action event (the broker signs
one with the reporting node’s own key, §7), and no endpoint this record adds
accepts a token the ledger does not know (§2). To keep such a leak to one
box, the operator registers one key per box in the type’s jwks with a
distinct kid, never one key per type. Not closed for what the broker
vends. A vended platform credential is a plain bearer that
git credential fill prints to stdout and that a platform-CLI shim places in
the real CLI’s environment (example, GitHub: an installation token in the
real gh’s GH_TOKEN — so gh auth token prints it and any pager or editor
gh launches inherits it), and a database leaf sits in the client library
inside the model’s own process (example: the psql leaf in libpq). The model
can read each and replay it from any host, within its
ceiling — the node’s locations and actions (§5, §9) — for the
credential’s upstream lifetime (example, GitHub’s hour)
minus the broker’s deletion (§6), or the leaf’s lifetime (T3), off the
broker’s log; a platform may attribute those uses to the platform identity
with no token identifier (example: GitHub attributes them to the App).
Custody covers only what the broker holds: a stored platform-CLI login (for
example gh auth login), a
stored git credential, an SSH key or a .pgpass already present on the
agent’s uid is the user’s, not the tree’s, and no ceiling here reaches it —
which is why §9 requires that uid to carry no ambient credential. Residual:
the process can still use its key through the broker; and same-uid
isolation is soft — under Yama ptrace_scope=1 a sibling cannot attach to
the broker or read its memory (where Yama is off, a debugger has its keys and
its refresh token), but it can read any same-uid process’s
/proc/<pid>/environ (PTRACE_MODE_READ, which scope 1 does not restrict),
write to the user’s tmux socket, and edit the shim, the git config and the
hooks the parent process will execute, so a same-uid child can make its
parent act; a hard gate between two agents needs two uids or a user namespace
(§12). Whatever the uid, the broker and not the caller decides where a new
node sits (§12): a process that reaches the broker spends the ceiling of the
bound node it resolves to, never a wider one, and can neither graft a node
under another parent nor start a root while it stays under a node. Sender
constraint stops off-host replay of QAuth node tokens, not of the root
refresh path, not of vended bearers, and not misuse within the ceiling.
T3 — Replay. DPoP proofs carry jti, iat, htu, htm, ath and are
accepted for a bounded window (RFC 9449 §11.1); spawn assertions are
single-use with a 60 s life; leaves are single-audience and short; a vended
platform credential is cached per node for at most its upstream lifetime
(example: a GitHub installation token’s hour). Residual:
a leaf replayed inside its lifetime at a resource that does not verify the
proof — PostgreSQL (the example database), an OS-level authority manager, an
mcp-guard host with DPoP off — which RFC 9449
§7.2 concedes for any DPoP-unaware resource; the answer is the lifetime (§6).
An OS-level authority manager is in that list for its wire alone,
bearer-shaped and DPoP-unaware,
not as a CLI resource a scope opens: it is the actuation gate one layer
below QAuth, where verb-level scopes that the resource registers (for example
<resource>:observe and <resource>:actuate) only cap what an
agent may petition, the grant comes from the manager’s own consent prompt and
powerbox, and a delivered file descriptor is kernel authority no QAuth
revocation reaches (§10).
T4 — Confused deputy. Every agent holds its own token, mcp-guard never
forwards a bearer upstream
(libs/fastify/plugins/mcp-guard/src/lib/core.ts:10), and every credential
the broker vends is derived by QAuth from the caller’s token — resources,
permissions and roles (for the github adapter, repositories and
permissions) intersected with its authorization_details on the
server — so neither the broker nor the model can be talked into a credential
wider than the token that asked.
T5 — AS outage. Existing tokens verify offline until exp; nothing
already vended is torn down; the tree stops growing and the broker vends
nothing new, while a cached platform credential carries an in-flight push.
Revocation is fail-open for at most one access-token lifetime and an outage
extends that window — an OS-level authority manager’s own written window
says no document may imply otherwise (§10, Example: vitrin).
The dead-man switch (§6) is the broker’s pidfd on the process, not a call to
QAuth.
T6 — Prompt injection. A hook can be argued out of; a credential cannot.
A read-only child persuaded to write gets 403 insufficient_scope from
mcp-guard’s exact match (missingScopes,
libs/fastify/plugins/mcp-guard/src/lib/scope.ts:41), no write permission
from the STS (for github, no contents: write) — its type’s registered
rights exclude it whatever its parent grants (§5) — no writable role from the
validator, and cannot forge its
lineage because caused_by is written by QAuth (§5). Not closed: actions
within the ceiling, and the free-text purpose a model writes about itself,
which is display, never policy. QAuth authenticates a workload, not an
intelligence; an OS-level authority manager’s planning documents say the same
(§10, Example: vitrin).
T7 — Forged attribution. Anything an agent writes about itself outside
QAuth is unauthenticated where it lands: a Co-authored-by or Author line
in a commit, a QAuth-Session trailer, a model name, a purpose. Anyone can
write any of them into any commit in any repository, and the agent itself can
(T2: a same-uid process edits the hooks that write them). Closed for what
QAuth records and only there: an agent identity is a row QAuth owns (§13), a
node is a key QAuth verified (§3), an action event is a SET whose transmitter
QAuth registered and whose subject jti the ledger knows — from the broker,
signed by that node’s own key (§7) — and a public
profile states what it does and does not attest. Not closed: the world
outside the ledger. A commit that names an agent is a claim until the owner’s
own systems tie its hash to a node (§9, provenance; those systems are out of
scope here), and a model name is a
harness’s report of itself, never something QAuth verified (§7). No surface
in this record presents either as QAuth’s word.
T8 — Approval fatigue. An agent under prompt injection asks for more
rights again and again, until a tired owner taps yes — the push-bombing
pattern known from MFA. Closed as far as a gate can close it (§14). Asking
needs a scope of its own, which no approval can grant or extend. A
per-tree_id budget caps how often a node may ask. A mute turns further asks into silent
denials. The approval page shows the typed request from QAuth’s own records;
the model’s words appear only in an attributed box. And an approval covers
one delta, for one node, for a bounded time, and is never passed down. Not
closed: an owner who approves what they should not. The record can make each
approval small, specific and costly to ask for; it cannot make the owner
read it.
The invariant the record exists to make provable: for every child token c
with parent p, scope(c) ⊆ scope(p), scope(c) ⊆ registered(type(c)),
aud(c) ⊆ aud(p), mode(c) ≤ cap(type(c)), exp(c) ≤ exp(p),
rights(c) ⊆ rights(p), rights(c) ⊆ registered_rights(type(c)),
depth(c) ≤ 4, tree_id(c) = tree_id(p), cnf(c) = the key that presented the
exchange, and the exchange was authorised by the holder of cnf(p) — the
node the broker resolved the caller to, never one a request named (§12).
Each is one check at mint; by induction every leaf is bounded by the root,
and the root by the human’s consent, which shows its scopes and its rights
(§11), within its type’s registration and, when it names an agent, that
agent’s root ceiling (§13). One consequence of
ADR-007’s maintainer decision: the modes are
independent scopes and the check is exact set inclusion, so a parent holding
only agent:exec cannot hand out agent:readonly. The root grant carries
the union of the modes the tree may use, each child takes a subset, and the
consent screen shows the union (§11). An elevation (§14) stands outside this
chain on purpose: no exchange derives it, a passkey-confirmed approval of one
delta bounds it instead of a parent, and it can neither spawn nor narrow — so
no child ever holds what it grants. Its type’s registered scopes and rights
still bound it (§14, step 1).
Decision
One switch gates all of it: AUTHORITY_TREE_ENABLED, a boolean in the auth env
schema, default false — the shape ID_JAG_ENABLED,
WALLET_FEDERATION_ENABLED and HYBRID_SIGNING_ENABLED set. When false,
nothing in this record runs: no tree_id is minted, no ledger row is written,
no new gate is evaluated, and the token exchange is byte-for-byte today’s,
whatever the seed manifest or any AGENT_* setting says. When true,
P0’s behaviour applies to every agent-client mint, and the new exchange
gates, as each phase lands them, run as §4 states. Remote approval (§14) has
a second switch, REMOTE_APPROVAL_ENABLED, default false, which does
nothing unless AUTHORITY_TREE_ENABLED is on.
1. Session root — tree_id on agent access tokens
Every access token minted to an agent client (isAgentClient,
apps/auth-server/src/app/helpers/client-resolution.ts:94) from a
human-consented grant carries tree_id: an opaque string, unique within the
issuer, of at least 128 bits from a CSPRNG (randomUUID(), as jti is),
minted once per authorization_code grant and bound to that grant’s consent
record. It is a column on the refresh_tokens row, copied on rotation as
family_id already is (libs/infra/db/src/lib/schema/tokens.ts:178), so a
refreshed token keeps it; every token derived by exchange inherits it
unchanged. The agent the grant names (§13), when it names one, travels the
same way: a nullable refresh_tokens.agent_id beside tree_id, copied on
rotation, inherited by every exchange. The ID token issued by the same code
exchange does not carry
it. The ID token’s own sid (Front-Channel Logout 1.0 §3) is a different
identifier: it names a User-Agent or device session for logout, which
QAuth’s sessions row is and this grant identifier is not, and one browser
session may root several grants. A client_credentials token carries none
and cannot root a tree: exchange already requires an enabled user
(apps/auth-server/src/app/routes/oauth/token.ts:1474), so the root of a tree
is always a human sub. An agent type (an agent client whose
max_agent_mode the seed manifest set) holds only the grants a tree uses:
authorization_code and refresh_token for a root type, the token-exchange
grant and, from P5, the CIBA grant (§14). With the switch on, the seed
manifest refuses every other grant for an agent type, client_credentials,
the JWT-bearer grant and the device grant among them, as it refuses a client
secret (§4(e)), and the token endpoint refuses them to an agent type whatever
its stored row says. So an agent
type’s key mints no token on its own (T2). An agent client that is not an
agent type keeps the grants it has today, and its client_credentials token
reaches nothing this record adds (§2).
One root per main-agent process. The broker runs the authorization_code
grant when a main agent’s harness adapter registers its harness
session_id and pid over the socket (the registration
Harness adapter contract describes for
teammates; for example, Claude Code’s adapter does it from its SessionStart
hook), so one harness session is one tree_id, the ledger’s root row records the
harness session_id, and the root key is the one keyed to that process. The
broker starts a root only for a registration whose caller resolves to no
bound node (§12). From under a bound node a session registration is a child
of that node, never a new root, so no process can log in its way out of a
node’s ceiling while it stays under that node (§12). The broker’s
authorization request carries
qauth_binding_code, a short code the broker also returns to the
registering adapter, which shows it in the session; the consent screen shows
the same code (§11), so the person can match the screen to the session they
started. The code is a matching aid and proves nothing, since a model can
print any code into its own session. It is display only and never
evaluated, and QAuth accepts it only on an agent type’s authorization
request (any other client’s is invalid_request): at most 8 bytes
matching ^[A-Z0-9]+$, and anything else is invalid_request. A resumed
session (same session_id; for example, Claude Code’s --resume and
--continue) reuses its root only when the dead-man walk (§6) did not
reach QAuth at the process’s exit. Even then, the root lives only while the
grant’s refresh token lives (ADR-015 question 8, decided 2026-10-06), and
the broker binds the resumed process to that root only after the person
approves the root’s consent screen again (§11). The broker’s authorization
request for it names the live root’s tree_id; QAuth shows the screen only to
the root’s own user for the root’s own type, with the root’s ceiling
unchanged and the resumed session’s binding code, and the code exchange
continues that tree_id and refresh family without extending the family’s
expiry. Until then the resumed process is under no node. A
second harness process is a second root with its own tree_id. The cadence is a
per-host setting, default one root per process. A host may choose one root
per broker start instead, as a daemon host does (decision 8, decided
2026-10-09). On such a host the shared root’s node is the broker’s own
process (ADR-015 question 8), so a process the broker starts is under it
(the broker is never a child subreaper, and it opens the login browser
outside its own process tree, so neither an orphan nor the browser is under
it), and a session registration whose caller resolves to that node joins it; one
from under a node below it is a child registration there (§12). A
registration from any other process under no node starts a root of its own,
through its own consent screen.
The return leg is a native app’s (RFC 8252 §7.3): the broker opens the user’s
browser on the authorization URL with PKCE S256 and a state it minted,
binds an ephemeral port and listens once on a loopback redirect URI —
http://127.0.0.1:<port>/callback, the IP literal rather than localhost,
which §8.3 says is NOT RECOMMENDED — receives the code there, checks
state, closes the listener (§8.3: open the port for the request, close it
on the response) and exchanges the code as the type’s confidential
private_key_jwt client. QAuth accepts any port on a registered http
loopback redirect URI, as §7.3 requires (redirectUriMatchesRegistered,
apps/auth-server/src/app/helpers/oauth-redirect.ts:114, called at
apps/auth-server/src/app/routes/oauth/authorize.ts:181; PR #415). So no
port is fixed, and the type’s seeded redirect_uris holds one portless
entry (decision 7). Plain-HTTP loopback passes the environment gate in
every profile —
development allows it outright, staging and production because they
require PKCE (isRedirectUriAllowedForPolicy,
apps/auth-server/src/app/helpers/oauth-redirect.ts:152).
A subject token with no tree_id — a legacy token, or one issued to a non-agent
client whose aud happens to name the agent — is not left without one: the
exchanged token starts a new tree with a fresh tree_id, depth 0, and a
kind: root ledger row (§2) whose origin_jti and origin_client_id record
the subject’s jti and client_id. Refusing would break an exchange that
works today; an agent token with no tree_id is one no tree can revoke.
A refresh_tokens row with a NULL tree_id gains none by refresh. Such a row
predates the migration, or its code grant was issued while its client did
not declare is_agent. No refresh mints a tree_id: one comes only from a code
grant whose consent screen showed the persistence rung (§11), or from the
exchange rule above, whose new tree has no refresh family. A client that
starts declaring
is_agent after consent (for example, a CIMD document that changes) was
never shown it. So the family keeps today’s lifecycle, and it is the one
agent-client grant whose tokens carry no tree_id: sign-out ends it (decision
1; ADR-015 question 11), revoke-all reaches it (ADR-015 §1), and its tokens
reach a tree only by the exchange rule above, which bounds that tree by the
subject token’s remaining life. A longer tree takes a new authorization,
through its consent screen.
One case is refused (decided 2026-10-09, ADR-015 question 12). A subject
token with no tree_id whose iat is earlier than the user’s last
revoke-all (§6) is refused. Such a token has no ledger row, so revoke-all
cannot reach it. That time is a per-user timestamp in Postgres, cached in
Redis. The refusal is
invalid_request, as for any refused subject token (RFC 8693 §2.2.2).
tree_id is a QAuth-defined claim. It is deliberately not sid, the
IANA-registered “Session ID” claim (OIDC Front-Channel Logout 1.0 §3;
Back-Channel Logout 1.0 §2.4 carries it in the Logout Token), which names
the browser session on QAuth’s ID and logout tokens (ADR-019 Decision 3).
One name cannot carry both (decided 2026-10-11, maintainer). No
specification defines a tree identifier in an access token; this record
defines it, and the Standards table records its position: a tree roots in
the human’s authorization grant to the root agent, not in a browser
session. Introspection returns it as a service-specific member (RFC 7662
§2.2).
2. Ledger, not wire — the agent-token ledger
Every mint of a tree_id-carrying token for an agent client — the
authorization_code, refresh_token and exchange grants; a
client_credentials token (§1) writes nothing — writes one row to a new
table, agent_token_ledger: jti (key),
tree_id, realm_id, user_id, agent_id (nullable; the principal the tree
roots in, §13, copied from the root row to every descendant), node_id,
parent_node_id and parent_jti
(null at the root), kind (root | refresh | spawn | narrow |
id-jag | elevation, §14), origin_jti and origin_client_id (set only on the kind: root
row a subject with no tree_id started, §1), client_id, instance_jkt,
scope, aud, authorization_details, depth, spawn_receipt (the
verified spawn assertion’s claims, §4), approval_receipt (the owner’s
approval of an elevation, §14), issued_at, expires_at,
revoked_at, revoked_by, revoke_reason. Only public identifiers are stored
— never a token, key or secret, the rule audit_logs already keeps. Rows
outlive their tokens and are purged by AGENT_LEDGER_RETENTION_DAYS (default
90). A ledger write failure fails the mint: a token the ledger does not
know is a token the tree cannot revoke. And a token the ledger does not
know is refused everywhere this record adds: the STS on every adapter’s
route (§9), the CIBA backchannel endpoint (§14), the node leg of the
identifier API (§6), the push endpoint’s subject check (§7), and
introspection’s answer to a node about its ancestors (below). A
client_credentials token, a token of a family with no tree_id (§1) and any
other token without a row reach none of them; at the token endpoint a
subject with no tree_id keeps §1’s rules.
A node is a process, not a token — or, for the by-type case in
Harness adapter contract, a key the session
process holds on a sub-agent type’s behalf. node_id is minted at the first row for a (tree_id,
instance_jkt) pair; every later token under that key in that
session — a renewal, a narrowing — joins it, so a 300 s renewal is a new row
under the same node, and the dashboard tree and subtree revocation key on
nodes. Until §3 lands a node is one token.
Introspection serves the row: POST /oauth/introspect adds act, jti,
cnf and authorization_details (registered members), the QAuth markers
tree_id and token_use, and one service-specific member, qauth_delegation
— node_id, parent_jti, depth, revoked, agent (the row’s
agent_id handle, absent when NULL, §13) and chain, a root-first array
of { jti, client_id, issued_at } for a resource caller; ancestors’ jkt and
scope are returned only to the session owner’s portal and to a node of the
same tree presenting its own DPoP-bound token (§6), which already hold them
(RFC 7662 §2.2 lets the AS answer each caller differently). The name is
vendor-prefixed on purpose: delegation is already a registered JWT claim
(OpenID Federation 1.0 §13.6) with unrelated semantics. RFC 8693 §4.1 forbids
using prior actors in access-control decisions; the chain is for audit and
display. Introspection and revocation also start accepting private_key_jwt
(apps/auth-server/src/app/helpers/discovery.ts:147 is secret-only today).
Amended 2026-10-09 (maintainer): that change moves to ADR-017’s F0, not
this record’s P1a.
draft-liu’s delegation_chain claim is not adopted (the Alternatives
table has the size, the wit:// scheme — WIMSE defines wimse:// and has
asked IANA to register it, WIMSE-ID — and the moving signed-field set); its
own §10.6 names “chain by reference” — a compact token plus introspection —
as the mitigation, and that is this decision. Trigger to
revisit: working-group adoption, or a stable signed-field set. The ledger
holds every field of its record except the two detached signatures, so
emitting the claim later is a serialisation, not a migration.
3. Instance identity — DPoP-bound tokens, keys in a helper
Agent tokens are DPoP-bound (RFC 9449) to a per-process key: cnf.jkt names
the process, token_type: DPoP. Agent types are registered with
dpop_bound_access_tokens: true — operator-set, seed manifest only, like
max_agent_mode (libs/infra/db/src/scripts/seed-oauth-clients.ts:102) — so
a token request without a DPoP header is refused (§5.2); §5’s “regardless
of grant type” makes the exchange grant carry the proof. The root client is
confidential (private_key_jwt), so its refresh token is sender-constrained
by client authentication, not by the key — §5 says exactly that.
Amended 2026-10-09 (maintainer, ADR-017 question 10): QAuth has one DPoP
verifier, behind DPOP_ENABLED. This record and ADR-017 share it.
Node and leaf. A node token — aud names an agent type, itself or a
child — can spawn or narrow and is always DPoP-bound. A leaf token is
minted by narrowing (§4) with resource = one resource and can do neither. A
leaf is DPoP-bound too and carries cnf.jkt — except a leaf whose resource is
listed in AGENT_BEARER_LEAF_RESOURCES (below), the one election that mints
an unbound leaf — even across a bearer-shaped wire: for example,
PostgreSQL’s oauth method and an OS-level authority manager’s handshake
(§10) never carry a
proof, but both are single-scheme, DPoP-unaware verifiers, which RFC 9449 §7.2 foresees accepting a DPoP-bound
token as a bearer; there cnf.jkt is an instance label, not a sender
constraint, and the protection is the short lifetime (§6) and the single
audience — an accepted, stated weakness. The one case
§7.2 forbids is a dual-scheme resource receiving a DPoP-bound token as Bearer:
an mcp-guard host with DPoP verification on, reached by an MCP client that
sends Bearer only (for example, Claude Code’s does, by the documentation read
2026-09-21).
For exactly those resources the operator
lists the resource identifier in AGENT_BEARER_LEAF_RESOURCES (default empty,
operator-set like ID_JAG_TRUSTED_ISSUERS) and QAuth exercises §5’s election:
their leaves are token_type: Bearer without cnf, protected by lifetime and
audience alone. mcp-guard’s DPoP verification is a per-resource opt-in; a
resource that opts in is reached through the broker’s proxy (§9) or listed.
The model never touches the key. Keys are generated and held by
qauth-broker (§9), one per agent process, in memory, keyed to the process’s
pidfd (§12), never on disk, in env or in a file the model can read —
draft-ietf-wimse-aims-00 §8: the LLM MUST
NOT have access to an agent’s credentials. The broker signs proofs and
assertions on request and never returns a private key. A key dies with the
process (§6). The one exception is a by-type sub-agent node
(Harness adapter contract): its key is held per
(session, agent type) behind the proxy path and dies with the session process.
act adopts draft-mcguinness’s shape — iss, sub, sub_profile set to
ai_agent, optional nested act — where iss is QAuth’s issuer identifier
(the namespace of sub), sub is the actor’s client_id — the value an
RFC 7523 client assertion presented as actor_token would yield (§6.3.1,
§6.3.1.2 step 3; §14.2 calls iss = sub = client_id the conformant
pattern); §3.2 keeps client_id itself an auxiliary client-identity signal,
so the draft’s actor identifier is the (act.iss, act.sub) pair, durable
and never a thumbprint (§14.12) — and nested act is the prior chain,
preserved exactly. This deviates from §14.7’s SHOULD: where one client_id
fronts several acting instances the draft wants act.sub to name the
specific instance; QAuth keeps the instance in cnf.jkt and the ledger node
instead, because §14.12 says act.sub SHOULD NOT be key-derived and this
record mints no other per-instance identifier (§2; Alternatives, “One DCR
client per agent instance”). Prior actors’ thumbprints
live in the ledger, not inside act: RFC 8693 §4.1 makes act members
identity claims only, and the draft defines no per-actor confirmation. The
current presenter is the top-level cnf (§3.7.1). actClaimSchema
(libs/server/jwt/src/lib/access-token-claims.ts:29) becomes a loose object
requiring sub and admitting iss and sub_profile, so extension members
are ignored rather than stripped (§3.4).
4. Spawn = RFC 8693 exchange with a parent-signed spawn assertion
A spawn is one POST /oauth/token with
grant_type=urn:ietf:params:oauth:grant-type:token-exchange, made by the
broker on the child’s behalf: subject_token is the parent’s token,
subject_token_type the access-token URN, and:
- (a)
DPoPheader — the child’s proof, signed with the child’s fresh key. - (b)
spawn_assertion— a new token-request parameter (RFC 6749 §8.2 is the extension point; draft-liu §5.2 addsdelegatee_idthe same way): a JWT typedtyp: spawn-assertion+jwt, token-type identifierhttps://schemas.qauth.dev/token-type/spawn-assertion, signed by the parent’s DPoP key with its public key in thejwkheader:iss= parentclient_id,sub= childclient_id,aud= QAuth issuer identifier (the rfc7523bis rule),iat,exp≤iat+ 60 s,jti(single use),ath= the hash of thesubject_tokencomputed as RFC 9449 §4.2 computes it for a DPoP proof (binding one token instance — draft-niyikiza’spar_hashproperty),cnf.jkt= the child’s key thumbprint,scope= the ceiling the parent grants,authorization_details= the child’s purpose and rights (§5), optionalresource/audience.
The AS adds one gate, 3d, after GATE 3c and before the enabled-user check:
when the subject has a ledger row, that row has revoked_at IS NULL (the
denylist half already ships: GATE 3a refuses a revoked subject or actor token
on every exchange, apps/auth-server/src/app/routes/oauth/token.ts:1431-1449,
GHSA-6fcx-34r3-24v4); typ exact; header jwk thumbprint equals the subject
token’s cnf.jkt; signature verifies under that key; aud equals the issuer;
unexpired, lifetime ≤ 60 s; jti unseen (Redis, the consumeIdJagJti idiom,
apps/auth-server/src/app/helpers/id-jag.ts:244; store unavailable ⇒ refuse);
ath matches the presented subject_token; sub equals the authenticated
client; iss equals the subject token’s client_id; sub is in the
registered spawn_allowlist of the iss type (one oauth_clients lookup by
the subject token’s client_id, operator-set like max_agent_mode) — so the
root’s aud bounds which types the tree may contain and each type’s own
allowlist bounds what it may spawn, including itself; a same-key narrow
presents no assertion and never reaches this check; cnf.jkt equals the DPoP
proof’s key; and when (tree_id, cnf.jkt) already names a node, that node has
revoked_at IS NULL, the same client_id as the assertion’s sub and the
same parent node as the subject token’s — a spawn onto a revoked or foreign
key is invalid_request, so a revoked node cannot be renewed by the next
sweep and two agent types can never share one node. Any failure is
invalid_request (RFC 8693 §2.2.2), as the shipped subject-token gates
(GATE 3, 3a, 3b and 3c) answer, and is audited (the ledger check also runs for a legacy bearer subject, where
it is the only new part of 3d; GATE 4d below runs for every subject, bearer
or bound). The DPoP key is generated per process and
is never a key registered in the type’s jwks, so no assertion it signs can
authenticate the client; the client-assertion verifier additionally rejects
typ: spawn-assertion+jwt and typ: secevent+jwt, DPoP-proof verification
rejects any JWT carrying sub, cnf or events, and the push endpoint
requires events and rejects cnf and htm in a SET the key signs (§7), so
the four JWT types the key family produces are disjoint by typ and by
claim set, not by typ alone. The verified claims
become the row’s spawn_receipt — the post-hoc proof the OAuth-list thread
distinguishes from cross-validation, and the dashboard’s “who authorised this
hop”. A subject token that carries cnf cannot be exchanged to a new key
without a spawn assertion; a DPoP-bound token presented by a party that
cannot prove the holder authorised it is exactly the captured token GATE 3c
exists to stop.
This is QAuth-defined, not draft-mcguinness’s presenter rebind: §3.7.3
installs a new presenter only through a validated actor_token whose own
top-level sub names it, and there is no actor_token here. The profile’s
conformant shape is §6.3.1 — the child’s RFC 7523 client assertion as
actor_token with actor_token_type=urn:ietf:params:oauth:token-type:jwt,
which GATE 2 refuses today
(apps/auth-server/src/app/routes/oauth/token.ts:1366) — the future
conformance path, not adopted for the reason the Alternatives table gives.
The existing gates all still run and all still narrow: (c) GATE 3c and
the 3d allowlist bound the types, as above; (d) GATE 4a scope ⊆ subject
scope, and now ⊆ the assertion’s scope; a
new GATE 4d, scope ⊆ the child type’s registered oauth_clients.scopes
(validateScopes, apps/auth-server/src/app/helpers/client-auth.ts:484, the
check client_credentials already runs), so a type registered without
write:* can never hold it whatever its parent grants — GATE 4c gives the
same floor for agent:* through max_agent_mode (enforceAgentScopeCap,
apps/auth-server/src/app/routes/oauth/token.ts:1509), and GATE 4d’s second
half gives it for rights: the child’s authorization_details ⊆ its type’s
registered_rights (§5), invalid_authorization_details otherwise, so a
type registered without a write action never holds one either; GATE 4b
aud ⊆ subject aud; the lifetime clamp; the depth cap; rights narrowing
(§5).
(e) The requesting client is the child’s agent type, authenticated by a
private_key_jwt client assertion under a key the broker holds; exchange
stays confidential-only. An agent type never authenticates by client secret
(decided 2026-10-09, ADR-015 question 2). It is registered with
private_key_jwt, and the code already refuses a secret from such a client
(apps/auth-server/src/app/helpers/client-auth.ts:183). The client
credential names the type, not the instance (§9); the instance is the DPoP key (§3). The minted token: sub = user,
client_id = child type, cnf.jkt = child key, act extended (§3), tree_id
inherited, new jti, no refresh token — the code’s invariant stands, so
exp(c) ≤ exp(p) holds on the token itself. A node renews by re-spawn under
its parent’s current token with a fresh assertion for the same key, root-down
in one broker sweep (the root by its refresh token); the new row joins the
same node.
Narrow versus spawn. A self-narrowing — same client and same key,
deriving a single-audience leaf — is kind: narrow: the DPoP proof under the
subject token’s own key is the possession proof (draft-mcguinness §3.7.2,
presenter continuation), no assertion is needed, act is preserved exactly
(§3.6.3.2, so a leaf spends no depth), and a ledger row is written all the
same. Same-type children. A harness spawning a teammate of its own type
(for example, Claude Code does) is a child whose
client_id equals its parent’s: a full hop, iss = sub in the assertion,
allowed only when the type’s own spawn_allowlist names itself (GATE 3d) —
its client_id is always in aud for the narrow, so 3c alone would not gate
it — a new key, act nested one deeper, depth counted. A spawn’s
audience/resource names the child’s own client_id, the types it may
itself spawn and the resources it may reach, all ⊆ the parent’s aud (GATE
4b) and, for the types, ⊆ the child type’s allowlist (3d).
5. Purpose — one RFC 9396 type, narrow-only
QAuth’s first RAR support is one type, https://schemas.qauth.dev/agent-task,
carried in authorization_details on the authorization request (root), in
the spawn assertion (child), in the token response (RFC 9396 §7), in the JWT
filtered to the audience (§9.1) and in introspection (§9.2):
{ "type": "https://schemas.qauth.dev/agent-task", "purpose": "Open the release PR", "task": "pr-open", "caused_by": "<parent jti, written by QAuth>", "locations": ["https://git.example.com/acme/widgets"], "actions": ["contents:read", "pull_requests:write"]}The locations and actions above are a git host’s, as the worked example.
Each credential adapter defines the strings it maps to its upstream (§9).
purpose and task are recorded, displayed and never evaluated. Version 1
of the type says so: task is free text, a label shown to people and never
a policy input (decision 2). Both are
client-authored at the root and model-authored at every hop, so they are
data, never markup or policy (RFC 9396 §12: the AS MUST sanitise
authorization_details against injection). The type bounds them: task ≤ 64
bytes matching ^[a-z0-9][a-z0-9-]*$; purpose ≤ 200 bytes of printable
Unicode (NFC, no C0/C1 controls, no bidi overrides); anything else is a field
with an invalid value for the type and is refused with
invalid_authorization_details (RFC 9396 §5). Every render escapes them —
the consent page through the html tag that already guards the client name
and homepage (#112, apps/auth-server/src/app/routes/ui/consent.ts:194), the
dashboard the same way — and the consent page shows purpose in a box
attributed to the client (“The application says: …”), below the operator’s
scope descriptions, never inline with them. Logs write both as JSON string
values, never interpolated into a line. caused_by is written by QAuth
— the parent’s jti, absent at the root — and a client-supplied value is
rejected with invalid_authorization_details, so a hijacked child cannot
write a parent it likes into its own row. locations
and actions are RFC 9396 §2.2 common fields, sets of exact strings, and a
child may request only a subset of its parent’s —
invalid_authorization_details otherwise (§6). RFC 9396 defines no comparison
algorithm (§6.1); this type defines set inclusion by exact match and nothing
cleverer. Every other type is rejected, not ignored —
ADR-011 gate 15’s posture — and
the ID-JAG consume path keeps refusing authorization_details outright.
authorization_details_types_supported joins AS and resource metadata.
Two bounds come from outside the chain. Each agent type has
registered_rights: per credential adapter (§9), the actions the type may
ever hold and, optionally, the locations it may ever name, exact strings
compared as above. It is operator-set in the seed manifest like
max_agent_mode and never through DCR, CIMD or the developer API; once the
realm file and the admin API carry it, as they carry max_agent_mode and
spawn_allowlist, a write is a security operation (ADR-019 Decision 7). An
adapter the column
does not name gives the type no rights there, so a type nobody provisioned
holds none. Every mint checks it — the root at /authorize, a spawn or a
narrow at GATE 4d (§4), an elevation (§14) — and the STS checks it again at
every vend (§9). At the root, locations and actions are client-authored,
and the consent screen is not their only bound: a root whose rights exceed
its type’s registered_rights or, when it names an agent, that agent’s
root_ceiling (§13) is refused at /authorize with
invalid_authorization_details and never reaches the screen. What passes is
rendered there in full (§11).
6. Revocation — by tree_id and by jti, cascading, with a written window
Revocation walks the ledger: revoking a jti marks its node and every
descendant node revoked_at and writes each live jti into the existing Redis
denylist with its remaining TTL (revokeJti), so the hot path —
isJtiRevoked on requireJwt (apps/auth-server/src/app/app.ts:330) and
introspection — stays a Redis lookup. The walk and a mint must not race. A
mint (root, spawn, narrow or renewal) inserts its row in a transaction that
first locks its parent’s ledger row (SELECT ... FOR UPDATE on parent_jti)
and refuses when that row carries revoked_at: invalid_request on an
exchange (RFC 8693 §2.2.2), invalid_grant on a refresh. GATE 3a already
refuses a denylisted subject (§4), but denylist writes follow the walk’s
commit (below), so this lock and GATE 3d’s revoked_at check stop a marked
parent from renewing or spawning in between. The walk marks top-down: it
updates a node’s revoked_at
before it reads that node’s children, so a mint holding the parent’s lock
either commits before the mark (and is then found as a child) or reads the
mark and refuses. Denylist writes run after the ledger transaction commits.
If any revokeJti write fails, the request answers 503 with Retry-After
(RFC 7009 §2.2.1: the client “must assume the token still exists and may
retry”), and a repair job replays the denylist from rows whose revoked_at
is set and expires_at is in the future; the ledger, not Redis, is the
source of truth. Introspection reports active: false when the row carries
revoked_at, whether or not the denylist has caught up. Revoking a tree_id
revokes the root, its whole tree and the refresh-token family the grant
issued. Revoking the root node’s jti is revocation of its tree_id: the refresh
family goes with it, so the root cannot refresh into the same node. RFC 7009
§2.1 explicitly permits a policy-defined cascade to “related tokens and the
underlying authorization grant”; this is that cascade.
Who may revoke, and how: the token’s own client over POST /oauth/revoke
(RFC 7009, unchanged, now cascading); the session owner (the ledger row’s
user_id, from the portal) and a live node of the same tree over a
QAuth-defined identifier API, POST /api/agent-sessions/{tree_id}/revoke and
POST /api/agent-tokens/{jti}/revoke — RFC 7009 takes the token string, not
an identifier, and a parent never holds its child’s token, so no RFC 7009
request can cut a subtree. Over the identifier API a node proves its place
by authenticating as its agent type and presenting one of its own
unrevoked DPoP-bound node tokens for the same tree_id with a DPoP proof; the
target must be that node or a descendant of it (tree_id revocation requires the
root node, the row whose instance_jkt is the root’s). Client authentication
alone never suffices: a client_id names a type (for example claude-code) shared by
every user and every box of that type, so the rule at
apps/auth-server/src/app/routes/oauth/revoke.ts:164 — today only the agent
named on the token can revoke it, by client_id — cannot be carried over to
identifier-keyed revocation without letting any instance of the type kill
any user’s tree. tree_id and jti are public (§2) and confer nothing. Foreign
identifiers, identifiers outside the caller’s subtree and a same-type tree
whose keys the caller does not hold answer 200 and revoke nothing. The
session owner also has a kill switch, POST /api/agent-sessions/revoke-all:
it revokes every live tree the user rooted, each by the walk above. Only the
owner may call it, never a node, and it asks for no step-up, because an
emergency stop must not wait (decision 1). Each call records its time as the
user’s last revoke-all, which §1 checks for a subject token with no
tree_id.
The revocation window equals the access-token lifetime, so that lifetime is a
policy parameter: a profile row consulted through resolveEnvironmentPolicy
(apps/auth-server/src/app/helpers/environment-policy.ts:248),
ADR-008 style —
agentAccessTokenLifespan, 300 s in production and staging, 900 s in
development — applied to every token in a tree, root included, still capped
by the subject’s remaining life; the 900 s ACCESS_TOKEN_LIFESPAN and 8 h
DEV_ACCESS_TOKEN_LIFESPAN are for humans’ apps. A leaf for a resource that
declares a written revocation window is capped at that window, in every
environment. The operator lists such a resource with its cap, as
AGENT_BEARER_LEAF_RESOURCES is set, and a listed resource’s cap defaults to
300 s; the leaf lives for the shorter of the cap and
agentAccessTokenLifespan. An OS-level authority manager is such a resource
(§10): its cap is the number that manager writes down as its window.
QAuth emits CAEP session-revoked
(https://schemas.openid.net/secevent/caep/event-type/session-revoked) as an
SSF 1.0 transmitter — /.well-known/ssf-configuration, streams per SSF §8.1.1,
push delivery (RFC 8935) for receivers that serve an endpoint and poll
delivery (RFC 8936) for those that cannot, the broker among them — with
event_timestamp, initiating_entity (user
| admin | system) and reason_admin. For a tree the subject is an SSF §3.3
complex subject whose session member is { "format": "opaque", "id": "<tree_id>" }
and whose user member is iss_sub — RFC 9493 §5 forbids a format from saying
what kind of thing it names; the session member is the registered way. For a
subtree the subject is jwt_id { iss, jti } naming the token at the
subtree’s root, a QAuth reading of the event for a partial session, stated as
such. Receivers use it to shorten the window; nothing depends on it. The
broker, a local daemon behind whatever NAT the developer box sits on, serves
no push endpoint: it polls its stream (RFC 8936) and, poll or not, learns a
revocation from its own traffic — a renewal re-spawn (§4) or a vend (§9)
refused for a revoked row — within one lifetime.
The dead-man switch is not QAuth’s and is not a connection: no process on
the CLI leg holds one — hooks, a platform-CLI shim, the git helper and a
harness’s header hook are one-call processes. It is a pidfd: at binding the
broker holds a pidfd_open on the node’s pid (§12; the harness adapter
contract has each node kind’s registration). The pidfd turning readable is
the node’s death — exit
or kill, however delivered — on which the broker revokes the subtree (a
ledger walk, if QAuth is reachable, under the node’s key or its parent’s
before discarding it; if QAuth is unreachable then, the subtree runs to its
lifetime, the fail-open window already named) and, reachable or not, stops
signing for that key and deletes every platform credential it cached for the
node, by the upstream’s own deletion call (for github,
DELETE /installation/token; §9). Same rule as a connection-lifetime grant
at an OS-level authority manager (§10), on process liveness instead of a
wire. Honest limit: when
the broker itself dies — crash, kill -9, reboot — nothing is revoked and
nothing is deleted until expiry: QAuth leaves 300 s, a vended platform
credential the rest of its upstream lifetime (an installation token’s hour,
for github). The broker keeps its live tree_ids (identifiers, not
secrets) in $XDG_STATE_HOME/qauth-broker/tree-ids and names them on its next
start; with its keys gone it cannot cut them itself (type authentication
alone revokes nothing, above), so the session owner’s revoke-by-tree_id from
the portal is the remedy.
7. Telemetry — action events into QAuth, from resources and from the agent’s own side
QAuth exposes an RFC 8935 push endpoint, POST /events/push, accepting SETs
(RFC 8417, typ: secevent+jwt) signed by registered transmitters — an
mcp-guard host as a resource client and a validator, each an oauth_clients
row whose jwks verifies its SETs — and by the broker for the node whose
action it reports, which signs with that node’s own DPoP key and never with
the type’s registered key (below). SSF stream
management is not required inbound in the first slice; RFC 8935 stands alone
and SSF layers on later. One event type,
https://schemas.qauth.dev/secevent/agent-action, with members action,
resource, decision and optional tool, arguments_digest, outcome;
toe and txn are top-level SET claims (RFC 8417 §2.2), txn correlating
the SETs of one tool call; the subject is sub_id in the jwt_id format
naming iss and jti (SSF 1.0 §3.5). The endpoint verifies the SET, then
looks the subject jti up in agent_token_ledger and refuses (400, one
audit line) unless the row’s aud contains an identifier the transmitter is
registered to report for — the resource identifiers listed on its
oauth_clients row (event_audiences, operator-set, the same identifiers
AGENT_BEARER_LEAF_RESOURCES names) for an mcp-guard host or a validator —
so a transmitter may report only on tokens minted for it. A broker SET names
the node’s type as iss, carries the node’s public key as its jwk header
and an events claim but no cnf or htm (§4), and is accepted only when
that key’s thumbprint equals the subject row’s instance_jkt and the node has
revoked_at IS NULL. So a node reports only on itself, and a type key, of
which every box of the type holds one (T2), is never accepted as a SET
signer. An unknown jti is refused the same way. It never
reads the token itself. agent_actions is keyed by the SET’s jti scoped to
the transmitter (RFC 8417 §2.2: unique within a feed; a duplicate is
acknowledged and dropped), records the transmitter’s client_id, and is
indexed by the subject jti; a SET whose iat is older than
AGENT_EVENT_WINDOW (default 300 s) is refused. Emission is asynchronous
and best-effort. The dashboard’s tree is the ledger joined with
these rows, streamed live from the portal (GET /api/agent-sessions,
GET /api/agent-sessions/{tree_id} and /{tree_id}/events over server-sent events).
Agent-side transmitters. The roster above is the operator’s. An agent’s
owner (§13) runs things beside the harness that see what the resource never
does — a loopback proxy that knows which model actually served a request, a
runtime that knows why a step was taken, a git hook that knows which commits
left the box — and QAuth takes their events on the same endpoint under the
same rule, with two additions. First, the owner, not only the operator, may
register a transmitter: an agent_transmitters row keyed by the agents
row it reports for, holding a jwks and the event_audiences it may name,
created through the owner’s portal or the developer API with the same
ownership check ADR-012 uses for clients; the owner is the responsible party
for what it sends. Second, the subject check is widened by one hop: an
agent-side SET is accepted when the subject jti’s ledger row belongs to a
tree whose root agent_id is the transmitter’s agent. It is still refused
for any jti outside that agent’s trees, and it is still a claim: every
member an agent-side transmitter writes lands in agent_actions with
source: agent, is shown apart from the other rows, and is never an
input to §8. agent_actions.source is one of resource (an mcp-guard host
or a validator), broker (the broker’s vends and its push report, §9,
signed with the subject node’s own key: that node’s report, shown as
“reported by the node”, never as a resource’s observation) and agent (an
owner-registered transmitter); the dashboard labels all three. Registration is by out-of-band exchange of the transmitter’s
public keys and the push URL — RFC 8935 alone. SSF stream management (SSF 1.0
§7, §8) is receiver-initiated: the receiver reads the transmitter’s
/.well-known/ssf-configuration and creates the stream there, which a
daemon on a laptop behind NAT cannot serve. A transmitter-initiated
registration and an agent-action event type are what QAuth would take to
the Shared Signals WG, which is extending SSF and CAEP toward agentic use
cases; until either exists the out-of-band row is the mechanism, and the
event URI stays QAuth’s.
The agent on a stored row, and the members that name a model. Every
agent_actions row gains an agent_id column that QAuth fills from the
subject jti’s ledger row; it is not an event-type member, and a SET that
carries an agent member is refused (the caused_by rule, §5). Two
optional members join the event type. model — the model identifier the
transmitter reports for the request the action came from, a string ≤ 128
bytes matching ^[A-Za-z0-9._:/-]+$, bounded like task (§5). reason —
the transmitter’s free text for why the step was taken, bounded exactly as
purpose is (§5, ≤ 200 bytes of printable Unicode) and rendered exactly as
purpose is: escaped, in a box attributed to the transmitter, never inline.
model and reason are recorded, displayed and never evaluated (T6, T7): a
model name reaches QAuth only as a harness’s or proxy’s report of itself —
the harness hooks expose a model only at session start and on a switch, and
a loopback proxy sees the model on the wire — and QAuth has no way to check
it. The dashboard shows them under “reported by the agent”, beside and never
inside the ledger’s “verified” column, and introspection never returns them.
The one model fact QAuth does hold is which agent type minted the token,
which is the client_id and always was.
8. Decision API — AuthZEN 1.0, off the hot path
QAuth exposes an OpenID AuthZEN 1.0 evaluation endpoint,
POST /access/v1/evaluation (and /access/v1/evaluations boxcar), discovered
at /.well-known/authzen-configuration (§9.2, §10.1), PEPs authenticated as
QAuth clients (§11.2). Requests follow the COAZ-MCP binding’s default
tools/call mapping (§7.1): subject is type: identity with id the
token’s sub, context.agent the token’s client_id — the binding’s §11.2
puts the agent in context so user and agent trust are evaluated independently
— and a QAuth property context.qauth carries tree_id, jti, jkt, act and
depth, filled from the ledger when only jti is supplied.
The fast path stays offline: mcp-guard, the validator and the broker decide on
claims. Where an operator configures a PDP, the broker asks at vend time,
mcp-guard optionally per tool call, and harness hooks as advice; a PDP can
only refuse what the claims would allow, never allow what they refuse —
enforced at QAuth: the evaluation endpoint first evaluates the claim ceiling
from the ledger row (scope, aud, authorization_details, revoked_at)
and answers false when the claims refuse, whatever the policy says, so a
PEP that forgets to check the claims cannot be widened by the PDP. PDP
unreachable ⇒ existing credentials run to expiry, new vends refuse. AuthZEN’s
Access Request and Approval Profile (Draft 1) is the shape of a requestable
denial: a false decision carrying context.access_request (§7), and an
approval that expires at approved_until (§12). It leaves binding an
approval to OAuth token issuance to a profile; §14 is that binding on
QAuth’s side, with CIBA as the wire. The evaluation endpoint does not
return context.access_request yet.
9. CLI — qauth-broker and a QAuth-side STS; the credential is the gate
qauth-broker is a local daemon on $XDG_RUNTIME_DIR/qauth-broker.sock: the
OAuth client role for the CLI leg — it holds the client credentials
(private_key_jwt keys, never a secret, §4(e)) of every agent type that
host runs and authenticates to QAuth as the type of the node it is acting for, the
session’s main-agent type at the root, the child’s type on a spawn (§4(e)),
never as a client of its own; there is no broker entry in the seed manifest
— the key custodian (§3), the maker of spawn exchanges (§4) and the
credential vendor. It attributes callers as §12 says.
The credential vendor works through adapters. A credential adapter is the
part of the QAuth-side STS that vends one kind of upstream credential for a
node, and the part of the broker that holds and deletes it. ADR-016 calls the
same thing a vendor kind. A vend names its adapter in the route,
POST /api/credentials/{adapter}. An adapter that mints a narrowed credential
follows these rules: the node’s DPoP-bound leaf for that resource and its
proof; a ledger check, which refuses a token with no row (§2); a ceiling
taken from the token (T4) and bounded by its type’s registered_rights
(§5); explicit resource and permission lists upstream, where an empty list
refuses the vend rather than omitting the parameter; a refusal whenever an
upstream read the vend depends on fails, answers not-found or is
rate-limited; the upstream app identity’s private key held QAuth-side,
never on the box (§13); a binding that belongs to the token’s own agent and
whose adapter_ref is assigned to that agent’s current owner at the vend
(§13); and a log row per vend.
github is the first adapter. ADR-016 adds a weaker kind for a platform that
cannot narrow, the pass-through leg, and states what it gives up. A git host
is the worked example, and no rule here depends on it. Another platform
enters by adding an adapter, not by changing a rule. The bullets that name
GitHub, gh or PostgreSQL below are examples.
- Example adapter,
github— installation tokens are minted by the QAuth-side STS atPOST /api/credentials/github, presented with the node’s DPoP-bound leaf for that resource and its proof (a bearer node token until P1a). QAuth verifies the proof, reads the ledger row (not revoked; no row refuses), restrictsrepositories⊆ the token’slocationsandpermissions⊆ itsactions, both within the type’sregistered_rights— until §5 lands, that column alone is the per-type policy, octo-sts-shaped — and mints with the upstream app identity’s private key (for thegithubadapter, the GitHub App’s), which lives QAuth-side (§13) and never on the developer box; Chainguard’s octo-sts is the prior art and an interim option. It always sends an explicit resource list and permission list, because a platform may read an omitted list as everything (for example, GitHub grants every permission the installation holds whenpermissionsis omitted), so an emptyactionsrefuses the vend. For an agent’s identity it vends only for an installation on an allowlisted account, and only from the binding of the ledger row’s ownagent_id(§13); a tree with no agent named mints only from the per-organisation identity of decision 4. Once P1a lands the STS acceptsAuthorization: DPoPonly, checkshtu/htm/athandaud= its own identifier exactly, and refuses to start if its identifier appears inAGENT_BEARER_LEAF_RESOURCES: the one endpoint family that turns a 300 s sender-constrained token into a longer-lived unconstrained credential never accepts a bearer, on any adapter’s route. The broker caches each node’s credential for its upstream lifetime (forgithub, one hour) and deletes it at node end and the moment it learns of revocation (§6), by the upstream’s own deletion call (forgithub,DELETE /installation/token), so a QAuth outage blocks new vends only; until it learns, the upstream lifetime is the window. An adapter whose upstream has no deletion call says so in its description, and its credential’s lifetime is the window. - git — a credential helper, injected into the node’s environment as
GIT_CONFIG_COUNT=3,GIT_CONFIG_KEY_0=credential.helper/GIT_CONFIG_VALUE_0=(the empty value resets the helper list, so no inheritedstore,osxkeychainorlibsecrethelper from the user’s system or global config ever sees the node’s credentials),GIT_CONFIG_KEY_1=credential.helper/GIT_CONFIG_VALUE_1=!qauth-broker git-credential, andGIT_CONFIG_KEY_2=credential.useHttpPath/GIT_CONFIG_VALUE_2=true, answersgetwith the node’s cached credential for that remote (from the remote’s adapter; forgithub, the installation token) andpassword_expiry_utc, so git drops it at expiry, and answersstoreanderaseas no-ops: git sendsapproveto every configured helper after a successful push, andpassword_expiry_utconly makesfillskip an expired password — it does not stop a durable helper from persisting the token, so the reset is what keeps the token out of the user’s credential store and the user’s own pushes off the agent’s platform identity. - Platform CLIs (example, the
ghshim) — a shim for a platform’s own CLI on the agent’sPATHexecs the real CLI with its token and config-directory variables pointing at a fresh, empty per-node directory in the child’s environment only. Example,gh: the variables areGH_TOKENandGH_CONFIG_DIR(an empty value is treated as unset and falls through to~/.config/gh), so the user’s storedghlogin is never touched. The shim setsGH_PAGER=catand refusesgh auth tokenandgh auth status --show-token— UX, not custody, by this section’s own rule: the token is still readable from the child’s environment and fromgit credential fill(T2). - Precondition, not enforcement — the shim and the credential helper are
conveniences, not the gate: the real CLI by absolute path reads whatever
login the uid holds (example:
/usr/bin/ghresolvesGH_TOKEN, thenhosts.yml, then the keyring), and agit pushto an SSH remote never consults a credential helper. The CLI gate holds only when the agent’s uid carries no ambient credential the broker did not vend — no stored platform-CLI login in its config directory or the keyring, no~/.git-credentialsor stored credential helper for the platform’s host, no SSH key the platform accepts, no~/.pgpass(thepsqlexample below), no platform token variable in the inherited environment (for GitHub: agh auth loginin~/.config/gh, a stored credential for github.com, an SSH key GitHub accepts,GH_TOKENorGITHUB_TOKEN) — which on a developer box means the agent runs as its own uid or the user’s login is removed from that uid. The broker checks for these at session binding (the harness adapter’s session registration) and refuses to bind, with the reason logged, while any is present. An operator who runs agents at their own login has no first gate on the CLI leg. - Provenance — a
prepare-commit-msghook and, for a platform CLI the broker shims, a pull-request-creation passthrough (example:gh pr create) appendQAuth-Session: <tree_id>/<jti>to commits and PR bodies: a pointer to the ledger row, not a proof — both identifiers are public and confer nothing (§6). A platform may attribute a vended credential’s activity to the platform identity and record no token identifier (GitHub does so for an installation token, attributing it to the App’s bot), and then the trailer is the only platform-side key; the proof is the STS log row it names. Where the tree roots in an agent principal (§13) the broker also setsGIT_AUTHOR_NAME/GIT_AUTHOR_EMAILandGIT_COMMITTER_*in the node’s environment from the agent’s binding for that platform — the platform identity’s login and the platform-assigned attribution address, which QAuth records on the binding at provisioning (for GitHub, the App’s bot login and its<id>+<slug>[bot]@users.noreply.github.comaddress) — so a commit the agent wrote is authored by the agent, and the hook addsModel: <model>from the node’s reported model (§7, a report) and anAgent:line. That trailer carries the agent’sagent_idand itshandle@issuer-hostform (ADR-016 §5), with a version marker (decided 2026-10-09, ADR-016 question 5). The broker writes noSigned-off-by,Reviewed-byorAssisted-byline and refuses a commit message that carries one from the node: the first two certify a human’s act and only a human adds them at review or merge; the third means a human wrote the code with an agent’s help, the kernel’sAssisted-by: AGENT:MODELconvention (Documentation/process/coding-assistants.rst, merged April 2026), and never appears on an agent-authored commit. Apre-pushhook reports the pushed commit hashes to the broker, which emits them as one §7 SET (action: git-push,resourcethe repository, acommitsmember listing the hashes, signed with the pushing node’s own key and stored assource: broker, that node’s report) so the owner’s own systems can resolve a hash to a node; a squash- or rebase-merged hash is the git host’s, not the agent’s, and resolves through the merged pull request instead. Every line of it is a claim where it lands (T7); the ledger row it points at is the fact. - Example resource adapter, PostgreSQL (
psql) — a resource adapter presents a leaf in the form its resource accepts, and this one is an example, not core. libpq’s built-in OAuth flow is device-code only andpsqlhas no authdata hook, so the broker ships a preloaded library (or a thinqauth-psqlwrapper) that installsPQsetAuthDataHookand answersPQAUTHDATA_OAUTH_BEARER_TOKENwith a single-audience leaf (resource= the database’s identifier, lifetimeagentAccessTokenLifespanper §6 — 300 s in production) minted by narrowing.qauth_pg_validator, a PostgreSQL 18oauth_validator_librariesmodule, checksissandaudexactly,exp,typat+jwt,token_use, HBAscope⊆ token scope,locations∋ this database,actions∋ the requested role, and mapsauthn_id=subwithdelegate_ident_mapping=1; keys come from a JWKS cached under ADR-019 Decision 9’s rule, at most 5 minutes, so a shorter outage never blocks a connection whose key is known; it never logs a token and posts its SET from a worker, not insidevalidate_cb. It consults no denylist and cannot kill a live backend on revocation: a revoked leaf opens new connections and keeps existing ones untilexp, and a retired or emergency-retired signing key validates until the JWKS cache refreshes, at most 5 minutes later — the leaf’s lifetime (§6) and a short cache TTL are the window, as PostgreSQL’s own validator guidance says for offline validation. - MCP — a proxy that attaches
Authorization: DPoPand the per-request proof for MCP servers whose mcp-guard verifies DPoP, since some harnesses’ MCP clients send Bearer only (for example, Claude Code’s does; §3). The proxy is astdioMCP server (qauth-broker mcp-proxy --type <agent_type> --url <upstream>) spawned by the harness, so it is a child of the session process and the broker attributes it bySO_PEERCREDand ancestry like any other caller; it never listens on loopback. Where only a URL can be configured (an SDK-built agent’shttpserver, or a harness’s header hook such as Claude Code’sheadersHelper, in P3), the broker listens on a random loopback port and requires a per-node secret in a request header that it writes into that node’s server definition (headers); a request without the node’s secret is refused, and the listener resolves its caller as the socket does (§12). A path or a header naming the agent type is routing, never attribution, and picks only among the types the caller’s node may spawn (§12).
The credential is the gate; hooks are UX (the Alternatives table;
Harness adapter contract). The broker must
log, per vend:
tree_id, jti, client_id, cnf.jkt, pid,
uid, ancestry to the bound node (and the bound node’s pidfd/start time), the
executable and subcommand (never arguments), the resource and permissions
requested, what was vended (an opaque credential id and the upstream grant’s
identifier — for github, the installation_id — never the credential), the
PDP answer if asked, issue and
delete times, exit status, and every refusal with its reason — purpose and
task as quoted JSON strings, never interpolated (§5). Uses of a vended token
are not logged — vends, subcommands and deletions are; the platform’s audit
log is the record of use, attributed to the platform identity (example:
GitHub’s, to the App). The STS logs the same, keyed by
jti; each vend emits the §7 SET.
10. OS-level authority managers — the composition rule
An OS-level authority manager is a local component below QAuth that verifies QAuth tokens and enforces grants on the machine. A token is a ceiling on what may be petitioned there, never a grant. The manager sits on the actuation path, and QAuth does not. The QAuth token is one credential type among the manager’s own peers. Vitrin is one such manager, and the worked example; it is not the only one. What QAuth promises, proposes and must not claim is in OS-level authority manager composition.
11. Consent — the tree ceiling on the screen
For an agent client the consent screen states that the client is an AI agent
— and, when the root names an agent principal (§13), which one: its
display_name and its owner’s display name, each escaped by the same
html tag as the client name (§5), and its avatar, served from QAuth’s own
origin at the fixed path §13 gives, so the person sees “Release bot, owned by
you” and not only a harness type’s name such as “claude-code” — and shows the
ceiling of the tree
it may grow: the scopes with descriptions
(as of 2026-09-30, agent:* and write:* render raw), including the union of agent
modes the tree may use, since no mode implies another; the agent types it may
spawn — the client’s operator-set spawn_allowlist, written into the root
token’s aud beside the requested resource and the client’s own client_id
so GATE 3c stays byte-identical; every other member of that aud, the
resources the tree may reach; the root’s locations and actions from
authorization_details (§5), rendered from QAuth’s typed record of the
request in the operator’s part of the page — every string escaped, one per
line, grouped by credential adapter, never summarised by purpose or by a
count; the client-attributed purpose from
authorization_details (§5), in its own box beneath the scope descriptions
and those rights; the persistence rung, “may keep working until you revoke
it” — a code grant always issues a refresh token
(apps/auth-server/src/app/routes/oauth/token.ts:625), so every root can renew
until tree_id revocation or the refresh family expires — with the date that
family expires; the depth cap, MAX_DELEGATION_DEPTH; and, from the broker,
the qauth_binding_code its session shows (§1). One sentence says the
agent may delegate downwards within this ceiling and never beyond it. A root
whose authorization_details the screen cannot render in full is refused,
never shown in part; one beyond its type’s registered_rights or its
agent’s root ceiling never reaches the screen (§5). A root
that names an agent asks for that agent’s root ceiling (§13) and never more:
scopes beyond it are invalid_scope and rights beyond it
invalid_authorization_details, at /authorize. The owner
raises that ceiling in the portal with a passkey, and the next root takes the
raised ceiling only through this screen (decision 17).
spawn_allowlist follows max_agent_mode: seed manifest only, never DCR,
CIMD or the developer API; once the realm file and the admin API carry it,
a write is a security operation (§5). Enrichment is conditional on the column:
resolveAudience adds the client’s own client_id and its spawn_allowlist
to aud only for an agent client whose spawn_allowlist is non-empty. A
client with none — every DCR and CIMD client, every seeded type an operator
has not given an allowlist, and therefore every client in a default
deployment — keeps today’s aud byte-for-byte, presents that token at its
resource as today, and can neither spawn nor narrow: a tree of one. An
allowlisted type’s root always names at least itself, which is what lets it
narrow, and that root is the one the broker holds.
Why aud, and why a separate column: RFC 8693 §2.1 defines audience as
“the logical name of the target service where the client intends to use the
requested security token” and gives an OAuth client identifier as an example
value; reading aud ∋ client_id as “who may present this at the STS” is
the splicing thread’s rule, not RFC text, and it is what GATE 3c already
checks (the Alternatives table, may_act). Resource audiences (RFC 8707)
and STS presenters (RFC 8693) are separable, so the allowed-children set is
its own operator-set column beside the audience allowlist
(libs/infra/db/src/lib/schema/core.ts:163). The enriched root token is
multi-audience, which RFC 8707 §3 reserves for parties that trust
each other highly; every member here is the operator’s own resource server or
agent-type registration, and the enriched root token is held by the broker
and never presented at a resource — every resource sees a single-audience
leaf. An agent root never takes the skip-consent fast path
(canSkipConsent, apps/auth-server/src/app/helpers/consent.ts:77), and
prompt=none for an agent root answers consent_required (decided
2026-10-06; ADR-015 decision 11). A tree_id is minted per
grant, not per screen, and by a code grant only when its screen showed the
persistence rung; §1’s exchange rule is the one other source. A root grant
that names a dangerous scope
(agent:exec, agent:admin, any write:*, or a pass-through leg’s
<kind>:owner-token, ADR-016 §4) also needs a fresh login in
staging or production: step-up rule 3 (evaluateStepUp,
apps/auth-server/src/app/helpers/step-up.ts:196) forces a fresh login
for the whole requested set unless the browser session is under two minutes
old, and only a development profile relaxes it. The default root type is
operator-defined (decision 7); a root type seeded with exec, as the example
there is, costs the user one login for each new root, and a read-only-only
root does not.
12. Harness — a node is a process
A node exists only where a process holds its own handle. The broker resolves a
caller by walking the SO_PEERCRED pid’s parent chain to the first bound pid
— or, on a connection the broker issued as a spawn handle (below), to that
connection’s node; a chain that reaches no bound node is refused and logged —
never attributed to the session — unless it is a session registration, which
starts a root, or presents a spawn handle (below). On every other
connection the broker takes the peer as a pidfd (SO_PEERPIDFD) or, on a
kernel without one, checks at each call that the process at the peer’s pid
still holds the connection’s other end; a peer that has exited resolves to
nothing, so a recycled pid never stands in for a caller. The session node is
bound like every other node: qauth-broker login opens a pidfd on the session
process (fallback: pid plus /proc/<pid>/stat start time, field 22), and
every binding — session, teammate, spawner-started child — is a pidfd or
pid+start-time pair, so a recycled pid never inherits a node, and the broker
unbinds a node the moment its pidfd signals exit. An in-process sub-agent
shares the session process and so shares the session node; that is the harness
limit, not a default. Which nodes a harness can bind, and which it cannot, is
in the Harness adapter contract, with the Claude
Code adapter (read 2026-09-21) as its example.
The broker places a new node; the caller does not. A registration — of a session, a teammate, a spawner-started child or a by-type key — carries what only the harness knows. The broker decides the rest from what the kernel tells it:
- Parent. A new node’s parent is the bound node the caller resolves to,
and the broker signs its spawn assertion (§4) with that node’s key and no
other. A lead, parent or session id that a request names is checked
against that node, and a mismatch is refused and logged. A process that a
request names must be one of these: the caller; an ancestor of the caller
that is in the caller’s own POSIX session (
getsid), sits below the process of any node the caller resolves to, and is an ancestor of no other bound node’s process and of no other registered session; or the caller’s own child, passed as a pidfd (the spawner path). - Type. The child’s type must be in the parent type’s
spawn_allowlist, GATE 3d’s rule, which the broker checks before it signs. A proxy’s--type, a header or a hook’s agent type picks among those types and adds none. The node it yields is a spawn by the parent, with its receipt and its ledger row (§2, §4). - Root. A session registration starts a root (§1) only when its caller resolves to no bound node. Under a bound node it is a child registration under that node, except that on a host with one root per broker start a registration whose caller resolves to the root’s own node joins it (§1). A resume rebinds a root only as §1 says. No path gives a process under no node a root’s ceiling without the person’s consent screen.
- Outside the parent’s process tree. A child that does not descend from
its parent’s process — a teammate in its own terminal pane, a child under
another uid or in a sandbox — binds only through a spawn handle that its
parent’s node obtained. The parent’s call, resolved as above, asks for one
naming the child type, which the broker checks as above. A handle binds once
and lapses unless presented within 60 s; the registration that presents it
binds its process, with a pidfd, under the node that issued it. The handle
is a connected socket that the broker passes to the parent over
SCM_RIGHTSand the parent leaves open in the child. Where the launch path keeps no descriptors, it is an opaque single-use value in the child’s environment, which a same-uid process can read (T2), and never in a command line, which every user on the host can read. A child registration that presents no handle and resolves to no bound node is refused, never read as a root. Nothing else a child reads — a harness’s team configuration, a log, another process’s environment — is authority. - The socket. The broker creates
qauth-broker.sockwith mode 0600 in the user’s$XDG_RUNTIME_DIR(mode 0700) and refuses a peer whoseSO_PEERCREDuid is not its own. A child under another uid or in a sandbox is never given the path and needs nothing under the parent’sHOME: it reaches the broker only through the connected socket its spawner obtained as its handle, and every call on that connection resolves to the child’s node, whatever the child’s own ancestry says. - The loopback listener. The listener of §9 follows the socket’s rules. It maps each connection to the process holding its other end through the kernel’s socket table, refuses a connection it cannot map or whose uid is not its own, and resolves that process as above. It serves a request only when the per-node secret names the node that process resolves to or a node below it.
The residual, stated plainly: any process that reaches the broker under a bound node — a same-uid descendant, or a holder of that node’s connection — can spend that node’s ceiling, never a wider one; whoever reads an unconsumed environment handle first can bind in the child’s place, within what the parent granted; and a same-uid process under no node — a node’s descendant that left its ancestry by a double fork among them — can ask for a root, or for a resume of one, which reaches nothing until the person approves its consent screen (§1, §11). The binding code helps the person match a screen to a session but proves nothing, since a model can print any code into its own session.
13. Agent identity — a principal with an owner, held by QAuth and asserted nowhere else
An agent is a new principal in the realm: neither a user nor a client
registration, but a durable record that a user owns and that a tree can root
in. It lives in a new table, agents: id (uuid), realm_id, handle
(unique within the realm, ^[a-z0-9][a-z0-9-]{1,62}$, immutable once a
binding exists), display_name, description, avatar (a PNG QAuth
stores and serves at /agents/{handle}/avatar.png on its own origin, ≤ 512
KiB, decoded and re-encoded on upload so nothing but pixels survives — no
SVG, which can carry script, and never a URL QAuth would dereference on
render), active, profile_visibility (public | private, default
public), root_ceiling (the agent’s default root ceiling: the scopes, and
per credential adapter the locations and actions (§5), that a new root
naming this agent asks for and never exceeds; the owner raises it in the
portal with a passkey, and the next root takes it only through consent, §11;
decision 17),
owner_user_id (a users
row, onDelete: 'restrict' — an agent never outlives its owner silently;
the owner deactivates or transfers it first), created_at, updated_at.
The owner is the person the SCIM Agent draft calls the responsible party,
and the only party who may edit the row, bind it to a platform, register a
transmitter for it (§7) or deactivate it. A harness’s own agent_id
(a sub-agent instance, Harness adapter contract)
is a different thing with the same name and is never written to the ledger.
There is no email on an agent and
none is needed: ADR-002 already made
email a credential, not an identity, and an agent has no credential of its
own — it acts through the harness types and keys this record already binds.
Where it sits in the tree. sub stays the human (§1); nothing here
touches the root invariant. The agent is named in the root grant: an agent
client that authorizes with agent_id=<handle or uuid> on the authorization
request roots the tree in that agent, and QAuth refuses the request
(invalid_request, audited) unless the row is active and its
owner_user_id is the authenticating sub — the consent screen then names
the agent (§11). The ledger’s kind: root row records agent_id; every
descendant row inherits it unchanged as tree_id is inherited (§2); introspection
returns it in qauth_delegation as agent; revocation gains one more
identifier, POST /api/agents/{id}/revoke, which cuts every live tree of
that agent by the §6 walk and may be called by the agent’s current
owner_user_id or a realm admin — not by a node, whose reach stays its own
subtree (§6). A transfer (decision 10) revokes every live tree of the agent
by the §6 walk, so from then on the agent acts only under trees the new
owner roots; the old trees’ rows keep their user_id. act is untouched:
act.sub remains the harness
type’s client_id, because draft-mcguinness’s actor identifier is a client
identifier and §14.12 wants it durable, and the agent is not a client. A
grant with no agent_id is byte-for-byte today’s grant: the column is NULL
and every consumer treats NULL as “no agent named”.
SCIM projection. agents is served as the SCIM Agent resource of
draft-wzdk-scim-agent-resource-00 (June 2026, individual, Informational) at
/scim/v2/Agents, schema urn:ietf:params:scim:schemas:core:2.0:Agent:
agentUserName ← handle, displayName, description, active, and
owners[] ← one value whose $ref is the owner’s User resource. The draft
defines no avatar and no platform binding, so both go in a QAuth extension
schema, urn:qauth:params:scim:schemas:extension:agent:1.0, with avatar
(a URI on QAuth) and bindings[] (below) — named as an extension, never as
a core attribute, and offered to the SCIM WG list as feedback on the draft.
The projection is read-only in the first slice; the developer API and the
portal write the row. QAuth has no SCIM endpoint today, so this is the first
one, scoped to this resource, GET only, authenticated as the realm’s
developer API is.
Bindings. A binding is one row per (agent, platform) in
agent_bindings: platform (the name of the credential adapter, §9; github
first, others as their adapters are provisioned),
external_id (the platform’s identifier for the identity that acts — on
GitHub, as the example, the bot user’s numeric id from
GET /users/{slug}[bot], which is
what the noreply address carries, not the App id), upstream_id (the id of
the application or integration record that identity belongs to, where the
platform has one — on GitHub the App id), external_login, external_email
(the attribution address the platform assigns), adapter_ref (the reference
naming the upstream app identity and private key the adapter mints from for
this agent: a key in QAuth configuration, or one its owner uploaded; below,
§9), proof (how QAuth learned it; for
GitHub, the STS App’s
own GET /app and GET /users/{slug}[bot] answers at provisioning),
bound_at. A column an adapter has no use for stays NULL; the adapter
defines what each column holds for its platform. The platform identity the
binding names is the one the adapter mints from. For GitHub the binding is
the STS App of §9: the App’s slug
is the agent’s public name on that platform, its bot login the author of
every commit and pull request the agent makes (§9, provenance), its App page
the place GitHub itself shows the owner account as the developer. One
upstream app identity per agent, then — for GitHub, one STS App per agent,
not one per organisation (decision 4 is amended below) — and its private key
stays where §9 puts it. The STS vends for that identity only on accounts the
binding’s allowlist names, by account, and removes every other installation
of it (decided 2026-09-30; ADR-016 §2). Installation ids are read from the
platform, not configured. The avatar the platform
shows is the platform’s (example: GitHub exposes no API to set an App’s logo),
so the owner sets it by hand from the same file QAuth serves, and QAuth
records nothing about whether they did.
Whose upstream identity a binding may name. An adapter_ref belongs to
exactly one owner, and QAuth refuses, audited, a binding whose adapter_ref
is not assigned to the authenticating owner.
- Assignment. An
adapter_refis assigned in one of two ways. The operator’s configuration entry for it names that owner’suser_id. Or the owner uploads the upstream identity’s private key through an owner route (ADR-015 §3’s guard, a passkey approval per operation). - Upload. QAuth stores an uploaded key envelope-encrypted under a
per-realm key used only for this purpose, as ADR-016 §4 keeps the
pass-through token, and no route returns it.
proofis the platform’s answer under that key (for GitHub,GET /app), so holding the key is the proof. Either way the key stays QAuth-side, never on the box. - The per-organisation identity. The per-organisation identity of decision 4 is assigned to no owner, so no binding can name it.
- Refused uploads. An upload is refused, audited, when its key opens the
per-organisation identity or any other upstream identity QAuth’s
configuration already holds (compared by
upstream_idandexternal_id), as a binding naming such an identity is. - Uniqueness.
agent_bindingsis unique on (platform,adapter_ref) and on (platform,external_id), so one upstream identity serves one agent. - At every vend. The STS refuses a vend whose binding’s agent is not the
ledger row’s
agent_id, or whoseadapter_refis not assigned, at that vend, to the agent’s current owner (§9); an uploaded key stays assigned to the owner who uploaded it. So after an operator reassigns theadapter_refor the agent is transferred (decision 10), the binding mints nothing until one assigned to the new owner replaces it. Neitherproofnor the allowlist stands in for this check: the platform’s answers say which identity the key opens, not which QAuth user may use it.
Public profile. GET /agents/{handle} on the realm’s public origin
serves the profile, unauthenticated: display_name, description, avatar,
the owner’s display name, the bindings’ external_logins, active, and a
fixed statement of what the page attests — that this agent is a principal of
this issuer owned by this person, and nothing about any commit, message or
action that names it elsewhere (T7). The same at Accept: application/json.
No tree_id, jti, node, scope, model or event ever appears on it; those are
the owner’s and the realm admin’s (§2, §6). An agent whose
profile_visibility is private answers 404, indistinguishable from a
handle that does not exist.
What QAuth does not do here. It does not provision the platform side —
create the upstream app identity on the platform (for GitHub, the App),
upload its logo, set an avatar on another platform — and it does not
verify what the agent says about itself on those platforms (T7). It does not
sign commits, evaluate a model name, or hold a memory, a runtime or a reason
for a step beyond the bounded reason member of §7. Those are the owner’s
own systems, out of QAuth’s scope and not specified here; QAuth’s part is the
row, the binding, the profile, the tree that roots in it and the log that
names it.
14. Remote approval — a refused request the owner approves out of band
The ceiling is fixed at the root, and every hop can only narrow it (the
invariant). Sometimes the owner wants to lift it for one step. An agent
needs write access to one resource, once (on a git host, contents: write
on one repository). The owner is away from
the terminal and drives the session from a remote or mobile owner client,
such as a phone, with no shell and no SSH. This section lets the owner
approve that one step from there. It widens no token and no tree. It is
not a harness’s own permission prompt: that prompt is the harness asking,
and hooks are UX (§9); this is the credential gate. It sits behind
AUTHORITY_TREE_ENABLED and its own switch, REMOTE_APPROVAL_ENABLED (default
false).
Nothing that exists is widened. A refusal stays a refusal. If the owner approves, QAuth mints a separate token for exactly what was asked: an elevation leaf. It is bound to the requesting node’s key, it can neither spawn nor narrow, and nothing below the node inherits it. The invariant still holds for every token an exchange derives; an elevation is bounded by its own approval, not by a parent.
The flow.
-
Refusal. A node asks for more than its ceiling: at the token endpoint (
invalid_scopefrom GATE 4a,invalid_authorization_detailsfrom §5), at the STS (§9), or at an mcp-guard resource (403 insufficient_scope). The broker sees the refusal. A GATE 4d refusal is final and leads to no request. No approval lifts a type’s registered scopes (decided 2026-10-09, ADR-015 question 4), and so none lifts itsregistered_rightseither, 4d’s other half (§4, §5). The owner’s route to more is decision 17: raising the agent’s root ceiling with a passkey (§13). -
Request. If the node’s token carries
agent:request(below), the broker files an approval request: an OpenID CIBA backchannel authentication request (CIBA Core 1.0 §7.1), in poll mode — poll, because the broker sits behind NAT, the same reason §6 polls its SSF stream. The broker authenticates as the node’s agent type and adds a DPoP proof under the node’s key. It sends:login_hint_token= the node’s own DPoP-bound token. CIBA requires exactly one of its three hints and leaves this one’s format to the deployment; QAuth defines it as the requesting node’s token, which names the user, thetree_idand the node;- the delta asked for:
scope(with theopenidvalue CIBA requires) and oneagent-taskentry inauthorization_details(§5) naming one resource — RFC 9396 §3 lists CIBA requests among the placesauthorization_detailsmay appear; binding_message: a short code the broker shows in the session, which the approval page shows too — CIBA’s own purpose for it, a visual cue that interlocks the two devices;requested_expiry: at mostREMOTE_APPROVAL_EXPIRY(default 300 s);qauth_approval_duration:onceorwindow— what the agent asks for; the owner decides. CIBA lets a profile add parameters (§7.1).
-
Notification. QAuth notifies the session owner — the ledger row’s
user_id, never anyone else — through a channel the owner registered (decision 16): web push to the portal, plus an optional owner-registered webhook; never email. The webhook goes through the egress client of ADR-019 Decision 8, which checks its URL when the owner registers it and again at every delivery. A notification carries only the request id, the agent’s handle and the URL of QAuth’s approval page. It never carries an approve action: approval happens only on QAuth’s origin. -
Approval page. QAuth renders it from the typed request, never from the model’s text: the agent (principal and type, §13), the session, the node, the exact delta, the resource and the duration asked. The node’s
purposeappears below, in the box attributed to the client (§5). Thebinding_messageis shown for matching. The owner answers with a passkey: a WebAuthn assertion with user verification, whose challenge QAuth binds to this request. A live browser session is not enough. The choices:- Approve once — one elevation leaf for the one resource, with the
agentAccessTokenLifespanlifetime (§6), or one STS vend. - Approve for a while — the owner picks the window: 15 minutes, one hour, or until the session’s grant ends. A window never outlives the root’s refresh family and never reaches a durable rung.
- Deny.
- Deny and mute — for this session, or for a chosen time. Further
requests from that
tree_idare refused withaccess_deniedand notify no one. - Always block — a standing deny the owner sets for the same delta from this agent. It lasts across sessions, refuses like a mute, and is removed only in the portal.
There is no “always allow” (decision 17). A lasting rise is the owner’s change to the agent’s root ceiling (§13), which the next root takes through consent (§11).
- Approve once — one elevation leaf for the one resource, with the
-
Token. After an approval, the broker’s next poll at the token endpoint (
grant_type=urn:openid:params:grant-type:cibaand theauth_req_id, CIBA §10.1, with a DPoP proof under the same key) returns the elevation leaf; until then it getsauthorization_pending, and on a denialaccess_denied(§11):sub= the user,client_id= the node’s type,cnf.jkt= the node’s key,tree_idandact= the node’s;- scope and
authorization_details= exactly the approved delta — not the node’s scope plus the delta; - one audience, the resource; no refresh token.
The ledger records it as
kind: elevation, withparent_jti= the requesting node’s token and anapproval_receipt: the request id, the approved delta, the duration, the passkey’s credential id and the time of the assertion. Any token exchange whose subject is akind: elevationtoken is refused withinvalid_request, spawn and narrow alike: the leaf is used where it was approved, by the node that asked, and nowhere else. -
A window. While a window is open, the node renews its elevation leaf by a new CIBA request (step 2) for the same delta, under the same key. QAuth matches it to the open window and resolves it silently, with no notification and no passkey. The broker’s next poll returns the new leaf (step 5). The match may reuse the canonical-request hash of ADR-019. No token exchange renews an elevation, so GATE 4a has no exception (decided 2026-10-09, ADR-015 question 5). The renewal is a new
kind: elevationrow under the same approval. QAuth accepts a renewal only in the last 60 seconds of the live leaf’s lifetime. An earlier one is refused withinvalid_requestand notifies no one, so a node gets at most one leaf per leaf lifetime. A matched renewal does not count againstREMOTE_APPROVAL_BUDGET, which caps the questions that reach the owner. It still needsagent:request, and a mute or an always-block refuses it like any request (decided 2026-10-09). The window ends at its time, when the node’s process dies (the dead-man switch, §6), when thetree_idis revoked, or when the owner ends it from the portal. The §6 walk treats elevation rows like any other row.
Asking is a scope of its own. agent:request is the right to file an
approval request at all. It sits in the root grant like any other scope,
and the consent screen shows it (§11). An approval can never grant or
extend it, an elevation never carries it, and a child gets it only by
narrowing from a parent that holds it. A per-tree_id budget caps asking —
REMOTE_APPROVAL_BUDGET, operator-set, default three pending and ten an
hour — and no approval raises it. A renewal matched to an open window
asks no one and does not count (step 6). A node without the scope, over its
budget, or muted gets access_denied, and nobody is notified.
Why a mute only denies. In a chat client, “don’t ask again” usually means “allow from now on”. Here it means “stop asking me”. If it meant allow, one tap would become a standing grant for whatever the agent asks next — the approval-fatigue attack of T8. Repeated identical requests are what “approve for a while” is for: it covers the same delta for the window, so the agent has no reason to ask again. Decision 14 records the owner’s framing next to this default.
Authenticators. A passkey is the only accepted factor, in every profile
(decision 15): it is phishing-resistant and bound to QAuth’s origin, so a
relayed link cannot capture it. No TOTP or other offline code is accepted:
a phishing page can relay one — NIST SP 800-63B-4 §3.2.5 says manually
entered OTPs “SHALL NOT be considered phishing-resistant” — and an agent can
type one into a form. A synced passkey is acceptable up to
AAL2 (SP 800-63B-4, Appendix B). No current standard lets the
authenticator itself show the request it signs — WebAuthn Level 1’s
txAuthSimple extension is gone from Levels 2 and 3 — so what the owner
reads is the page QAuth renders, and the binding is QAuth’s own: the
challenge is minted for this request and accepted for nothing else. A
wallet presentation bound to the request through OID4VP transaction_data
(OID4VP 1.0 §8.4; ADR-004) is a
request-bound option behind WALLET_FEDERATION_ENABLED, not in the first
slice. QAuth has no passkey or TOTP support today (verified 2026-09-24), so
a WebAuthn credential provider is a precondition (P5). Amended 2026-10-09
(maintainer, ADR-017 question 10): it is one WebAuthn provider, shared by
this record and ADR-017.
Alternatives considered
| Alternative | Why not |
|---|---|
Adopt draft-liu’s delegation_chain in the token now | Individual -00, June 2026; records of 500–1000 bytes per hop; wit:// identifiers defined only in its own terminology; signed-field set still moving. Ledger plus qauth_delegation carries the same facts; revisit on the §2 trigger. |
| draft-niyikiza offline attenuation (holder-derived child tokens, no AS) | No sub, so no on-behalf-of; no ledger, so no dashboard or cascade; not an access token at any RS. Its par_hash binding is borrowed as ath in the spawn assertion. |
| Transaction Tokens as the tree identifier | Per-invocation, minutes-scale, single trust domain, “MUST NOT be used as an access token” (-11 §13.13). Not a durable root. |
| One DCR client per agent instance | Anonymous DCR clients are unowned (ADR-012), get NULL max_agent_mode and a secret to keep; a client_id names a registration, not an instance. The instance is the key. |
may_act as the allowed-children carrier | RFC 8693 §4.4 may_act is one party, a JSON object; a set of agent types is not expressible. aud ∋ client identifier is an example value §2.1 allows, read as “who may present this at the STS” per the splicing thread, and it is what GATE 3c already checks. |
A ledger aud_ceiling in place of GATE 4b’s subject-aud bound | Moves the on-token invariant aud(c) ⊆ aud(p) into a table; a resource server could no longer verify audience monotonicity from the tokens alone, and the consent screen would lose the resource ceiling it shows today. Enrichment keeps 4b byte-identical. |
| A refresh token per spawned node | Reverses “no refresh token for a delegated token”, replaces the on-token floor exp(c) ≤ exp(p) with a ledger check, and inherits a seven-day default; RFC 9449 §5 says a confidential client’s refresh token is not DPoP-bound, so the node’s key would not even protect it. Renewal is a re-spawn. |
The child’s client assertion as actor_token (draft-mcguinness §6.3.1) | The conformant rebind shape, and a second URN relaxation on GATE 2; it names the child, not the parent’s authorisation. Named as the future path, not adopted. |
The spawn assertion in the actor_token slot | RFC 8693’s actor_token is the acting party’s own identity, and GATE 2 pins it to access tokens. A parent-signed statement about a child is a different object; a new parameter keeps the semantics honest and GATE 2 untouched. |
| mTLS instead of DPoP | A certificate per process needs a CA on the box; DPoP needs a JWK. An OS-level authority manager’s wire is bearer-shaped either way; the JWT stays the credential. |
| A local broker that mints platform credentials itself (for example GitHub installation tokens) | Puts the upstream app identity’s long-lived private key and the vend policy on the developer box, same uid as the model’s shell: the agent could widen its own ceiling by editing a file. The STS keeps both server-side; the broker forwards, caches and deletes. |
| Hooks as the enforcement point | A harness’s pre-tool hook (Claude Code’s PreToolUse, for example) cannot inject or remove a credential and cannot attribute an in-process sub-agent’s CLI call. Hooks stay UX. |
| Short lifetimes only, no ledger cascade | The floor, kept. The user must be able to kill a tree from the dashboard within the window, and a refused refresh is not an audit trail. |
| Macaroons / Biscuits | HMAC chaining gives attenuation without proof of possession; Biscuits need a Datalog engine at every RS; neither is an OAuth token at mcp-guard, a git host or a database (GitHub and PostgreSQL, for example). |
The agent as a users row (a service account) | A user is the on-behalf-of sub; making the agent one would let a tree root in it with no human, which the exchange gate refuses on purpose (§1). A user also holds credentials of its own; an agent holds none (§13). |
The agent as an oauth_clients row | A client_id names a harness type shared across users and boxes (§6, §9); one client per agent recreates the per-instance registration ADR-012 rejected, and developer_id is NULL for every anonymous client. A client is not owned by a user the way an agent is. |
Agent identity in act (act.sub = agent) | draft-mcguinness §3.2 makes act.sub a client identifier and §14.12 wants it durable and never key-derived; the agent is not a client, and the harness type still has to be named. The agent is a ledger column and an introspection member, not an act member (§13). |
A public per-jti or per-commit resolver on QAuth | Puts ledger rows behind a URL anyone can enumerate from public commits; tree_id and jti confer nothing, but the rows are the owner’s (§2, §6). The profile shows the principal and nothing below it; resolving a commit is the owner’s own systems’ job (§9, §13). |
| Model name as a ledger fact | QAuth never sees the model: the harness hooks report it at session start and on a switch, a loopback proxy sees it on the wire, and neither is QAuth’s own observation. A report stays a report (§7, T7). |
| SSF stream management for agent-side transmitters | Receiver-initiated by design (SSF 1.0 §7, §8): the receiver reads the transmitter’s well-known configuration and creates the stream there, which a local daemon behind NAT cannot serve. RFC 8935 with an out-of-band agent_transmitters row now; a transmitter-initiated registration goes to the WG (§7). |
| Commit signing by the broker or by QAuth | A broker-held per-node key verifies against the ledger but a git host that does not know the key shows each agent commit Unverified (for example, GitHub reports unknown_key); a QAuth-side signer with a registered key shows Verified but puts the AS on each commit, against the hot-path rule. Unsigned (decision 12). |
| Widen the live token or tree when the owner approves | Breaks the invariant: every child spawned afterwards inherits the extra right, and the root consent no longer bounds the tree. An elevation is a separate leaf for one node (§14). |
| An approve button in the notification (chat bot, email) | The button is not bound to a passkey on QAuth’s origin: anyone who can read the channel — or the agent, if it can post there — could approve. A notification carries a link only (§14). |
| “Don’t ask again” as an automatic approval | One tap becomes a standing grant for whatever the agent asks next; “approve for a while” already covers repeats of the same delta. A mute only denies (§14, decision 14). |
| A TOTP code as the approval factor | A one-time code can be relayed by a phishing page; a passkey is bound to QAuth’s origin. Not accepted in any profile (decision 15). |
Standards position
The composition caveat is the honest headline: RFC 9449, RFC 9396, RFC 8707
and RFC 9700 never mention token exchange, and RFC 8693 never mentions cnf.
“DPoP-bound exchange”, “RAR narrowing on exchange” and “aud subset across
hops” are QAuth rules anchored on RFC 9449 §5’s “regardless of grant type” and
RFC 9396 §6.1’s “fewer permissions” — composition by analogy, not text.
| Piece | Position | Hook |
|---|---|---|
Hop grammar, act nesting, top-level-only policy | Covered | RFC 8693 §2.1, §4.1 |
| Who may present a token at the STS | Covered by analogy; list-thread guidance | RFC 8693 §2.1 (client identifier as an example audience value), §4.4 may_act; the splicing thread’s aud(N) = sub(N+1); GATE 3c |
Sender constraint, cnf.jkt, DPoP at the RS | Covered by composition | RFC 9449 §4.2, §5 (“regardless of grant type”), §5.2, §6, §7, §7.2; RFC 9700 §2.2.1; RFC 9449 never mentions exchange |
| Audience narrowing per hop | Covered by analogy | RFC 8707 §2.2 “subset thereof” (code and refresh only), §3 multi-audience caveat; RFC 8693 §2.2.2 invalid_target; monotonicity across exchange is GATE 4b |
| Purpose and ceilings | Covered (mechanism); QAuth-defined (type) | RFC 9396 §2, §2.2, §6, §7, §9.1, §9.2, §12 (sanitise; bound by the type); RFC 9728 authorization_details_types_supported; the agent-task type and its subset rule are QAuth’s, and §6.1 says no comparison is standardised |
act.iss, sub_profile, loose schema, preserve/extend | Proposed by draft | draft-mcguinness-oauth-actor-profile-00 §3.2, §3.4, §3.5, §3.6.3.1, §3.6.3.2, §3.7.1, §6.3.1.2, §14.7 (deviates), §14.12 |
| Presenter transition on spawn | QAuth-defined | Neither §3.7.2 continuation (that is the narrow) nor §3.7.3 rebind (no actor_token); §6.3.1 named as the future path |
| Spawn assertion | QAuth-defined | Nearest: RFC 6749 §8.2 extension parameter, RFC 7521 assertion framework, rfc7523bis aud rule, RFC 8725 §3.11 explicit typ, draft-liu §5.2 delegatee_id, RFC 9449 §4.2 ath computation |
| Per-hop lineage | Proposed by draft (not adopted); QAuth ledger | draft-liu-oauth-chain-delegation-00 §4, §10.6 chain by reference |
| Child bound to the exact parent token | Proposed by draft (idea reused) | draft-niyikiza-oauth-attenuating-agent-tokens-01 §4.6 par_hash |
| Tree identifier in access tokens | QAuth-defined claim; not the registered sid | tree_id, a private claim name (RFC 7519 §4.3); not in RFC 9068, not in the introspection registry; the IANA-registered sid (Front-Channel Logout 1.0 §3; Back-Channel Logout 1.0 §2.1) keeps its session meaning on ID and logout tokens (ADR-019 Decision 3), and one name cannot carry both |
| Introspection members | Covered / QAuth-defined | RFC 7662 §2.2; registered act, cnf, authorization_details, jti; extension tree_id, token_use, qauth_delegation (cross-domain use would need Specification Required registration; delegation is OpenID Federation 1.0 §13.6) |
| Revocation cascade | Covered (policy); QAuth-defined (API) | RFC 7009 §2.1 “related tokens and the underlying authorization grant”; revoke-by-tree_id/jti endpoints are QAuth’s |
| Session-revoked signal | Covered | SSF 1.0 §3.3 complex, §3.5 jwt_id, §7, §8.1.1; RFC 8935 push, RFC 8936 poll; CAEP 1.0 §3.1 (Final, 29 August 2025; approval announced 2 September 2025); RFC 9493 opaque, iss_sub |
| Resource-side action events | Covered (envelope); QAuth-defined (event type) | RFC 8417 §2.2 (events, txn, toe), RFC 8935 push; the agent-action URI is QAuth’s |
| Agent-side transmitters and their registration | Covered (envelope); gap (registration); QAuth-defined (members) | RFC 8935 push with out-of-band keys; SSF 1.0 §7–§8 stream management is receiver-initiated and does not fit a NAT’d transmitter; no CAEP or SSF event type describes an agent’s action (CAEP 1.0 §3 defines session, token-claims, credential, assurance-level, device-compliance and risk-level changes — states, never acts); the model and reason members and the server-written agent_id column are QAuth’s; both gaps are what QAuth takes to the Shared Signals WG (§7) |
| Agent identity record | Proposed by draft (shape adopted); QAuth extension (avatar, bindings) | draft-wzdk-scim-agent-resource-00 §3, §4.1, §4.2 (Agent resource, agentUserName, displayName, description, active, owners; no email, no avatar, no binding), RFC 7643 §3.3 extension schemas; the urn:qauth:…:extension:agent:1.0 schema is QAuth’s; draft-ietf-wimse-aims-00 §10.3 keeps client_id = the acting workload, which is why the agent is not in act (§13) |
| Commit provenance | Product convention; kernel process document | git author/committer identities; the platform-assigned attribution address (example, GitHub: App bot login and noreply address); Documentation/process/coding-assistants.rst (Assisted-by: AGENT:MODEL, humans only add Signed-off-by); a git host’s signature verification reasons (example, GitHub: unknown_key) for the signing question (decision 12) |
| Out-of-band approval | Covered (decoupled flow); QAuth-defined (elevation, mute, budget) | OpenID CIBA Core 1.0 §7.1 (one hint of three, binding_message, requested_expiry, profile parameters), §10.1 (poll), §11 (errors); RFC 9396 §3 (authorization_details in CIBA); FAPI-CIBA working copy §4.1.1 (poll, binding_message, confidential clients); AuthZEN AARP Draft 1 §7, §12; OID4VP 1.0 §8.4; WebAuthn Level 3; NIST SP 800-63B-4 §3.2.5 |
| Decision API | Covered; WG-draft binding; QAuth context | AuthZEN 1.0 §6.1, §9.2, §10.1, §11.2 (Final, 11 January 2026); COAZ-MCP Binding §7.1, §11.2 (WG Draft 1); context.qauth is QAuth’s; AARP noted |
| Agent framework vocabulary | WG draft (Informational) | draft-ietf-wimse-aims-00 §8 (LLM never holds credentials), §10.3 (client_id = agent, sub = user), §11 (audit minimums) |
| CLI credentials | Product documentation | git-credential protocol; examples: PostgreSQL 18 oauth HBA and validator API (database adapter), GitHub App installation tokens, gh environment precedence and octo-sts (github adapter) |
| OS-level authority managers | Planning prose in the one worked example; no decision-log id | The manager’s own written window and ceiling rule; the example’s citations are in the Example: vitrin block that closes the composition section (§10); a manager’s normative entry, where owed, may amend the 300 s default |
Watch list
Rows go into docs/spec-pin-log.md with P0, each with
a Re-check by date no later than its expiry — spec-pins.test.ts fails the
build on a past date. A draft in the RFC Editor queue does not expire.
| Document | Revision · date | Expires | Why watched |
|---|---|---|---|
| draft-mcguinness-oauth-actor-profile (individual) | -00 · 30 Apr 2026 | 1 Nov 2026 | act shape adopted; depends on entity-profiles |
| draft-liu-oauth-chain-delegation (individual) | -00 · 6 Jun 2026 | 8 Dec 2026 | delegation_chain trigger |
| draft-niyikiza-oauth-attenuating-agent-tokens (individual) | -01 · 15 Jun 2026 | 17 Dec 2026 | attenuation invariants |
| draft-asor-wimse-agent-delegation-chain (individual) | -01 · 3 Sep 2026 | 7 Mar 2027 | header only; open before citing |
| draft-ietf-wimse-aims (WG, Informational; replaces draft-klrc-aiagent-auth) | -00 · 15 Sep 2026 | 19 Mar 2027 | audit minimums; “LLM MUST NOT hold credentials” |
| draft-ietf-oauth-transaction-tokens (WG) | -11 · 30 Jul 2026 | 31 Jan 2027 | rejected as tree id; txn reused |
| draft-ietf-oauth-identity-assertion-authz-grant (WG) | -04 · 21 May 2026 | 22 Nov 2026 | ADR-011 pin; actor profile layers on it |
| draft-ietf-oauth-client-id-metadata-document (WG) | -02 · 6 Jul 2026 | 7 Jan 2027 | agent client naming |
| draft-ietf-oauth-rfc7523bis (WG) | -11 · 28 Apr 2026 | none: in the RFC Editor queue | aud = issuer only |
| draft-ietf-wimse-workload-creds / -wpt (WG; s2s-protocol is dead) | -02 · 2 Jul / 27 Aug 2026 | 3 Jan / 28 Feb 2027 | one identity per credential |
| draft-oauth-ai-agents-on-behalf-of-user (individual) | -02 · 26 Aug 2025 | expired 27 Feb 2026, no successor | expired; its consent-time disclosure of the acting party is the precedent for §11’s allowlist line; requested_actor itself not adopted |
| AuthZEN COAZ-MCP Binding / AARP (OIDF WG drafts) | Draft 1 · 13 Feb / 17 Sep 2026 (both WG-adopted 15 Jun 2026) | — | request shape; park-and-approve |
| FAPI-CIBA (OIDF, Implementer’s Draft) | working copy · 26 Jun 2026 | — | §14 follows its choices: poll, binding_message, confidential clients |
| MCP Authorization | 2026-07-28 | — | EMA still the only STABLE ext-auth extension |
| draft-wzdk-scim-agent-resource (individual, Informational) | -00 · 5 Jun 2026 | 7 Dec 2026 | Agent resource shape adopted (§13); the SCIM WG is consolidating it with draft-abbey-scim-agent-extension (IETF 125 slides) — re-pin to whichever survives |
| draft-kushwaha-scim-agent-governance (individual) | -00 · Jul 2026 | — | lifecycle and autonomy extension on the same resource; not adopted, watched for the avatar/binding question |
| Shared Signals WG agentic extension (OIDF) | announced Jul 2026, no draft yet | — | where the agent-action event type and a transmitter-initiated registration would be proposed (§7) |
Linux Documentation/process/coding-assistants.rst | merged Apr 2026 | — | Assisted-by semantics reused in §9; a change there changes the convention |
Explicitly out of scope
Machine-to-machine trees with no human root: exchange refuses a
client_credentials subject, and this record keeps it so. Cross-domain trees.
In 1.0, with AUTHORITY_TREE_ENABLED on, an agent type cannot mint an
ID-JAG: GATE 2 refuses the request
(decided 2026-10-09, ADR-015 question 3). Any other ID-JAG or
identity-chaining hop ends the tree at the domain boundary, and
the ID-JAG mint path of ADR-011 stays outside it — an assertion minted from a
tree token carries no act and no tree_id, the ledger records the mint as a
kind: id-jag row that can have no children, and the foreign AS’s tokens are
outside the tree. A restore-token
analogue or any durable rung at an OS-level authority manager: a later ADR
after that manager’s own design for durable rungs settles (the example’s
planning item is in the Example: vitrin block, §10), if ever. A
policy language for the PDP: AuthZEN carries the question, not the policy.
Attestation of the process that holds a key:
draft-ietf-oauth-attestation-based-client-auth is not evaluated here.
The agent’s own systems. Everything an agent principal (§13) does with
its identity outside QAuth is the owner’s domain, not this record’s. It is
the owner’s own systems, out of QAuth’s scope and not specified here:
provisioning the platform side of a binding (creating the upstream app identity on the
platform, such as a GitHub App; uploading its logo; setting an avatar on
another platform); the
runtime, memory and planning layers that produce a reason; the proxy or
harness that reports a model; the index that resolves a commit hash to a
node (QAuth stores the pushed hashes the broker’s git-push SET carries, §9,
and serves them to the owner — it builds no resolver, §13); the wording of a
commit message beyond the lines the broker writes or refuses (§9); commit
signing (decision 12); and any public claim made in the agent’s name that
QAuth did not itself record. QAuth’s rule for all of it is T7: a claim stays
a claim, the ledger row is the fact, and nothing QAuth serves blurs the two.
Phasing
Each phase is one or two mergeable PRs with named tests; no phase loosens a gate.
- P0 — the root is observable, and answers “which agent, in which session,
with which scopes, opened this PR”. 0a, server: the
AUTHORITY_TREE_ENABLEDswitch as the Decision defines it;tree_idon agent tokens (code, refresh, exchange) and therefresh_tokens.tree_idcolumn;agent_token_ledgerand its repository; ledger writes at the three mint sites; the rule for a subject with notree_idand the NULL-tree_idrefresh rule; introspection membersjti,tree_id,token_use,act,qauth_delegation;GET /api/agent-sessionsand/{tree_id}; the STS with its first adapter,github, on an operator-set per-type policy, the type’sregistered_rights(§5); the agent type’s grant rule (§1); spec-pin rows with theirRe-check bydates. In P0 the STS accepts the node’s bearer token (no proof until P1a); the broker’s rootaudis the seededaudienceallowlist — ownclient_id, child types, the STS resource — since the broker sends noresource; a PR opened through a platform’s own MCP server (for example, a GitHub MCP server) is outside this answer until P3. Tests: with the flag off, every token and the exchange are byte-identical to today’s and no ledger row exists; claim presence and inheritance across code → refresh → exchange; one ledger row pertree_id-carrying mint and none forclient_credentials, and a failed write fails the mint; a subject with notree_idstarts a new tree with akind: rootrow; a refresh family with a NULLtree_idgets none at its next refresh, including one whose CIMD client starts declaringis_agentafter consent; the introspection schema; with the switch on, an agent type’s ID-JAG request failsinvalid_requestat GATE 2; the seed manifest refuses an agent type registered with any method butprivate_key_jwt, or with any grant a tree does not use (§1), and the token endpoint refuses each to one; the STS refuses a resource outside policy, against a mock upstream (forgithub, a repository, against a mock GitHub), a token with no ledger row, an emptyactionsset and a vend whose platform read fails. 0b, broker:loginon an ephemeral loopback port with PKCE, git-credentialget, the platform-CLI shim (gh, for the first adapter), the commit trailer, the log. Tests: vend within policy, refusal logged, asetsid/double-forked descendant of a read-only node is refused, not attributed to the session; a process that reuses an exited teammate’s pid is refused; vended credential deleted at node end (forgithub, the installation token); a trailer resolves to a ledger row; a teammate registration naming a lead its caller does not descend from, with no handle, is refused, one presenting the lead’s handle binds under the lead, and a second use of that handle is refused; a session registration from under a bound node starts no root; a resumed session binds under its root only after the person approves the root’s screen again; on a host with one root per broker start, a registration from outside the broker’s process starts a root of its own through its screen; a call on a connection whose peer has exited is refused, whatever process now has its pid; a peer of another uid is refused at the socket and at the loopback listener, and a loopback request whose secret names a node above or beside its caller’s is refused; a child under another uid reaches the broker only through its spawner’s connection. 0c, agent identity: theagentsandagent_bindingstables,agent_idon the authorization request, onrefresh_tokensand on the ledger’s root row (§1, §2), the owner check at authorization,qauth_delegation.agent, the consent line (§11), the public profile, and the broker’s author identity andAgent:/Model:lines from the binding (§9); revocation by agent waits for the §6 walk in P2. Tests: a grant naming an agent thesubdoes not own failsinvalid_request; a grant naming an inactive agent fails the same way; a grant naming none is byte-identical to today’s; every descendant row carries the root’sagent_id; the profile of a private agent is 404 and the profile of a public one carries notree_id,jtior scope; a commit made through the broker under an agent root is authored by the binding’s login and address; a node-suppliedSigned-off-byis refused; a binding whoseadapter_refis assigned to another owner, or to none, fails, and so does a second binding with the sameadapter_reforexternal_id; the STS refuses a vend from the binding of an agent other than the token’s, and one from a binding whoseadapter_refis no longer assigned to the agent’s owner after a reassignment or a transfer. Honest limits: the spawn proof is possession-only until P1; the MCP leg is still the harness’s own non-agent DCR/CIMD client (Claude Code’s, in the example adapter), which never sendsis_agent, so it is outside the tree until P3 (harness adapter contract, example adapter, row 8); same-type children are told apart by ledger rows only; and the platform side of a binding is provisioned by the owner, outside this record. - P1 — keys and the tree. 1a: DPoP at the token endpoint (
cnf.jkt,token_type, nonce) through the shared verifier behindDPOP_ENABLED(§3),dpop_bound_access_tokensin the seed manifest,AGENT_BEARER_LEAF_RESOURCES, key custody in the broker, the introspection membercnf;private_key_jwtat introspection and revocation moved to ADR-017’s F0 (§2). 1b:spawn_assertionand GATE 3d, GATE 4d, narrow versus spawn and node identity, theactshape and loose schema,spawn_allowlistandaudenrichment with the 3d allowlist check,spawn_receipt, the renewal sweep. Tests: a proof under the wrong key fails; acnfsubject exchanged to a new key without an assertion failsinvalid_request; a replayed assertion fails; a root grant carryingagent:exec agent:readonly write:foo read:foospawns a read-only child and the child’s exchange forwrite:foofailsinvalid_scopeat 4a, and at 4d when its type is registered without it; areviewerseeded without an allowlist cannot spawn areviewereven when itsaudnames it; a root type spawning its own type fails when its allowlist omits itself (the example seed’slead, decision 7); a same-key narrow leavesactbyte-identical and depth unchanged; a new key nestsactonce; a leaf cannot spawn. - P2 — purpose, ceiling, revocation. The
agent-taskRAR type end to end with server-writtencaused_byand the introspection memberauthorization_details; the consent ceiling; revocation bytree_idandjti, the identifier API, the cascade and ancestry ownership; theagentAccessTokenLifespanrow; the CAEP transmitter; the STS narrowed byauthorization_details;POST /api/agents/{id}/revokewith its owner-or-admin rule (§13);POST /api/agent-sessions/revoke-all, with sign-out leaving refresh families that carry atree_id, except a CIMD client’s that no longer declaresis_agent(decision 1); GATE 4d’s rights half and the root’s rights bound at/authorize(§5). Tests: widenedlocationsfailsinvalid_authorization_details; a child whose type’sregistered_rightslack a write action failsinvalid_authorization_detailsat 4d although its parent grants it; a root beyond its type’sregistered_rightsor its agent’s root ceiling fails at/authorizeand never reaches the screen; a client-suppliedcaused_byis rejected; revoke-by-agent makes every tree rooted in the agent inactive and leaves the owner’s other agents’ trees alone, and a node calling it is refused; revoke-by-tree_idmakes every descendant inactive at introspection and in the denylist; a spawn or renewal whose parent row is revoked failsinvalid_request; a denylist write failure mid-cascade answers 503 and leaves every marked row inactive at introspection; revoke-by-jtileaves siblings active; a revoked node is not renewed by the next sweep (the re-spawn for its key failsinvalid_request); a denylisted orrevoked_atsubject token cannot spawn; revoking the root’sjtirefuses the next refresh; sign-out ends the refresh family of a CIMD client that no longer declaresis_agentand leaves atree_id-carrying agent family; after revoke-all, a subject token with notree_idissued before it failsinvalid_request; the consent screen shows the union of modes, the allowlist, everylocationsandactionsentry escaped, theaudresources, the refresh family’s expiry, the depth cap and the binding code. - P3 — observation. The RFC 8935 push endpoint and event type, the
agent_actionstable, the operator-setevent_audiencescolumn onoauth_clientsandAGENT_EVENT_WINDOW; mcp-guard normalisesact/tree_id/jti, verifies DPoP per resource, emits events; the broker’s proxy and header-hook leaves (Claude Code’sheadersHelper, in the example adapter) so the MCP leg joins the broker’s root and gains itstree_id; the portal’s live tree; theagent_transmittersrow and the one-hop subject rule for agent-side SETs, themodelandreasonmembers and the server-writtenagent_idcolumn, thesourcecolumn with its three values shown apart, the broker’spre-pushSET (§7, §9), and the SCIMGET /scim/v2/Agentsprojection (§13). Tests: an event byjtilands on the right node; a revokedtree_idreaches the broker by poll, and a refused re-spawn reaches it with polling off; either deletes its vended credentials; a foreign local process — another uid, or a container on host networking — cannot obtain a signed request or a DPoP proof from the proxy; mcp-guard with DPoP on rejects a bound token presented as Bearer (RFC 9449 §7.2), with DPoP off accepts it; an agent-side SET for ajtioutside the transmitter’s agent’s trees is refused; a transmitter-suppliedagentmember is refused and the stored row carries the ledger’s;modelandreasonnever reach introspection or the §8 request; the SCIM projection returns the owner asowners[0]and rejects writes; a broker SET signed with a type key, or with any key but the subject row’sinstance_jkt, is refused; a by-type proxy naming a type outside its session type’s allowlist gets no node. - P4 — the example database adapter, the decision API and an OS-level
authority manager.
qauth_pg_validator, the libpq hook library and thepsqlpath (the PostgreSQL example adapter); AuthZEN evaluation and metadata, default off; COAZ-MCP online mode in mcp-guard; the verifier contribution to an OS-level authority manager once that manager schedules it (for the worked example, the issue named in the Example: vitrin block, §10); re-pin every watch-list row;delegation_chainif its trigger fired. Tests: the validator refuses a token whoseaudis not the database and a role outsideactions; the PDP can deny but never widen. - P5 — remote approval (after P2). A WebAuthn credential provider, the
one shared with ADR-017 —
passkey registration and assertion in the portal; QAuth has none today;
the portal setting that raises an agent’s root ceiling with a passkey
(§13, decision 17); the owner route that uploads an upstream identity’s
key (§13);
the CIBA backchannel endpoint in poll mode, with
login_hint_token= the requesting node’s token;REMOTE_APPROVAL_ENABLED,REMOTE_APPROVAL_EXPIRYandREMOTE_APPROVAL_BUDGET; theagent:requestscope; theagent_approvalstable; thekind: elevationledger row with itsapproval_receipt; the approval page; web push and the owner-registered webhook; mutes. Tests: an elevation carries exactly the approved delta and one audience; akind: elevationsubject cannot spawn or narrow; a request withoutagent:request, or over budget, is refused and notifies no one; a mutedtree_idis refused and notifies no one; a request for a scope outside the type’s registered scopes, or for rights outside itsregistered_rights, is refused and notifies no one (GATE 4d is final); a request whoselogin_hint_tokenhas no ledger row is refused; a renewal matched to an open window resolves with no notification and no passkey, writes a newkind: elevationrow under the same approval and leaves the budget untouched; a renewal before the live leaf’s last 60 seconds failsinvalid_requestand notifies no one; a mutedtree_id’s renewal is refused; an approval without a fresh passkey assertion bound to the request is refused; the page’s headline is the typed delta andpurposerenders only in the attributed box; revoke-by-tree_idends an open window; a window never outlives the refresh family; a notification body carries only the request id, the agent’s handle and the URL; raising a root ceiling without a passkey assertion is refused, and a live tree keeps its ceiling; an uploaded key whose platform answer names no identity, or that opens an identity QAuth’s configuration holds, is refused, and the stored key is never returned by any route.
OS-level authority manager composition
This section states §10’s composition rule for any OS-level authority manager: a local component below QAuth that verifies QAuth tokens and enforces grants, with the token as a ceiling on what may be petitioned. It assumes that the manager’s wire is bearer-shaped and DPoP-unaware, as T3 says of the examples there. It describes no manager as shipped. Each manager’s own documents and decision log govern it. Where a manager’s own normative entry on OAuth or on the ceiling rule is owed, that entry may amend the numbers here, the 300 s default among them. Vitrin is the one worked example; its planning-document identifiers, issue links, paths and scope names are in the closing block of this section, and nothing above that block depends on it.
What QAuth promises. A JWT under 32768 bytes; a single-audience leaf per
manager core or per MCP server fronting it, minted with RFC 8707 resource;
a leaf that carries cnf.jkt — always for a manager core, which is
single-scheme and DPoP-unaware and so is never listed in
AGENT_BEARER_LEAF_RESOURCES; for an MCP server fronting it unless that
server is listed — stable across refresh because the key does not rotate with
the token; RFC 7662 introspection for the verifier to consult
at refresh, off the hot path; the per-resource lifetime cap (300 s by
default, §6) as the written window, with the
fail-open sentence beside it (§6); verb-level scopes only, registered by the
resource (for example <resource>:observe and <resource>:actuate), as
ceilings on what may be petitioned, never as permissions,
no scope implying another verb, and no resource-, realm-, path- or net:
scopes — which layer owns that narrowing is the manager’s open question;
issuer-policy changes (a new iss check, the jti denylist, key
rotation) announced so a long-lived agent sees a legible failure rather than
a bare 401. QAuth is a reference implementation and validation target, never
a dependency.
What QAuth proposes to a manager’s OIDC verifier: that the canonical
principal be (iss, client_id, cnf.jkt) — the agent instance — with
sub carried as the on-behalf-of human and never folded in. A manager that
canonicalises (iss, sub, aud), as the worked example does, would on
every QAuth agent token, where sub is the human, fold every agent of one
person at one core into one principal and one admission cap, which per-agent
isolation forbids. draft-ietf-wimse-aims-00 §10.3 says resource servers MUST use
client_id for the agent and sub for the user; client_id names the agent
type the human sees on the consent card, cnf.jkt keeps one principal per
process, and a refreshed token keeps all three. The verifier ignores act;
the chain collapses to one principal per connection. Mid-connection
re-presentation of a refreshed token is the manager’s own open item.
What QAuth must not claim. That a manager accepts its tokens before that
manager’s own verifier ships; that DPoP is verified at the manager’s
handshake (the wire is bearer-shaped; sender constraint there is the
connection’s principal triple, which QAuth’s binding complements, not
replaces); that revoking a token kills a connection or a grant (revocation
stops future petitions, grants a human approved run to their expiry, and a
delivered file descriptor is kernel authority); that a refresh token is a
restore token or reaches a durable rung of the manager’s grant ladder (QAuth
promises none, and the worked example has none by construction); that a
manager has attenuation, sub-grants, a revoked push or chains deeper than
one, unless that manager’s own documents say it is built; that a manager
relays invalid_token or insufficient_scope to the agent (a manager may
answer every refusal with one uniform failure); that any token claim
pre-approves a petition or skips the consent card; that a token attests a
model, a prompt, an operator or an intent; or that a manager’s own open
question on per-agent tokens is decided.
Example: vitrin
Vitrin is a public open-source OS-level authority manager. It is the one worked example of this section and not the only such manager. Everything specific to it is in this block. The statements below were written against its documents on 2026-09-21 and were not re-checked when the record was restated on 2026-10-07.
- Documents. Cited from the vitrin documents the Related list names
(WS-D §7, the protocol pages and the PRD). No vitrin decision-log entry
covers OAuth, QAuth or the ceiling rule. WS-D §5 says the entry is owed
and, in vitrin’s own words, “needs the owner’s clauses rather than a
derivation”, so this record cites WS-D §7.2 and §7.6 by section and expects
that entry may amend the numbers. The Standards position points here for
its citations: §7.2, §7.3 and §7.5 (workstream prose) and §7.6 (the one
section headed DECIDED in
docs/plan/13-workstream-agent-integration.md). - Scopes. The verb-level scopes are
vitrin:observeandvitrin:actuate. Which layer owns narrowing beyond the verbs is vitrin’s open question, WS-D §7.7(1). - Window. The 300 s default in §6 is vitrin’s written window. A leaf for a vitrin core is listed with a cap of 300 s (§6), so its leaf lives 300 s in every environment. WS-D §7.6 says no document may imply that revocation is faster than the window (T5). WS-D §7.7(2) says QAuth authenticates a workload, not an intelligence (T6).
- Wire. Vitrin’s
hellohandshake is bearer-shaped and DPoP-unaware (T3, §3). The QAuth token is onecredential_typeamong its peers (§10). Itswhile_runninggrant lifetime is the model for the dead-man switch of §6. - Principal. Vitrin’s own pages canonicalise (
iss,sub,aud) and keep onebusyadmission cap per principal. Its per-agent pillar forbids folding the agents of one person into one principal. The OIDC verifier is tracked at https://github.com/vitrin-os/vitrin-os/issues/167 (open). Mid-connection re-presentation for refresh is https://github.com/vitrin-os/vitrin-os/issues/170 (open). - What QAuth must not claim, specifics. Only the static verifier exists
(
crates/vitrin-core/src/identity.rs). A delivered file descriptor is kernel authority, and revocation does not reach it (docs/protocol/13-vitrin_powerbox.md). The durable rungsuntil_revokedandalwaysare unreachable by construction (crates/vitrin-core/src/grants.rs). Attenuation, sub-grants, arevokedpush and chains deeper than one are unbuilt Growth seams (docs/protocol/04-vitrin_grant.md). Every refusal there is one uniformauth_failed. Vitrin’s §6.2 is open: §7.3(1) offers per-agent tokens as the answer and §5 still lists it open. - Out of scope. A restore-token analogue or any durable rung waits for vitrin’s planning item E3.7 (Explicitly out of scope).
Harness adapter contract
A harness is the program an agent runs inside. It owns the session, runs
tools in subprocesses, starts sub-agents and talks to MCP servers. QAuth and
the broker cannot see inside it, so each harness gets a small adapter
that tells the broker what only the harness knows. The contract is the same
for every harness. What a given harness can deliver decides which tree nodes
it can bind. An adapter is advice to the broker, never authority: the broker
binds by SO_PEERCRED and ancestry (§12), and hooks are UX (§9).
An adapter must do four things.
- Register the session. When a main agent starts, send the broker the harness session id and the process id over the socket, from a process in the harness’s own POSIX session (§12). The broker opens a pidfd on the process (§12) and runs the root grant (§1) only when the caller resolves to no bound node; under a bound node the registration is a child of that node, or joins a host’s one root per broker start when its caller resolves to that root’s node (§1, §12). Show the binding code the broker returns in the session (§1).
- Link the parent. For a child that runs as a separate process, send its session id, pid and process start time from the child, or let the parent register it before exec (the spawner path). The broker spawns its node under the bound node the caller resolves to — or, for a child outside its parent’s process tree, under the node that issued the spawn handle the child presents — never under a lead the request names (§4, §12). Where the parent starts such a child, obtain the handle on the parent’s side and pass it to the child’s start.
- Report sub-agent start and stop. For a sub-agent that runs inside the session process, send its instance identifier and type when it starts and when it stops. The report is advice. With reporting off, a by-type node’s key lives until session end, and the node’s dead-man switch is the session process’s pidfd either way (§6).
- Optionally, supply a header hook for MCP. Where the harness can run a command to obtain MCP headers per connection, point it at the broker, so the MCP leg joins the tree (P3). Without it, the MCP leg reaches the tree only through the broker’s proxy (§9), or stays outside it.
An adapter holds no key and no credential. It names no node as an attribution: a handle or label in a log is never a binding. It never chooses a node’s parent or type; the broker does (§12). It is never the only thing that stops an action. A process that no adapter registered reaches no bound node, and the broker refuses it (§12).
The consequence is a rule, not a caveat: a child that needs a hard read-only
gate is spawned as a process under a different uid, or in a sandbox that
denies it ptrace-read of the parent, the parent’s tmux socket and every
user-writable directory on the parent’s PATH and HOME (the spawner’s
job — e.g. bwrap --unshare-user or a DynamicUser= unit). Such a child
reaches the broker only through the connection its spawner holds open for
it, never through the socket’s path, and reads nothing under the parent’s
HOME to bind; its node’s parent and type are the spawner’s node’s choice,
never its own (§12). A process is
necessary for the gate on both legs and, until P3, sufficient on the CLI leg
only (the example adapter’s row 8, P0b’s honest limits). What a same-uid
child can still do to its parent is T2’s residual; a sub-agent cannot be
gated at all (§12).
Example adapter: Claude Code and the Agent SDK
Claude Code does duty 1 and the child’s half of duty 2 from its
SessionStart hook, duty 3 from
SubagentStart and SubagentStop, and duty 4 from headersHelper. From the
Claude Code and Agent SDK documentation (fetched 2026-09-21): every
hook payload carries session_id; SubagentStart/SubagentStop carry
agent_id and agent_type and cannot block; PreToolUse can block and
rewrite input but cannot inject environment; the settings env block reaches
every Bash subprocess; sub-agents run in-process and share the session’s MCP
connections and tokens unless their definition sets its own mcpServers;
teammates are separate processes with their own session_id; MCP tokens are
stored per server, not per session, and headersHelper re-runs on connect and
on 401; the Agent SDK passes mcpServers and headers per query().
| Tree node | Bindable (read 2026-09-21) | How |
|---|---|---|
| The session’s main agent | yes | The broker holds its key and tokens and a pidfd on the session process opened at login, when the main agent’s SessionStart hook registers session_id and pid; a Bash subprocess maps to that session’s root node only when its SO_PEERCRED parent chain reaches that pid. An orphaned descendant (( cmd & ), nohup, setsid, double fork — reparented to pid 1 or a subreaper) reaches no bound node and is refused; a child that must outlive its parent is started through the spawner path below, which passes a handle. |
| Spawner-started process | yes | The spawner is any runtime helper that starts nodes and sandboxes them: it spawns the node and registers the child’s pid with the broker over the socket before exec (a pidfd passed with SCM_RIGHTS, so pid reuse cannot rebind it); the child’s node is a spawn by the node the spawner’s own call resolves to, of a type that node may spawn (§12). For a child under another uid or in a sandbox the broker returns a connected socket, which the spawner leaves open across the exec and which is the child’s only way to the broker (§12). A label in the child’s environment is for logs, never a binding; it is not the single-use environment handle of §12, which only the broker issues. The broker binds by SO_PEERCRED, ancestry, the connection or such a handle, and refuses a label whose node is not on the caller’s ancestry. Full CLI and MCP attribution. |
| Teammate | yes, by ancestry or handle | A separate process; its SessionStart hook registers session_id, pid and process start time with the broker, which opens a pidfd on it (fallback pid + start time). A teammate that descends from the lead’s process is spawned under the lead’s node by ancestry. One that does not (for example, in its own terminal pane) presents a spawn handle that the adapter obtained under the lead and passed to the teammate’s start (§12); where the harness gives the lead no way to pass one, that teammate is refused. The team config under ~/.claude/teams/ names the lead for display only and is never read as authority. The ancestry check maps every call that reaches the broker from that process, or from a descendant whose chain reaches it, to the teammate node — the CLI leg from P0b, the MCP leg once its headersHelper reaches the broker (P3); until then its MCP calls carry Claude Code’s per-server token, the same one the lead presents (row 8). The binding ends when the pidfd signals exit, not when a pid is reused. |
| Agent SDK agent | yes | The application is the runtime: it obtains leaf tokens from the broker per query() and passes them in mcpServers headers; one process per agent is the application’s choice. |
Sub-agent with its own inline mcpServers | by type | The inline server is a stdio entry running the broker’s proxy with the agent type as an argument; the broker attributes the child by SO_PEERCRED and ancestry, and SubagentStart says which instance of that type is live. The node is a spawn by the session node, so its type must be in the session type’s spawn_allowlist; the argument picks among those types and adds none (§12). Two concurrent sub-agents of one type share a node. Its key is created on the first SubagentStart of that type in the session — or, when no hook has announced the type (hooks disabled), on the proxy’s first call under it — and retired on the last SubagentStop of that type or at session end; the start and stop events reach the broker by hooks (advice, not authority), so with hooks off the key lives until session end, and the node’s dead-man switch is the session process’s pidfd either way. MCP only. |
| Sub-agent sharing the session’s connections | no | Same connection, same token: the session node. |
| Any in-process sub-agent’s CLI call | no | The Bash tool runs in the session process; SO_PEERCRED sees the session; the credential vended is the session node’s. PreToolUse can refuse as advice; it cannot make it a different principal. |
| Claude Code’s own per-server OAuth (DCR/CIMD) | outside the tree | A non-agent client under §1: no tree_id, no ledger row, no node. It joins the broker’s root only when the server is reached through the proxy or headersHelper → broker (P3). An operator who seeds it as an agent client and points Claude Code at those pre-configured credentials gets a tree_id and ledger rows — but a root of its own. |
Consequences
Positive
- Provable: every property is one check at mint and holds from the token alone (the invariant paragraph), so a read-only child is read-only by credential before any judgement (T6, §4) and a resource server needs only the token.
- Observable: a node per process, a row per hop, a receipt per spawn, an
event per action (§§2, 4, 7); “which agent opened this PR” is answered in
P0 from the trailer and the two
jti-keyed logs (§9). - Revocable: one identifier cuts a whole tree within a written window, by the user or any live ancestor node, not only the agent (§6).
- The CLI leg gets the MCP leg’s discipline with no new token format; the upstream app identity’s key never leaves the server and the type key on the box is one box’s (§9).
- Attributable: a tree can root in a principal a person owns, so “whose agent” has one answer across sessions, platforms and time; the public profile states it, the consent screen names it, and a commit the agent wrote is authored by it rather than by a shared harness name or the person’s own address (§9, §11, §13).
- Separable: what QAuth verified and what an agent reported about itself are stored, shown and served apart, so a model name or a reason can be logged without ever being mistaken for QAuth’s word (T7, §7).
- Reachable: the owner can lift a ceiling for one step from a phone, with a passkey, without a shell or SSH, and without widening the tree (§14).
Negative
/oauth/tokengains DPoP proof validation, two exchange gates and a ledger write, and introspection and revocation gain a ledger walk: the most branch-heavy route grows again, and with the flag on a ledger outage stops every agent mint (§2).- A local daemon joins the trusted computing base on every agent host, and
QAuth grows an STS that holds the upstream app identities’ private keys (a
GitHub App’s, for the
githubadapter); the on-box type key and root refresh token, and the soft same-uid isolation, are T2’s residuals, and a hard child gate costs a uid or a sandbox per node (harness adapter contract). A child outside its parent’s process tree binds only with a handle its parent passes: a harness that cannot pass one leaves such a child refused, and a handle passed in the environment is readable by a same-uid process until it is used (§12). - Revocation is fail-open for one lifetime (T5, §6); at 300 s that is one
root-down re-spawn per node every five minutes (§4), and a vended
credential’s upstream lifetime (an hour, for
github) shortens only by the broker’s deletion (§9). - One browser login per new session whenever the root names a dangerous
scope, which every root of an
exectype does (decision 7’s example is one); resume reuses the root only when the dead-man walk did not reach QAuth, and then only through the root’s consent screen again; a second process does not (§1, §11). - Every new root shows the consent screen, even when a stored consent
covers it, and
prompt=nonecannot start one (§11). - Three of the shapes followed are individual drafts that may expire without
successors (Watch list); the
actobject may need a second migration. - The MCP leg is outside the tree until P3 (Phasing; harness adapter contract, example adapter, row 8).
- A public profile is a new unauthenticated surface on the realm, and an
owner-registered transmitter is a new party whose SETs QAuth stores; both
are bounded (the profile shows the principal and nothing below it, the
transmitter reaches one agent’s trees and its rows are marked as reports),
but each is one more thing to enumerate and one more
jwksto rotate (§7, §13). - Every provenance line outside QAuth is forgeable, by anyone and by the agent itself; the record can only make its own store honest about the difference (T7). Commit signing, the one thing that would change that, is not done: agent commits stay unsigned until provenance must become evidence (decision 12).
- QAuth grows a CIBA endpoint, a WebAuthn credential provider, a notification path and an approvals table (§14). The owner’s phone joins the approval path: a lost, unlocked phone with a synced passkey is a way in, bounded by the delta, the budget and the window — and a human who approves carelessly is T8’s residual.
Neutral
- Default off in every dimension:
AUTHORITY_TREE_ENABLED=falseruns none of it (Decision), and with it on, no agent client is DPoP-required, nospawn_allowlistexists and so no root token’saudis enriched, no lifetime row changes, no transmitter or push endpoint is configured, until an operator says so — and no agent, binding or agent-side transmitter exists until an owner creates one (§7, §13). No approval request is possible until an operator setsREMOTE_APPROVAL_ENABLEDand an owner registers a passkey (§14). MAX_DELEGATION_DEPTHstays 4; draft-mcguinness’s “at least depth 4” is met and draft-liu’s recommended 5 is not adopted. A narrowing spends no depth, so lead → teammate → process → tool with a leaf per level fits.- ADR-007’s maintainer decision stands (the invariant paragraph); nothing on
the wire is proprietary to a standard client;
WalletProvider.verify()is untouched and still throws.
Decisions parked for the maintainer
Each question carries the default the record was written on; the record proceeds on that default until the maintainer decides otherwise. As of 2026-10-09 the maintainer has decided every question below.
- Browser logout and authority trees. Decided 2026-09-30 (maintainer).
Should a QAuth logout revoke the user’s authority trees (
tree_id)? RFC 9700 §4.14.2 leaves automatic refresh-token revocation on logout a MAY, and says nothing about the access tokens below it. The maintainer’s words: there will be a separate method that revokes all agents; agents survive the browser logout. Decision: trees survive sign-out. TodayPOST /auth/logoutrevokes every refresh token of the user (revokeAllForUser,apps/auth-server/src/app/routes/auth/logout.ts:79). WithAUTHORITY_TREE_ENABLEDon, sign-out no longer ends a refresh family that carries atree_id; with the flag off nothing changes. One exception was decided on 2026-10-09 (ADR-015 question 1). Sign-out ends atree_idfamily whose CIMD client has stopped declaringis_agent, and so its tree. A CIMD client’sis_agentfollows its document at every re-resolution (apps/auth-server/src/app/helpers/client-resolution.ts:134). The separate method isPOST /api/agent-sessions/revoke-all(§6). By the §6 walk, it ends every live tree the user rooted. It has no step-up; in his words, an emergency stop must not wait. purposeandtaskprovenance. Model-authored free text, shown and never a policy input, ortaskrestricted to operator- or spawner-issued identifiers? Default: free text;caused_byserver-written. Decided 2026-10-09 (maintainer):taskstays free text, a label shown to people and never a policy input. Version 1 of the type says so (§5).- Whose identity an adapter’s credential carries (example, GitHub). Credentials that carry the upstream app identity (GitHub: App installation tokens, attributed to the App’s bot) or credentials that act as the user (GitHub: user-to-server tokens, the user’s avatar with an app badge)? Default: credentials that carry the app identity; the trailer carries the agent. Amended 2026-09-22: where the tree roots in an agent principal, the upstream app identity is the agent’s binding and its bot identity is the author (§9, §13), which settles this on app-identity credentials for that case; the user-acting option remains open only for trees with no agent named. Decided 2026-10-09 (maintainer): adapter credentials carry the app identity for every tree. A user-acting token exists only on ADR-016’s pass-through leg.
- Which upstream app identity the adapter mints from (example, GitHub: which App and installation the STS mints from) — one app identity per organisation or one shared. Default: one per organisation, with installation ids in QAuth configuration. Amended 2026-09-22: one app identity per agent principal where one is named (§13), its slug being the agent’s public name on the platform (GitHub: the App’s slug); the per-organisation default holds for trees with no agent named. For GitHub, installation ids stay in QAuth configuration either way. Decided 2026-10-09 (maintainer): one upstream app identity per organisation, never one shared, so no key is shared. An agent’s own identity (§13) stands where an agent is named. For that identity the 2026-09-30 decision replaced configured installation ids: the allowlist is by account, and foreign installations are removed (§9, §13; ADR-016 §2).
- Where
qauth-brokerlives — a TypeScript app in the Nx monorepo or a separate project beside an OS-level authority manager’s own (a Rust crate, for example); the PostgreSQL validator is C either way. Default:apps/qauth-brokerin the monorepo. Decided 2026-10-09 (maintainer): it lives in the QAuth monorepo. Its language is chosen separately. Its stable surface is its language-neutral protocol. - Same-type teammates as one registered type (told apart by
cnf.jktandparent_jti) or a distinct type per role for a separatemax_agent_modecap? Default: one type. Decided 2026-10-09 (maintainer): teammates of one role share one registered type. An operator may split them into separate types. - First seed manifest — agent types with
max_agent_mode,scopes,spawn_allowlist,jwksand, for a root type,redirect_uris. The default root type is operator-defined; the entries below are an example manifest, not a product list. Default: an example root type namedclaude-code(root,exec, allowlist naming itself andreviewer, oneredirect_urisentry, the portless loopback URIhttp://127.0.0.1/callback, §1),reviewer(readonly, nowrite:*),worker(exec); onejwkskey per box, distinctkid. Decided 2026-10-09 (maintainer): the example root type is renamed to the neutrallead.claude-codeappears only as a labelled example in docs.reviewerandworkerstay. Amended 2026-10-10: each type also carriesregistered_rights(§5) and only the grants a tree uses (§1); the decision is unchanged. - Root-grant cadence. One root per main-agent process (a login per
session, per-session attribution) or one root per broker start (one login,
concurrent sessions share a
tree_idand a node)? Default: per process. Note 2026-09-22: an unattended runtime — a daemon with no session-registration hook (§1; for example, Claude Code’s isSessionStart), restarted independently of the broker, started through the spawner path — cannot carry a per-process root, since its every restart would cut thetree_id(§6); it needs the per-broker-start shape. A per-host setting choosing between the two, rather than one answer for every host, is the likely resolution. Decided 2026-10-09 (maintainer): a per-host setting, default one root per process. A daemon host may choose one root per broker start (§1). Amended 2026-10-10: on such a host the shared root’s node is the broker’s own process, and a session registration joins it only when its caller resolves to that node; one from a process under no node starts a root of its own through its consent screen (§1, §12). The decision is unchanged. - Agent handle namespace. Realm-unique (
build-botis one agent per realm) or user-scoped (alice/build-bot, so two owners may share a handle)? The SCIM draft wantsagentUserNameunique across the provisioning domain, which is the realm. Default: realm-unique, first come; the profile URL is/agents/{handle}. Decided 2026-10-09 (maintainer): unique within the realm. - Ownership transfer. Decided 2026-09-30 (maintainer). May an owner
hand an agent to another user, and does the agent’s history (ledger
rows, bindings, events) move with it? The maintainer’s words: activity
before the transfer was done under the previous owner; that history is
not rewritten or removed; from the transfer it continues under the new
owner’s name. Decision: the current owner may transfer through the
portal. Every ledger row keeps the
user_idof its time, and everyagent_actionsrow keeps it through its subject’s ledger row. History renders from those rows; nothing is rewritten. The transfer revokes the agent’s live trees by the §6 walk, which follows from his words: a live tree runs under the old owner’s consent. So from the transfer the agent acts only under trees the new owner roots. The old owner’s name leaves only the public profile. - Agent-side transmitter trust. Does an owner-registered transmitter (§7) need the realm admin’s approval before its SETs are stored, or is the owner’s registration enough? Default: the owner’s registration is enough, because the rows it produces are already marked as reports and reach only that agent’s trees; an admin may disable a transmitter. Decided 2026-10-09 (maintainer): the owner’s registration is enough. A realm admin may disable a transmitter, through the permission catalog of ADR-019 (ADR-015 question 10).
- Commit signing. Leave agent commits unsigned (pointer only), sign
with the broker’s per-node key (verifiable against the ledger, shown
Unverified by a git host that does not know the key; for example, GitHub
reports
unknown_key), or sign server-side with a key registered to a machine user (shown Verified, the AS on every commit)? Default: unsigned; the trigger to reopen is a requirement that provenance be evidence rather than a pointer. Still parked 2026-09-30. The maintainer asked whether signing is meant to stop a malicious force push under the agent’s identity. Branch protection and rulesets prevent that; the ledger detects it after the fact (§9); a signature lets a third party verify authorship offline. Decided 2026-10-09 (maintainer): agent commits stay unsigned. The question reopens if provenance must become evidence (ADR-016 §6). modelon the ledger row? Keep the reported model only inagent_actions(§7), or copy the first report onto the ledger’s root row for the dashboard’s convenience? Default:agent_actionsonly; the ledger holds what QAuth verified and nothing it did not. Decided 2026-10-09 (maintainer): the reported model stays only inagent_actions.- “Don’t ask again”: a mute or an allow? The owner’s framing (2026-09-23): approval fatigue is prevented by “don’t ask again for this session” and “don’t ask for a while”, and asking has its own permission scope, which cannot be extended. Decided 2026-09-30 (maintainer), five of six answers. The maintainer’s words: it can work both ways — always allow, allow for a while, allow for this session, always block, mute and block for a while, mute and block for this session. Decision: “allow for a while” and “allow for this session” are the approval windows (§14, step 4). The two mutes stay as written. “Always block” is a standing deny the owner sets; it lasts across sessions and is removed in the portal (§14, step 4). “Always allow” is not decided: it conflicts with §14’s rule that an approval never reaches a durable rung, so it was parked as decision 17, which the maintainer decided on 2026-10-06.
- Which factors may approve? A passkey only, or also a TOTP or other
offline code, or a wallet presentation bound by
transaction_data? Decided 2026-09-30 (maintainer). The maintainer’s words: passkey first, the wallet later; TOTP is dangerous, because agents can fill forms where vitrin is not used. Decision: a passkey only, in every profile,developmentincluded; a wallet presentation later, behindWALLET_FEDERATION_ENABLED, as a request-bound option (§14); no TOTP. - Where a request reaches the owner. Web push to the portal, email, an
owner-registered webhook, or all three — and what happens while the
owner does not want to be reached? Default: web push, plus an optional
owner-registered webhook that receives only the request id, the agent’s
handle and the approval URL; no email. A request nobody answers expires
at
requested_expiryand counts as denied; quiet hours are the owner’s channel’s business, not QAuth’s (§14). Decided 2026-10-09 (maintainer): web push plus an optional webhook that carries only the request id, the handle and the URL. No email. An unanswered request expires as denied. - “Always allow”. The maintainer named it among the lasting answers
(decision 14). As an approval it would be a standing grant, which breaks
§14’s rule that an approval never reaches a durable rung. Default: not
offered. The alternative: the owner raises the agent’s default root
ceiling in the portal, and the next root takes it through consent (§11).
Decided 2026-10-06 (maintainer): the alternative. An approval prompt
has no “always allow”. The owner raises the agent’s default root ceiling
in the portal, proving it is the owner with a passkey. The body carries
it since 2026-10-09: the
root_ceilingfield (§13), the consent screen (§11), §14’s step 4 and P5.
Related
- ADR-006 — the
audandoauth_clients.audiencedecision the root-token enrichment sits beside; ADR-007 — the agent-native layer this record extends, the independent-scope-modes decision, epic #181 and issues #184 and #186 as named in the code; ADR-008 — the profile resolver the lifetime row joins; ADR-011 — the exchange gate discipline, gate 15,private_key_jwt, the operator-config trust posture; ADR-012 — why per-instance DCR clients were rejected, and the ownership check §13 reuses; ADR-013 — the burn-then-bind idiom the spawn assertion’s single-usejtifollows; ADR-002 — email as credential, not identity, which is why an agent has neither (§13); ADR-015 — the tree’s fail-closed rules and the questions the 2026-09-30 amendment left out; ADR-016 — the platform side of an agent’s identity: a public platform identity, approval-only resources, the pass-through leg andhandle@issuer - Agent Authorization guide —
the shipped agent layer this record builds on;
docs/spec-pin-log.md— where the watch list will be pinned at P0 - Example, vitrin (an OS-level authority manager; §10):
docs/plan/13-workstream-agent-integration.md(WS-D §7),docs/protocol/01-vitrin_handshake.md,docs/protocol/02-vitrin_principal.md,docs/protocol/04-vitrin_grant.md,docs/protocol/13-vitrin_powerbox.md,docs/protocol/19-vitrin_egress.md,docs/PRD.md; https://github.com/vitrin-os/vitrin-os/issues/167 · https://github.com/vitrin-os/vitrin-os/issues/170 - RFC 6749 · RFC 8693 · RFC 9449 · RFC 9396 · RFC 8707 · RFC 7662 · RFC 7009 · RFC 8417 · RFC 8935 · RFC 8936 · RFC 9493 · RFC 7521 · RFC 7523 · RFC 8252 · RFC 8414 · RFC 8725 · RFC 9068 · RFC 9728 · RFC 9700
- OpenID Federation 1.0 §13.6 · OIDC Back-Channel Logout 1.0 §2.1
- draft-mcguinness-oauth-actor-profile-00 · draft-liu-oauth-chain-delegation-00 · draft-niyikiza-oauth-attenuating-agent-tokens-01 · draft-ietf-wimse-aims-00 · draft-ietf-oauth-transaction-tokens-11 · draft-ietf-oauth-rfc7523bis · draft-oauth-ai-agents-on-behalf-of-user-02 · draft-asor-wimse-agent-delegation-chain-01 · draft-ietf-oauth-client-id-metadata-document · draft-ietf-oauth-identity-assertion-authz-grant · draft-ietf-wimse-workload-creds · draft-ietf-wimse-wpt · draft-klrc-aiagent-auth · draft-ietf-oauth-attestation-based-client-auth
- OAuth WG thread: Delegation Chain Splicing in RFC 8693 Token Exchange
- OpenID AuthZEN Authorization API 1.0 · COAZ-MCP Binding 1.0 (WG draft) · Access Request and Approval Profile 1.0 (WG draft) · Shared Signals Framework 1.0 · CAEP 1.0 · OIDC Front-Channel Logout 1.0 §3 (
sid) · IANA JWT Claims registry - MCP Authorization 2026-07-28 · gitcredentials · Example database adapter, PostgreSQL: PostgreSQL 18 OAuth authentication · Example credential adapter, GitHub: GitHub App installation access tokens · octo-sts
- Example harness adapter, Claude Code: Claude Code hooks · sub-agents · agent teams · MCP · Agent SDK MCP
- draft-wzdk-scim-agent-resource-00 · draft-abbey-scim-agent-extension · draft-kushwaha-scim-agent-governance · SCIM WG agentic-draft progress, IETF 125 · RFC 7643 · RFC 7644
- CAEP Interoperability Profile 1.0 (draft 01) · OpenID Foundation on SSF/CAEP and agentic use cases (July 2026)
- OpenID CIBA Core 1.0 · OpenID for Verifiable Presentations 1.0 · WebAuthn Level 3 · NIST SP 800-63B-4 · Example remote owner client: Claude Code Remote Control
- Linux kernel: AI Coding Assistants · Example, GitHub: GitHub App visibility · GitHub commit signature verification reasons · GitHub REST: pull requests associated with a commit