A3 Approval Protocol
Version: 1.0.0 Status: Stable. Changes follow the versioning rules in the README. Licence: Community Specification License 1.0 (LICENSE.md). Independent implementation is explicitly permitted and encouraged. Depends on: Decision Trace Format 1.0 — approvals are recorded as trace records and verified over a trace.
Every agent control plane can put a human in front of a tool call. This protocol specifies what most of them leave undefined: what exactly was approved, so that a verifier can later prove the action that ran is the action a human saw — and that nothing else ran under that approval.
The words MUST, SHOULD and MAY are used as in RFC 2119.
1. The problem this solves
An approval that is not bound to a specific action is theatre. Between the moment an approver clicks approve and the moment the tool is invoked, the agent can re-plan, the arguments can change, the target can move. A log line saying “approved” proves nothing about what followed.
This protocol makes the approval a cryptographic commitment to a plan: the concrete, fully resolved action. The gateway that executes the action recomputes the plan’s hash at the moment of invocation and refuses if it differs. The trace records the plan the approver saw, every decision, and the plan that ran, so that all three can be compared after the fact by someone who was not there.
Three properties follow:
| Property | Meaning |
|---|---|
| Binding | An approval applies to one plan, identified by its hash. Any change to the plan voids it. |
| Single use | An approved plan is invoked at most once. |
| Verifiability | Given only the trace and the key set, a verifier can confirm that every approval-gated action was approved, by eligible approvers, in quorum, before expiry, and ran unchanged. |
2. The plan and its hash
A plan is a JSON object describing the action exactly as it will run:
{
"v": "1.0",
"trace_id": "019267b4-…",
"task_id": "task-4471",
"agent": {"name": "order-analyst", "namespace": "support"},
"originating_principal": {"type": "human", "id": "sub|8f3a…"},
"action": {
"tool": "refund-processor",
"operation": "issue-refund",
"arguments": {"order": "A-1043", "line": 3, "amount": "42.50"}
},
"side_effect": "mutating"
}
| Field | Why it is in the plan |
|---|---|
v |
Protocol version. |
trace_id, task_id |
An approval given in one task cannot be replayed in another. |
agent |
Which agent definition will act. |
originating_principal |
On whose authority. Needed for separation of duties (§5.3). |
action |
The tool, operation and fully resolved arguments. No templates, no late binding. |
side_effect |
The classification the approver is being asked to accept. |
plan_hash = sha256(JCS(plan)), encoded sha256:<64 hex>, using RFC 8785 canonicalisation exactly as the trace format does. Two implementations that agree on the plan object agree on the hash.
Deliberately not in the plan: the instance — an instance may restart between approval and invocation without voiding the approval; and the request’s expiry — that belongs to the request, not the action.
Deviation is exact. There is no partial or fuzzy match in this version; a plan whose arguments differ by one byte is a different plan.
2.1 What is not plan binding
The guarantee in §1 rests entirely on one property: the hash covers an action that is fully resolved, so the deed that runs is the deed that was accepted. An implementation MUST NOT put a hash of anything else in plan_hash, MUST NOT describe a weaker construction as plan binding, and MUST NOT report E_PLAN_MISMATCH about one.
There is a weaker construction that some deployments will need, and naming it here costs less than letting each of them name it differently. Call it plan-class authorisation: one decision covering a described set of future actions rather than a single resolved one — the same operation against any line of one order, any actuation within a stated envelope, anything that must be authorised before the acting party can be reached. Where an approver is reliably reachable it is an unnecessary weakening and this specification’s answer is to use plan binding. Where one is not — an emitter disconnected at the moment the question arises (decision-trace.md §5.1) — the alternative to it is not stronger authorisation, it is an agent that stops.
It is weaker in a way worth stating precisely rather than in general terms. Under plan binding an approver accepts a deed, and a verifier confirms afterwards that the deed and the acceptance are the same object; the question is closed by arithmetic. Under plan-class authorisation an approver accepts a description, written before the deed existed, and a verifier can confirm only that the deed matched the description. That moves the load-bearing question from cryptography to whoever wrote the description, and no amount of hashing moves it back.
This version does not specify it. So that the room stays open and an extension cannot collide with this document, class_hash and the error prefix E_CLASS_ are reserved, and a verifier of this version MUST NOT accept a class_hash in place of a plan_hash: an invocation carrying one is not plan-bound, whatever else it may be.
3. Records
The protocol defines the content of three record kinds from the trace format. Fields marked required are in addition to what the trace format already requires for the kind.
3.1 approval.requested
Emitted by the component that decided approval is needed, when it suspends the action.
| Field | Required | Meaning |
|---|---|---|
request_id |
yes | Unique within the trace. |
plan_hash |
yes | Hash of plan. |
plan |
yes | The plan object of §2, verbatim. Approvers decide on this; verifiers rehash it. |
policy |
yes | The policy that required approval. |
approvers_from |
yes | Groups whose members may decide. |
quorum |
yes | Number of distinct valid approvals required. ≥ 1. |
separation_of_duties |
yes | Whether the originating principal is barred from approving. |
step_up |
yes | "none" or "mfa": what the decision must carry. |
expires |
yes | RFC 3339 UTC. Both the decision and the invocation must happen before this. |
reasoning_summary |
no | The agent’s stated reason, for the approver. |
blast_radius |
no | Estimated affected set. |
dry_run |
no | Result of a dry run, or its hash. |
escalates |
no | request_id of an earlier request this one replaces after timeout. |
break_glass_groups |
no | Groups whose members may break glass on this request (§5.6). Absent means break-glass is not permitted. |
3.2 approval.decided
Emitted by the approval service once per decision, including decisions it rejects as invalid, and once for expiry. The record carries the running tally so the outcome is verifiable at every step.
| Field | Required | Meaning |
|---|---|---|
request_id |
yes | |
plan_hash |
yes | MUST equal the request’s. |
decision |
yes | approve | deny | expired | break-glass. |
approver |
yes | {"type": "human"\|"service"\|"component", "id": …, "groups": [...]}. For expired, the approval-service component. groups MUST be present for approve and break-glass. |
step_up |
yes | Whether step-up authentication was performed for this decision. |
valid |
yes | Whether the decision counts. false with invalid_reason when the approver was ineligible, self-approving, or otherwise barred. Invalid decisions are recorded — an attempt to self-approve is itself evidence — but never counted. |
invalid_reason |
when valid is false |
|
tally |
yes | {"approvals": n, "denials": m, "quorum": q} after applying this decision. |
outcome |
yes | pending | approved | denied | expired after applying this decision. |
on_behalf_of |
no | {"type", "id", "groups"}: the principal whose authority was exercised, when the approver acted as a deputy (§5.5). Absent means the approver decided on their own. |
comment |
no | The approver’s note. |
break_glass |
when decision is break-glass |
{"reason": …}. |
approver_sig |
no | {"kid", "sig"}: the approver’s own Ed25519 signature over utf8(sha256(JCS({request_id, plan_hash, decision}))). Optional; gives non-repudiation independent of the service’s key. |
3.3 tool.invoked
For an approval-gated invocation, two fields are required beyond the trace format’s:
| Field | Meaning |
|---|---|
plan_hash |
The approved plan’s hash. |
approval_request_id |
The request whose approval authorises this invocation. |
The record’s own tool, operation and arguments are what actually ran. A verifier rebuilds the plan from them and the trace’s context and rehashes it (§6 step 4). This is the binding check.
4. Lifecycle
The rules below are normative. The diagram summarises them.
stateDiagram-v2
direction LR
[*] --> pending: approval.requested
pending --> pending: valid approve, quorum not yet met / invalid decision
pending --> approved: valid approve or break-glass, quorum met
pending --> denied: any valid deny
pending --> expired: no final outcome at expires
approved --> invoked: tool.invoked, plan hash recomputed, once, before expires
denied --> [*]
expired --> [*]
invoked --> [*]
Rules:
- Outcome is a pure function of the valid decisions in order. After each decision: any valid
deny→denied, final. Else if distinct valid approvers (includingbreak-glass) ≥quorum→approved, final. Elsepending. - A final outcome does not change. Decisions recorded after a final outcome MUST carry
valid: falsewithinvalid_reason: "request already resolved". - Expiry. If no final outcome has been reached at
expires, the service MUST recorddecision: expired,outcome: expired. A request withescalatesMAY then be raised naming the new groups; it is a new request with a newrequest_idand its own expiry, bound to the sameplan_hash. - Invocation. The gateway MUST invoke only after observing
outcome: approved, MUST recompute the plan hash from the action it is about to perform and refuse on mismatch, MUST invoke beforeexpires, and MUST NOT invoke the samerequest_idtwice. - Batches. Approving several actions at once is several requests, each plan-bound and individually recorded. There is no batch object.
5. Decision validity
A decision is valid only if every applicable rule holds. An invalid decision is recorded with valid: false and does not affect the tally.
5.1 Eligibility
The approver’s groups MUST intersect the request’s approvers_from. Membership is evaluated at decision time by the identity interface, not at request time.
Where the decision carries on_behalf_of (§5.5), eligibility is evaluated against that principal’s groups instead. A deputy uses lent authority; the deputy’s own groups are irrelevant to whether they may decide, and equally irrelevant to how far they may.
5.2 Distinctness
Each approver counts once per request. A second approve from the same approver.id is valid: false, invalid_reason: "duplicate approver".
5.3 Separation of duties
When the request has separation_of_duties: true, an approve or break-glass whose approver.id equals the plan’s originating_principal.id is valid: false, invalid_reason: "self-approval". Denials are always permitted from anyone eligible.
The same rule applies to the lender: when the request has separation_of_duties: true and the decision’s on_behalf_of.id equals the plan’s originating_principal.id, the decision is valid: false, invalid_reason: "deputised by the originating principal". Otherwise the person whose work is being approved could lend their approval authority to somebody else and have that signature stand in for the independent one the policy asked for, which is self-approval with an extra step.
5.4 Step-up
When the request has step_up: "mfa", an approve with step_up: false is valid: false, invalid_reason: "step-up required".
5.5 Deputised authority
A decision MAY carry on_behalf_of: {"type", "id", "groups"}, the principal whose authority is being exercised. It changes who may decide and nothing else.
- Eligibility is judged on
on_behalf_of.groups(§5.1). - Distinctness is judged on
approver.id(§5.2). One person is one signature however many people’s authority they hold: an approver cannot satisfy a quorum of two by deciding once as themselves and once as a deputy. - Separation of duties applies to both identities (§5.3).
- Step-up is the approver’s to present (§5.4). Authority can be lent; a fresh authentication cannot.
on_behalf_of.idequal toapprover.idisvalid: false,invalid_reason: "deputised to the approver themselves".- A deputy MUST NOT appoint a deputy with authority they hold only as a deputy. A verifier cannot see this from the trace — the record shows one lender, not a chain — so it is a requirement on the implementation appointing the deputy, not a verification rule.
How a deputy is appointed is outside this protocol. What the protocol requires is that both identities appear on the record: a deputy is lent authority, not identity, and an audit that could not see the decision was made by somebody other than the person entitled to make it would be missing the fact that matters most about it.
5.6 Break-glass
break-glass counts as an approval that alone satisfies quorum, regardless of quorum, when the approver’s groups intersect the policy’s break-glass groups. It MUST carry break_glass.reason. It is subject to separation of duties. Implementations MUST alert on it in real time and SHOULD require post-hoc review; both are outside the trace.
6. Verification procedure
Runs over a trace that has already passed the Decision Trace Format verifier. Input: the trace. Output: VALID or INVALID with every error found, each naming the seq.
Maintain, per request_id: the request record; the set of valid approvers; the denial count; the current outcome; whether it has been invoked.
For each record in seq order:
- Request. On
approval.requested:plan_hashequalssha256(JCS(plan)), elseE_PLAN_HASH.plan.trace_idequals the trace’s,plan.task_idequals the trace’s task, elseE_PLAN_SCOPE.quorum ≥ 1. Duplicaterequest_idisE_REQUEST_DUPLICATE. - Decision. On
approval.decided: a request with thatrequest_idexists earlier, elseE_DECISION_ORDER.plan_hashequals the request’s, elseE_PLAN_MISMATCH. Recompute validity per §5 and compare withvalid/invalid_reason; mismatch isE_VALIDITY. Apply the decision and recomputetallyandoutcome; mismatch with the record isE_OUTCOME. Adecision: expiredwithtsbeforeexpiresisE_OUTCOME. - Gated invocation. On
tool.invokedcarryingapproval_request_id: the request exists (E_APPROVAL_MISSING); its outcome at this point isapproved(E_APPROVAL_MISSING);tsis beforeexpires(E_APPROVAL_EXPIRED); the request has not already been invoked (E_APPROVAL_REUSED). - Binding. Rebuild the plan from the invocation:
v, the trace’strace_idandtask_id,agentandoriginating_principalfromtask.admitted,actionfrom the record’stool,operation,arguments, and the record’sside_effect. Its hash MUST equal the record’splan_hashand the request’s. ElseE_PLAN_MISMATCH. - Ungated invocation. A
tool.invokedwithplan_hashbut noapproval_request_id, or vice versa, isE_STRUCTURE.
A verifier MUST report every error, not stop at the first.
What the verifier cannot establish, and does not claim to: whether an invocation should have been gated. That depends on policy, which lives outside the trace. The gateway’s policy.evaluated records show what it decided; auditing the policy itself is a separate exercise.
7. Standalone implementation
This protocol is implementable by a runtime that uses no other component of any particular runtime. The minimum is:
- Build the plan object of §2 for any action that needs approval, hash it with JCS + SHA-256.
- Emit the three records of §3 into a Decision Trace Format trace.
- Apply the rules of §4–5 when collecting decisions.
- Recompute the hash at invocation and refuse on mismatch.
How decisions are collected — a web page, a chat message, a CLI, a ticket — is not specified and does not matter to verification. What matters is what gets recorded.
8. Security considerations
- Approver identity. The protocol records who the approval service says approved. That is only as strong as the service’s identity integration.
approver_siglets an approver sign independently; deployments that need non-repudiation against a compromised service should require it. - TOCTOU. The hash MUST be recomputed from the exact request that will be sent to the tool, at the gateway, immediately before sending. Recomputing earlier — or trusting a hash supplied by the agent — reintroduces the gap this protocol closes.
- Replay.
trace_idandtask_idin the plan stop cross-task replay; single-use stops within-task replay;expiresbounds the window. - Fatigue. Nothing here prevents an approver from rubber-stamping. Measuring approval load and blast-radius-scaled quorum are policy concerns; the protocol makes them auditable by recording every decision.
- What the approver sees. The approver MUST be shown the plan that was hashed, not a summary of it. A UI that shows “refund order A-1043” while the plan says the whole order rather than one line has not obtained an informed approval, whatever the hash says.
9. Conformance
Two conformance roles: Requester/Executor (emits requests and gated invocations that verify) and Verifier (produces the specified result for every vector in vectors/approval-protocol/). The initial vectors: a valid single-approver flow; quorum of two reached; a denial; expiry; invocation after expiry; a self-approval recorded invalid then a valid approval; an ineligible approver; a duplicate approver; a decision before its request; an invocation whose arguments differ from the approved plan; the same approval used twice; a break-glass approval; a request whose plan_hash does not match its plan.
10. Example
A mutating call, one approver, quorum 1, invoked before expiry. Envelope fields abbreviated.
{"seq":5,"kind":"policy.evaluated","actor":{"type":"component","id":"gateway"},"content":{"point":"pre-action","policy":"large-refunds","rule":"matches","decision":"require-approval","reason":"…"}}
{"seq":6,"kind":"approval.requested","actor":{"type":"component","id":"gateway"},"content":{"request_id":"ar-7c1e","plan_hash":"sha256:9b1c…","plan":{"v":"1.0","trace_id":"019267b4-…","task_id":"task-4471","agent":{"name":"order-analyst","namespace":"support"},"originating_principal":{"type":"human","id":"sub|8f3a"},"action":{"tool":"refund-processor","operation":"issue-refund","arguments":{"order":"A-1043","line":3,"amount":"42.50"}},"side_effect":"mutating"},"policy":"large-refunds","approvers_from":["support-oncall"],"quorum":1,"separation_of_duties":true,"step_up":"none","expires":"2026-09-16T14:30:00.000Z","reasoning_summary":"Line 3 was lost in transit and has not scanned since the depot; refunding that line is the documented remedy."}}
{"seq":7,"kind":"approval.decided","actor":{"type":"component","id":"approvals"},"content":{"request_id":"ar-7c1e","plan_hash":"sha256:9b1c…","decision":"approve","approver":{"type":"human","id":"sub|c04d","groups":["support-oncall"]},"step_up":false,"valid":true,"tally":{"approvals":1,"denials":0,"quorum":1},"outcome":"approved","comment":"agreed, bounce it"}}
{"seq":8,"kind":"policy.evaluated","actor":{"type":"component","id":"gateway"},"content":{"point":"pre-action","policy":"approval","rule":"plan-bound","decision":"allow","reason":"request ar-7c1e approved; plan hash matches"}}
{"seq":9,"kind":"tool.invoked","actor":{"type":"agent","id":"order-analyst-7d9f"},"content":{"call_id":"c-02","tool":"refund-processor","operation":"issue-refund","arguments":{"order":"A-1043","line":3,"amount":"42.50"},"side_effect":"mutating","plan_hash":"sha256:9b1c…","approval_request_id":"ar-7c1e"}}
{"seq":10,"kind":"tool.completed","actor":{"type":"component","id":"gateway"},"content":{"call_id":"c-02","status":"ok","result_hash":"sha256:…","duration_ms":2140,"trust_level":"tool-result"}}
Change the refunded line from 3 to 2 in record 9 and the verifier reports E_PLAN_MISMATCH at seq 9: the action that ran is not the action that was approved.
11. Settled in 1.0, and future work
Settled:
approver_sigis optional. Without it, the record says the approval service attests that the approver decided. With it, the record says the approver decided. A deployment that needs non-repudiation requires it by policy. A future profile may make it mandatory.- Approval binds one exact plan. Plan-class authorisation is named and fenced off in §2.1, with
class_hashandE_CLASS_reserved for it. It will be specified as a profile, with its own vectors, and never changes what an exact approval means. - One expiry window.
expiresbounds both the decision and the invocation. A separate, shorter invocation window can be added later as an optional field without changing the meaning ofexpires. blast_radiusis informational. It is shown to approvers and recorded, and a verifier never interprets it. A shared vocabulary can be added in a minor version.