Reason and error codes

Every refusal in SharedOS is a code, not a thrown exception. This page is what each one means and what to do about it.

Denied is not failed

Three statuses appear across ToolResult, ResourceResult, MessageDeliveryResult, and ExecutionResult:

StatusMeaningRetry?
succeededIt happened
deniedAuthorization refused it. Nothing ranNo — change the grant
failedIt was allowed, and something broke while doing itMaybe — check retryable

ExecutionResult adds cancelled for a deadline or host cancellation, and escalated for a turn that stopped and asked for a human. An escalated result carries an escalation, not an error: a denial is a decision SharedOS made, and an escalation is one it declined to make. Counting them together inflates every denial rate by the cases where the system correctly asked.

Over HTTP all four are 200. A 403 means the request never reached the kernel's decision. Client code that only checks the HTTP status will read denials as successes.

Authorization reason codes

AuthorizationDecision.reasonCode, and the reason field on authorization.checked audit events.

CodeMeansFix
allowedA grant matched
no_matching_grantNothing the GrantSource returned covers this resource and actionSee the checklist below
grant_exhaustedA matching grant exists but its maxUses is spentIssue a new grant; usage is not resettable
host_policy_deniedA grant matched and the host ceiling overrode itProduct or organization policy, not authority
invalid_contextThe AccessContext failed its schemaA host bug. Build the context server-side
invalid_requestThe resource or action failed its schema, or names another worldCheck path segments, action naming, and the owner
authority_unavailableThe GrantSource threw, or answered with unusable materialFail-closed. See the authority table below
usage_store_unavailableThe grant has maxUses and there is no usageStore, or it threwSupply CapabilityAuthorizer({ usageStore })
delegation_chain_unverifiedThe chain could not be established at allSupply CapabilityAuthorizer({ delegationResolver })
delegation_chain_invalidThe chain resolved and broke a rule — often a revoked ancestorUsually working as intended — upstream authority ended
host_policy_unavailableThe host ceiling threw, answered with a decision it was not shown, or the turn's PolicySource failedFail-closed. A ceiling may only narrow

Four of these are SharedOS failing to establish a fact rather than a policy decision: authority_unavailable, usage_store_unavailable, delegation_chain_unverified, and host_policy_unavailable. They are named once, in INFRASTRUCTURE_DENIAL_REASONS, and their audit records carry failClosed: true. Exclude them before computing any denial rate. delegation_chain_invalid is not among them: a chain that resolved and broke a rule is a real answer about authority, not a failure to get one.

host_policy_denied is the opposite case and is kept apart from no_matching_grant for the reason the separation exists at all: "nobody authorized this" and "a grant authorized this and our own policy overrode it" are different facts about a deployment, and a host that expressed the second by withholding the grant made the kernel assert the first. It is not marked failClosed — a deliberate refusal is not an outage — and it carries the grantId it overrode, so the two are countable separately (ADR 0020).

Two of them are usually not faults at all but omissions, and say so: usage_store_unavailable and delegation_chain_unverified add missingDependency: "usageStore" | "delegationResolver" to the audit record when the authorizer was built without the port the grant needed. A maxUses grant with no usageStore, or a derived grant with no delegationResolver, denies every time and looks exactly like a permission problem. It is a wiring problem; see host integration.

authority_unavailable collapses four situations on purpose, so that no caller can tell a broken store from a rejected one:

SituationInternal code
the source threwgrant_source_failed
a grant does not satisfy CapabilityGrantSchemainvalid_grant_material
a grant is outside the context's namespace/actor/issuergrant_scope_mismatch
more grants than MAX_RESOLVED_GRANTSgrant_limit_exceeded

A source that answers with a superset fails closed rather than being quietly filtered: pre-filtering to (namespace, actor, authority) is part of the contract. Which of the three the grant broke, and which grant it was, is on the authority.resolved audit event as rejectedGrants — the caller still sees one code.

When you get no_matching_grant and expected otherwise

Walk these in order. Every one of them produces the identical code.

  1. context.authority does not equal grant.issuer. The most common cause. authority is whose grants are being exercised, not who owns the data. For a grant Alice issued it is Alice; for a grant Bob derived from it, it is Bob.
  2. context.actor does not equal grant.subject. The grant was issued to someone else.
  3. context.purpose is not in constraints.purposes. Purpose is matched exactly, not by prefix.
  4. context.now is outside notBefore / expiresAt.
  5. namespaceId differs. Grants never cross worlds or tenants.
  6. The path is not covered. scope: "exact" matches only that path. scope: "descendants" matches the path and below — and segments are compared as segments, so cell-3 never covers cell-30.
  7. The action is not listed. Actions are matched literally, with one exception: a grant whose actions contains the literal "*" covers every action on its resource. Nothing else expands — snapshot:* is an ordinary string that matches nothing — and "*" in a request matches only a grant that lists it.
  8. A grantVerifier returned false or threw. A throw is treated as false.
  9. The capability is spread across grants. One requirement must be satisfied by one grant. Path from one and action from another is refused deliberately.

You do not have to walk the list by hand. The reason code is the same for all nine because a caller may not learn which one it was; the host may. Every denial records a rejectedGrants array on its authorization.checked audit event, naming each resolved grant and the first condition it failed:

authorization.checked  denied  files/Work/Finance  no_matching_grant
  grantsResolved: 2
  rejectedGrants: [ { grantId: "grant-17", reason: "issuer" },
                    { grantId: "grant-19", reason: "capability" } ]

reason is one of issuer, subject, namespace, window, purpose, verifier, capability, delegation, or exhausted. grantsResolved: 0 with no rejections is a different fault from every grant being rejected: the store returned nothing for this context at all.

Three of the nine — namespace, subject, and issuer — are checked earlier, when authority is resolved, and refuse the whole set rather than one grant. Those appear on the authority.resolved event instead, under the same key, beside authority: "grant_scope_mismatch".

The denial says which capability it wanted

A no_matching_grant decision carries requiredAuthority: a CapabilityRequest naming the exact resource and action that would have satisfied it, with the requester, owner, namespace, and purpose from the context that asked. It is what a consent workflow needs in order to issue a grant on one action rather than parse a sentence, and SharedOSKernel.recordEscalation accepts it so an escalation carries it to whoever resolves it.

It is a description and nothing else. It grants nothing, no port accepts one as input, allowed stays false, and a host that ignores it sees no change. Its id is derived from the fields rather than random, so the same missing authority has the same identifier every time it is described.

Three things it is deliberately not:

  • Not on any other denial. grant_exhausted names a grant that exists, host_policy_denied names one that exists and was overridden, and the infrastructure denials name a fact SharedOS could not establish. Issuing a grant is not the remedy for any of them.
  • Not on discovery. canDiscover is asked about a tool's declared capability, which may be a broader ceiling than any call. A description there would name more authority than an operation needed.
  • Not an existence oracle. It restates the request and the caller's own context. It does not say whether the path exists, whether a grant for it exists, or who holds one.

tool_unavailable covers three different situations

kernel.invokeTool returns denied with tool_unavailable — and the same message — when the tool is not registered for this context, when its namespace is disabled, and when no grant makes it discoverable. That is deliberate: the caller learns it cannot use the tool, not which of the three reasons applies.

The specific reason is in the audit trail, on the tool.invoked event itself, as metadata.cause:

tool.invoked  denied  files.read  <- tool_unavailable, cause: namespace_disabled

cause is one of not_registered, namespace_disabled, the reason code the discovery check returned (no_matching_grant, host_policy_denied, or a fail-closed code), or — from the execution envelope — not_offered, a tool name the turn's catalogue never held. reason stays the code the caller was given, so the audit code and the wire code remain comparable and one refusal keeps one name (ADR 0012).

Where the refusal came from a decision, an authorization.checked event is also recorded immediately before and carries the same reason. Two of the situations produce no decision — nothing was checked when the tool is not registered, and nothing was checked when its namespace is off — so cause is what makes the disambiguation hold for all of them rather than for the one that happens to consult the authorizer.

If you are debugging a tool_unavailable and have no audit sink wired, wire one first.

Both boundaries use this one code. The execution envelope refuses a tool outside the turn's permission-filtered catalogue with tool_unavailable, the same code the kernel uses. Which boundary refused is recorded separately, as metadata.source on the audit event and as OperationRecord.source in a conformance record: a code says what was refused, a source says who refused it. The earlier tool_not_available is gone rather than aliased — two names for one refusal is the defect.

An owner-crossing requirement is the other pair worth keeping apart: invalid_request is a denial, checked before the tool's declared ceiling and answered by the authorizer, so it produces an authorization decision; invalid_tool_requirement says the tool misbehaved, not that the request was impermissible.

Tool invocation

CodeStatusMeans
tool_unavailabledeniedNot registered, namespace off, or not discoverable — see above
no_matching_grantdeniedThe exact argument-selected resource is not authorized
invalid_requestdeniedThe resolved requirement names a world other than the caller's own
invalid_tool_argumentsfailedparseArguments rejected the call. The thrown error goes to onProviderError and nowhere else (below)
invalid_tool_requirementfailedresolveRequirement returned something outside the declared ceiling
tool_requirement_resolution_failedfailedresolveRequirement threw. The thrown error goes to onProviderError and nowhere else (below)
tool_catalog_unavailablefailedA ContextToolProvider threw. The catalog is never partially returned. The thrown error goes to onProviderError and nowhere else (below)
tool_execution_failedfailedYour invoke threw. The thrown error goes to onProviderError and nowhere else (below)
invalid_tool_resultfailedYour handler returned something that is not a ToolResult
trace_mismatchdeniedcall.traceId does not match the context
step_limit_exceededdeniedThe call names a step at or past the envelope's maxSteps. This call is refused; the turn continues
tool_call_limit_exceededdeniedThe envelope's maxToolCalls is spent. This call is refused; the turn continues

A budget refuses a call; it does not end a turn. The envelope answers the call that crosses maxSteps or maxToolCalls with denied, the runtime receives an ordinary tool result, and the turn may still complete. The one budget that ends a turn is StandardRuntime's own loop: a driver that is still asking for tools when the loop's last step is spent fails the turn with step_limit_exceeded (see turns). Which boundary refused is OperationRecord.source, as for tool_unavailable.

Resources

CodeStatusMeans
resource_provider_not_foundfailedNo provider registered for that namespace
resource_execution_failedfailedYour provider threw. The thrown error goes to onProviderError and nowhere else (below)
invalid_resource_resultfailedYour provider returned a malformed ResourceResult, or one whose operationId does not match

Messages

CodeStatusMeans
message_transport_not_configuredfailedNo messageTransport was supplied to the kernel
message_context_mismatchdeniedThe envelope's sender, purpose, or trace disagrees with the context
message_requirement_resolution_failedfailedThe capability resolver threw. The thrown error goes to onProviderError and nowhere else (below)
message_delivery_failedfailedYour transport threw. The thrown error goes to onProviderError and nowhere else (below)
invalid_message_receiptfailedYour transport returned a malformed delivery result
message_request_not_preparedfailedThe request tool did not prepare the authorized call
message_request_not_acceptedfailedThe transport did not accept the request
message_reply_resolution_failedfailedThe host router could not resolve the durable reply. The thrown error goes to onProviderError and nowhere else (below)
invalid_message_replyfailedThe resolved reply did not preserve request context

Turns

CodeStatusMeans
actor_mismatchdeniedThe turn's agent is not the admitted one
receiver_mismatchdeniedThe delivered message's receiver is not the executing agent
message_context_mismatchdeniedThe delivered message's trace or purpose disagrees with the context
no_matching_grantdeniedNo sharedos.execution / invoke grant for the target agent
escalation_requestedescalatedThe runtime stopped and asked for a human. Nothing was granted
step_limit_exceededfailedStandardRuntime spent its own steps while the driver was still asking for tools. The envelope's budgets refuse calls instead — see tool invocation
driver_failedfailedYour AgentTurnDriver threw. The thrown error goes to onTurnError and nowhere else (below)
invalid_driver_decisionfailedThe driver returned something that is not a valid decision
runtime_failedfailedA RuntimePlugin threw, or a host port the turn body called did. The message is fixed and the thrown error goes nowhere near the wire — install onTurnError to see it (below)
invalid_runtime_outcomefailedA plugin returned a malformed outcome
tool_unavailablefailedA plugin returned escalate on a turn whose catalogue does not offer sharedos.escalate. The envelope refuses the outcome as it refuses a call outside the catalogue, under the same code (ADR 0017); the turn's turn.ended event carries it
turn_cancelledcancelledDeadline expired, or the host aborted

Diagnosing a contained throw

Every code in this document is a bounded fact: it says an operation stopped and does not say why. That is deliberate. A ProtocolError.message reaches the model, an ExecutionEvent reaches an ExecutionRecord that travels further than an audit sink, and audit has never carried call data — while a thrown message may hold arguments, rows, or credentials the thrower had in scope.

So the error itself goes to a host-side sink instead, whole and unwrapped. There are two, one per layer that contains a throw, and neither changes anything a caller can see.

SharedOSKernelOptions.onProviderError — a provider, tool handler, transport, or router threw, and the kernel answered with a reason code:

new SharedOSKernel({
  grantSource,
  onProviderError: (error, op) =>
    logger.error({ err: error, ...op }, `${op.kind} port failed: ${op.reasonCode}`),
});

One hook covers every such port. op.kind is "tool", "tool_catalog", "resource", or "message", so a host that wants to route a transport failure differently branches on it — and a port added later reaches the hook already installed. op.reasonCode is the code the kernel returned in its place and the one the matching audit event carries under reason, so a log line joins to audit without correlating on timing. It is usually also what the agent was told; the exception is a transport failure under the message-request tool, where audit records message_delivery_failed and the tool result says message_request_not_accepted. Both carry the same operationId.

kind follows the entry point rather than the port where the two differ: a MessageCapabilityResolver that throws is message under sendMessage and tool under the message-request tool, which is a tool call resolving its requirement. Match on reasonCode to watch one port.

The error arrives as thrown, with one exception: when a ContextToolProvider's listTools throws, the kernel replaces it with one catalogue-failure sentence every caller can match on, and the provider's error survives as that wrapper's cause. A tool_catalog report from another origin — a returned handler the registry refuses, which throws a named DuplicateRegistrationError or TypeError a caller can branch on — carries that error unwrapped and has no cause. Log error and let a formatter walk it.

The same provider failure reaches a different hook depending on who asked. A throw from listTools during a turn is contained by the execution envelope as runtime_failed and reported to onTurnError, because the turn body calls listTools itself; only a call made through invokeTool reaches onProviderError as tool_catalog_unavailable.

SharedOSExecutorOptions.onTurnError — a turn ended on a throw:

new SharedOSExecutor(kernel, plugin, {
  onTurnError: (error, { executionId, traceId }) =>
    logger.error({ err: error, executionId, traceId }, "turn ended on a throw"),
});

StandardRuntime takes the same option, because only one of the two catches any given throw: a driver's becomes the loop's cooperative driver_failed outcome, which the envelope never sees as an exception, and the executor catches everything else as runtime_failed. TurnExecutor forwards to both, so one sink covers both.

Read the stack. runtime_failed is also what a throw from openTurnAuthority, admitTurn, or listTools ends a turn as, so the code alone does not say whether the plugin or one of your own ports failed; the stack does.

Both hooks are observational and synchronous. One that throws is ignored, a component with none installed behaves identically, and neither is awaited — unlike onAuditError, which fires after the side effect where there is nothing left to hold up, these fire mid-flight with a result still to construct. A cancelled operation is not reported: every site that awaits a host port re-throws the abort ahead of the containment, and the three that do not — an argument parser, a requirement resolver, a message capability resolver — wrap synchronous code that is never handed the signal, so an abort cannot be what made them throw. A caller that stopped the work is not a defect.

HostCeiling reports through the same shape, but from CapabilityAuthorizerOptions.onProviderError rather than the kernel's: the ceiling is installed on the authorizer, so the kernel's hook cannot reach it. A host wanting both passes one function to both. Its reports carry kind: "policy" and reasonCode: "host_policy_unavailable". A PolicySource that throws reports the same kind and reasonCode through the kernel's hook, once per turn at the boundary rather than once per decision it fails, and with no resource or action, because no operation had started.

Still uncovered: the four authority ports discard a throw the same way, and they are not equally bad. GrantSource, GrantUsageStore, and DelegationChainResolver at least fail closed under their own codes — authority_unavailable, usage_store_unavailable, delegation_chain_unverified, each marked failClosed — so the failure is classified even though the cause is gone.

CapabilityGrantVerifier is the one to watch. A throw from verify is treated as false (reason 8 under no_matching_grant), so the grant becomes invisible and the denial reads no_matching_grant — not a failClosed code, and indistinguishable from an actor who was simply never granted the capability. A broken verifier looks exactly like correct enforcement.

Adapters and the MCP harness path

Codes from @aicoo/sharedos-adapters. The harness_* and model_* codes are how a driver or plugin ends its turn, so they surface as a failed ExecutionResult; escalation_pending is a tool result on the MCP path.

CodeStatusMeans
escalation_pendingdeniedA tools/call made after the turn asked for a human. The bridge refuses it in band and nothing further runs on that turn (ADR 0018). An agent without the escalation grant gets tool_unavailable
harness_not_startedfailedThe vendor CLI could not be spawned
harness_exited_without_outcomefailedThe CLI exited non-zero without a terminal frame
harness_ended_without_outcomefailedThe harness closed its channel before completing the turn
harness_frame_limit_exceededfailedToo many frames without an outcome (maxIgnoredFrames)
harness_arguments_unparseablefailedCodex or DeepSeek Harness sent tool arguments that are not a JSON object
harness_command_rejectedfailedPi rejected a command. Retryable
harness_failedfailedThe harness reported its own failure. Retryable; Codex and Claude Code substitute the vendor's own code when the frame names one
model_call_failedfailedModelDriver's provider call threw, other than by cancellation
model_output_truncatedfailedThe provider cut ModelDriver's reply at the output-token ceiling (finish_reason: length). Nothing in a cut-off reply is a decision the model finished making, so none of it is released
model_malformed_call_limit_exceededfailedThe model made more than maxMalformedCalls (default 8) calls whose arguments were not a JSON object. Each was refused in place as invalid_tool_arguments and answered back to the model, never sent as {}; the turn's metadata counts them as malformedToolCalls

HTTP

StatusCodeMeans
400invalid_jsonBody is not JSON
400invalid_requestBody does not match the v1 contract
403permission_deniedAn error carrying that code reached the handler
404not_foundUnknown path
405method_not_allowedWrong verb
500invalid_access_contextresolveContext returned an invalid context
500internal_errorAnything else; details never leak

Delegation

Delegation has two boundaries and each has its own vocabulary. deriveGrant refuses to issue; the chain check refuses to honour. A host that hits the first has a bug in what it is trying to hand out; a host that hits the second has a grant that was fine when written and is not fine now.

Refused at issue — deriveGrant

deriveGrant returns { ok: false, reason } rather than clamping — a silently narrowed delegation reads as accepted, and the delegator then believes it passed on more than it did.

ReasonMeans
empty_capabilitiesNothing was actually delegated
id_collides_with_parentThe derived grant reuses the parent's id
parent_not_delegableThe parent has no delegationDepth, or it is already zero
depth_exhaustedThe child asked for a longer chain than was received
capability_not_within_parentWider or sibling path, an unheld action, an exact parent widened into a subtree, an owner pinned onto an unowned parent, or one capability assembled from several
purpose_not_within_parentA purpose the parent does not carry
window_not_within_parentA validity window outside the parent's
issued_before_parentThe child is dated earlier than the grant that authorized it
bounded_parent_not_delegableA maxUses parent. Sharing one budget across a chain needs cross-grant accounting, so it is refused rather than multiplied

Refused at use — the chain check

Reported as delegation_chain_invalid, with the failing link's code and grant id in AuthorizationDecision.metadata.

CodeMeans
issuer_not_parent_subjectThe child's issuer is not who the parent was issued to
namespace_mismatchParent and child are in different worlds
parent_inactiveAn ancestor is revoked, expired, or out of purpose
capability_widenedA child capability is not contained in one parent capability
constraints_widenedWindow, purposes, or issue order widened — an omitted constraint counts
delegation_not_permittedThe parent declares no delegation budget
delegation_depth_exceededThe child's budget is not strictly smaller
bounded_parent_not_delegableThe parent is bounded by maxUses
chain_cycleThe chain leads back to a grant already walked
chain_too_longMore links than DEFAULT_MAX_DELEGATION_CHAIN_LENGTH

And as delegation_chain_unverified, when the chain could not be established at all: resolver_unavailable (none installed), parent_not_found, or resolver_failed. Unverified outranks invalid when several grants fail differently, so an outage is never reported as a policy decision.

Execution events

ExecutionResult.events, append-only and ordered by sequence.

TypeWhen
turn.startedAdmission passed; the runtime is about to run
tool.requestedThe runtime asked for a call, before authorization
tool.completedAny outcome — succeeded, denied, or failed
runtime.eventA plugin's own event, wrapped rather than trusted
turn.completedThe runtime finished
turn.escalatedThe runtime stopped and asked for a human; the result is escalated
turn.failedThe turn ended in failure; source says who ended it — envelope (it refused the runtime's outcome, or the runtime threw) or runtime (a failure the runtime reported as its own)
turn.deniedAdmission or context validation refused the turn
turn.cancelledDeadline expired or the host cancelled

Audit events

TypeOutcomesWhen
authority.resolvedsucceeded, failedA turn loaded its authority, once; failed is fail-closed
authorization.checkedallowed, deniedOne decision, before any tool, resource, message, or turn
escalation.requestedescalatedA turn ended by asking for a human; nothing was granted
resource.invokedsucceeded, denied, failedA direct resource operation
tool.invokedsucceeded, denied, failedA tool call
tool.catalog.listedsucceeded, deniedA catalogue was computed; denied is an empty one, fail-closed
tool.namespace.catalog.listedsucceededThe management-plane namespace catalogue was read
tool.namespace.selection.updatedsucceeded, failedA namespace patch was applied
message.sentsucceeded, denied, failedA message was delivered through the transport
turn.endedsucceeded, denied, failed, escalatedOne turn reached a terminal outcome, recorded by the envelope

authority.resolved opens a turn: a turn resolves authority once, and this is the event that records which grants it resolved to. It carries authorityHash and, in metadata, the grantIds and grantCount behind that hash — so every later decision in the turn, which carries the same hash, can be traced back to the exact authority set it was made against. A failure records authority_unavailable and failClosed, because a source that could not answer denies rather than widening.

escalation.requested is the audit record of a turn that stopped and asked a human. Its outcome is escalated, which is deliberately not denied: a denial is a decision SharedOS made, an escalation is one it declined to make. Counting them together inflates every denial rate by the cases where the system correctly asked for help.

Every event carries version, id, type, outcome, at, traceId, namespaceId, actor, authority, owner, purpose, and where applicable resource, action, grantId, authorityHash, operationId, tool, messageId, receiver, reason, requestedAuthority, and metadata.

id is the record's own identity, minted when the event is made and never derived from its content. at is the turn's instant, so every record of one turn shares it, and a bare authorize names no operationId; the same question asked twice in one turn is therefore two records that agree on every field but id. A store that deduplicates keys on id. Keyed on a hash of the content, it keeps one of the two and loses the other.

turn.ended is the execution envelope's one event, written at the terminal through the kernel, which owns audit. It carries the turn's executionId as operationId and the terminal code as reason. A cancelled turn is recorded failed with reason turn_cancelled rather than adding a sixth AuditOutcome: the outcome vocabulary is a compatibility surface, and reason already separates a deadline from a defect. There is one event per turn, not one per transition — a turn.denied would double-count against the authorization.checked that admission already produced for the same refusal (ADR 0023).

requestedAuthority appears on escalation.requested, and only when the escalation named a capability. The kernel minted it: its id, namespaceId, requester, owner, and requestedAt come from the trusted context, not from the caller, and the id is derived from the ask. It is the same payload a denial carries as requiredAuthority — one concept in two roles: a denial says what was required, and an escalation requests it. It is the CapabilityRequest a reviewer's queue is built from — a top-level field rather than a metadata key because it is a contract type with its own schema, and a consumer reading it should be reading that shape rather than trusting an untyped bag to hold it (ADR 0019).

authorityHash names the exact authority set a decision was made against. A turn resolves authority once, so the authority.resolved event that opened it and every authorization.checked and tool.catalog.listed event inside it carry the same value (ADR 0010); a consumer reconstructing a turn can pin every decision to that one load.

source is on every operation and terminal event, in metadata: kernel or envelope, the boundary that produced it. It was free to infer until the envelope began recording — anything in audit was the kernel's, because the envelope wrote nothing — and it exists so closing that gap did not open an ambiguity in its place.

metadata keys a host may rely on: authority.resolved carries grantIds and grantCount (or failClosed: true and authority, the internal code, when it failed), and hostCeiling, "installed" or "absent", on both; authorization.checked carries consumed, whether a bounded use was spent, failClosed: true on an infrastructure denial, and whatever the decision itself carried — a HostCeiling's own keys, or delegation detail on a broken chain — less consumed and failClosed, which the kernel states itself and a port cannot overwrite; tool.catalog.listed carries catalogHash, enabledNamespaces, hostPolicyVersion, and withheldCount (below), and failClosed: true with authority when authority itself could not load and the catalogue is empty; tool.invoked carries cause where its code covers several situations; turn.ended carries endedBy, envelope or runtime, on a failure, so a reader crediting enforcement does not credit a plugin's self-reported error; escalation.requested carries detail (the reason the runtime gave), reviewer, reviewerAssumed, and resolution.

A listing is recorded by what it was computed from, not by the names it returned or withheld. catalogHash is the catalogue the caller was shown, computed as listPublishedTools computes it, so an execution's manifest and the audit record match on one identifier; enabledNamespaces is the caller's own filter; hostPolicyVersion is the version the turn's PolicySource stated, present only when one loaded; and withheldCount is how many registered tools were not returned. With authorityHash at the top level, equal values on two events mean the same catalogue for the same reasons, and the record does not grow with the registry — a two-hundred-tool registry would otherwise write its names to audit on every turn to say what one digest says once. What a count cannot carry is the per-tool cause; failClosed: true keeps the one distinction a reader cannot do without, that something was withheld by an outage rather than by a decision, and an attempted call on a withheld tool is still recorded on tool.invoked with its own cause.

This vocabulary is a compatibility surface. Hosts persist these events under closed schemas of their own, so a new type, outcome, or top-level field is a contract change to record here; a new metadata key or reason string is not.

Wire onAuditError to alerting. A dropped audit write must not pass silently — it is the only record that separates "was allowed to" from "did it and nobody stopped it".

Contract limits

Rejected by the schemas, so they hold identically on both boundaries.

LimitValueLimitValue
Turn timeout≤ 600,000 msTool calls per turn≤ 10,000
Steps per turn≤ 1,000Tools per request≤ 512
Path segments≤ 64Segment length≤ 256 chars
Capabilities per grant≤ 64Actions per capability≤ 64
Purposes per grant≤ 64Purpose length≤ 512 chars
Delegation chain≤ 16Namespaces per catalog≤ 256
Search query / grep pattern≤ 8,192 charsSearch results≤ 100
Grep context≤ 100 lines per sideTool description≤ 8,192 chars
Capsule encoded≤ 128 KBCapsule item content≤ 96 KB, ≤ 12 items

Path segments additionally reject separators, traversal markers, and control characters. A filesystem-backed provider must still resolve beneath its own root and reject link escapes — the contract cannot see your disk.