ADR 0019: An escalation names the authority it needs

  • Status: Accepted
  • Date: 2026-08-31
  • Extends: docs/adr/0011-escalation-terminal-outcome.md

Context

ADR 0011 made escalation a third terminal state and settled how one is resolved: issuing a grant to the trusted store is host-owned control-plane work, and the next turn loads it. ADR 0017 published the affordance as a catalogued tool and gave AgentTurnDecision the variant that ends a turn on it. ADR 0018 recovered the same ending from an MCP call. The mechanism is complete on the model's side.

Two things are missing on the other side, and they are the same omission seen twice.

Only a model can start one. A driver recognises sharedos.escalate by name and ends the turn. Nothing else can. But the component that actually establishes that authority was missing is CapabilityAuthorizer, and it has no way to say so beyond allowed: false. A host whose product policy is "this class of denial is worth asking a human about" has to detect that outside SharedOS, from a denial that looks like every other denial.

A recorded escalation cannot say what it needs. recordEscalation(context, reason) takes a free-text reason, and Escalation carries reason, reviewer, requestedAt, and a status that is always pending. So the host that ADR 0011 hands resolution to receives prose. To issue the grant that resolves it, a reviewer — or a control plane acting for one — has to reconstruct the resource, action, owner, and purpose from a sentence a model wrote.

The cost of both is observable in a real host. Aicoo runs two human-in-the-loop paths in production, one for cross-agent tool approval and one for its escalation protocol, and both live entirely outside SharedOS with their own storage, their own expiry, and their own reason codes. Neither could be expressed here: the first is a system-initiated ask, and the second needs the approval to name a capability so it can be turned into a grant. Each host that hits this will grow the same pair of parallel mechanisms, which is the outcome ADR 0011 was written to prevent for the terminal state and did not cover for the payload.

CapabilityRequest already exists for exactly this. docs/security/permission-model.md says it "expresses requested authority for a consent workflow. It is not usable authority until an eligible issuer turns it into a trusted grant", and the type's own comment records that no port accepts one yet.

Decision

Escalation stays one mechanism with one terminal state. It gains a payload, and the authorizer gains a way to describe what was missing. Neither adds a control path.

A denial may describe the authority that would have satisfied it

AuthorizationDecision gains an optional requiredAuthority: CapabilityRequest, populated only on a no_matching_grant denial, where the authorizer already holds every field it needs: the request's resource and action, and the context's owner, namespace, purpose, and instant.

On invocation, not on discovery. authorize describes; canDiscover does not. The requirement a discovery check is made against is the tool's declared ceiling, which ADR 0012 allows to be broader than the argument-selected resource of any particular call — so a description built there would ask a reviewer to issue more authority than the operation needed, and would produce one request per hidden tool on every catalogue build. The denial worth escalating is the one where an agent tried to do something.

It is a description, not an offer. It grants nothing, it is not accepted as input anywhere, and the denial is still a denial — allowed stays false and fail-closed behaviour is untouched. A host that ignores the field behaves exactly as it does today.

It discloses nothing the caller did not already hold. The description echoes the resource and action the caller just named, together with the owner, namespace and purpose that were already in its own access context. In particular it does not reveal whether the path exists: the same description is produced for a path that is absent and for one the actor merely cannot reach, because the authorizer never consults a provider to build it. That property is stated here so it is not later "improved" into an existence oracle by populating the field from anything a provider knows.

It is deliberately absent from the other denials. grant_exhausted names a grant that exists, and the infrastructure denials name a fact SharedOS could not establish; describing a capability for either would suggest that issuing one is the remedy when it is not.

An escalation may carry it

Escalation gains an optional requestedAuthority: CapabilityRequest, and recordEscalation accepts one alongside the reason. The two names are one concept in two roles, and both end in the noun this repository uses for what grants confer: a denial says what was required, an escalation requests it, and { requestedAuthority: denial.requiredAuthority } is the whole hop. The escalation.requested audit event records it.

CapabilityRequest carries a required id, requester and requestedAt, and the kernel mints all three — a host cannot supply them, because a request the caller authored would be a caller-chosen correlation for a decision the kernel made. They come from the trusted context and nothing else: requester is context.actor, owner is context.owner, and requestedAt is context.now.

id is derived deterministically from those fields together with the resource and action — and any constraints — not generated randomly. A random UUID would make a conformance cell that cannot state what it observed: the row would have to either ignore the identifier or re-derive it, and a manifest that ignores a field is a manifest that does not check it. requestedAt is not part of it, so one missing authority keeps one identifier across turns.

recordEscalation mints the same way. What a caller passes as requestedAuthority is read as the ask — capabilities, purpose, constraints, metadata — and the identity fields it carries are discarded and minted again from the context, which is why the hop above round-trips: the denial's description and the escalation's request are the same ask, and so the same identifier. mintCapabilityRequest is exported for a host assembling a consent request of its own.

The reviewer is still assumed, the status is still always pending, and nothing inside SharedOS advances it. Resolution is unchanged and still host-owned: the host turns the request into a grant, and the next turn loads it.

One mechanism, two triggers

A model that chooses sharedos.escalate and a host that escalates a denial its policy marks as askable produce the same Escalation, in the same terminal state, resolved the same way. The kernel never decides to escalate on its own — it describes, and the host chooses — so there is exactly one place a turn can end this way and exactly one queue a reviewer reads.

The protocol version does not move, and that is a decision

Both fields are optional and both are additive for a writer. Neither is additive for a reader. The contract schemas are .strict(), so a consumer built against the current version rejects an object carrying an unknown key rather than ignoring it: an older client parsing a newer host's ExecutionResult fails on escalation.requestedAuthority, and one parsing an AuthorizationDecision fails on requiredAuthority. "Additive" is a statement about writers that this repository's own strictness makes false for everyone else.

The first draft of this ADR concluded that the version therefore moves with the fields. It does not, and the reason is what ProtocolVersionSchema actually is: one z.literal("1") shared by ExecutionRequest, ExecutionEvent, ExecutionResult, MessageEnvelope, and RuntimeManifest. Moving it re-stamps every protocol object in the system — including the four that did not change — to signal one optional field on Escalation. A reader re-pinning because MessageEnvelope says "2" would find nothing about a message had changed, which is a version number that has stopped carrying information.

So the break is documented rather than encoded, for this release. 0.x is where that is affordable: the changelog names it under breaking changes, and the version moves at the next release that has its own reason to move, carrying this with it. docs/open-items.md holds the row until then.

The failure mode being accepted is explicit: a host on a newer SharedOS talking to a client on an older one gets a strict-schema rejection reported as a malformed response, with no version difference to explain it. That is worse diagnostics than a version bump would give and better than a version number that means nothing.

Consequences

  • The audit record for an escalation becomes machine-readable. A control plane can render "@alice's agent needs files/Memory/project-x: read" and issue the grant on one action, instead of parsing a sentence.
  • Escalations remain excluded from denial rates, exactly as ADR 0011 requires. Nothing here makes an escalation a denial or a denial an escalation.
  • The authorizer does no extra work on the allow path, and none on any denial other than no_matching_grant.
  • A host that already had a consent workflow can retire its parallel record. The workflow stays host-owned; what stops being host-owned is the vocabulary for saying which capability it is about.
  • Conformance gains a row: an escalation recorded after a no_matching_grant denial names the capability that denial described. It lands with the implementation — ADR 0013's gate covers every declared row, so a row added ahead of the code would have to be declared notImplemented with a reason.
  • Every consumer of ExecutionResult and AuthorizationDecision upgrades in step with any host that writes these fields. Nothing in the protocol version says so, by the decision above, so the changelog and this ADR are the only notice a reader gets.
  • CapabilityRequest stops being a type with no port. It is still not authority and still not accepted as input. Its row in docs/open-items.md — "define a port or delete it" — is closed by the implementing PR, not by this ADR: an open item is closed by the code that closes it, not by a decision to write that code.

Open

How many capabilities may one escalation name? CapabilityRequest.capabilities is an array bounded at 64, the same bound the contract uses for every other list, so the number is a payload guard rather than a statement about review. The kernel-produced path emits exactly one, because one denial describes one missing capability; a host authoring a request deliberately could name several. Whether an escalation is answered as a whole — and therefore whether more than one may be named at all — is undecided and does not block this ADR.

Rejected alternatives

A third decision value, allowed | denied | confirm. Rejected. It makes escalation a decision, which is precisely what ADR 0011 refused: "a denial is a decision SharedOS made, and an escalation is a decision it declined to make." It also gives a turn a second way to terminate, one the driver does not own, so AgentTurnDecision and the authorizer would each be able to end a turn on the same event and the record would have to say which did.

A ConsentPort the kernel calls to ask a human. Rejected as the same mistake at larger scale. ADR 0011 states there is "no queue, no approval token, and no resumption, and the omission is the design"; a port whose whole purpose is to wait for an answer reintroduces all three. Worse, an answer arriving into a running turn is a second way for authority to enter it, which ADR 0009 closed. Asking is host work. SharedOS's part is to record what was asked.

Put the capability in Escalation.reason as structured text. Rejected: it is a schema behind a string, unvalidated, and it would make the 512-character bound on reason a limit on how many capabilities an escalation may name.

Leave it as prose and let hosts pattern-match denials. Rejected. That is the status quo, and the status quo is two hosts' worth of parallel approval machinery that SharedOS cannot see, cannot audit, and cannot test.