Skip to content

RFC: define the verified approver assertion (MCP-SECURITY-GATEWAY-1.0 §4.3 and ADR-0030) #3918

Open

Description

Summary

Two places in AGT stop a risky action and wait for a person. The MCP gateway calls approval_callback and blocks. ADR-0030 suspends the decision and records the outcome in an approval chain. Both surfaces store who approved, and neither defines a way to check it. What stands behind the recorded name is the approval service's word for it. Authenticated transport proves which service sent the message. It does not prove which human, if any, was at the other end. The hash-linked chain then computes over that name faithfully, which makes the arithmetic sound and the input unverified.

This RFC proposes carrying a signature made by the approver's own authenticator, so a reader of the record can check it later without asking the approval service anything. Three separable pieces:

  1. A timeout rule for approval_callback. Two paragraphs of spec text, independent of the rest.
  2. A result object in place of the bare ApprovalStatus string, which makes PENDING usable.
  3. An optional approver_evidence object on both surfaces, covering refusals as well as approvals, with one normative rule about how a runtime reads it.

Piece 3 is optional, and a deployment that omits it behaves exactly as it does today. Piece 1 is the only change that constrains an existing implementation, and it constrains it toward the fail-closed posture the spec already takes everywhere else.

Motivation

Three reasons this is worth doing now.

1. The repository already left this blank on purpose, and wrote down that it had.

ADR-0030 (#2953, merged 2026-06-11), from the proposing PR's own summary of what the ADR does:

  • requires verified approver identity, durable pending state, replay protection, and execution-time revalidation;
  • fails closed on timeout, restart, malformed responses, or binding mismatch;
  • defines linked approval evidence without requiring signed receipts in the first implementation;

So verified approver identity is required, and the mechanism that would make it verifiable was left for later. This RFC is that later piece.

A reviewer on the same PR named the gap as a test case. Roman Pelevin, listing the negative matrix he wanted driving the implementation:

  • return an approver string that is not bound to authenticated transport;

That test asks a good question and stops one step short. An approver string bound to authenticated transport is still a string the approval service chose.

The merged webhook contract (#3097) then stated the rule and left the mechanism open:

ADR-0030 section 5 supersedes that: a webhook is a transport, not an approver identity type, and the contract must carry the binding and refuse body-supplied identities that are not backed by a verified assertion.

The implementation honors that by refusing to trust an unverified identity, and delegates the judgment to a caller-supplied hook:

an approve is honoured only when the approver identity is verified by response_verifier

Nothing in the specification says what a verified assertion is, so response_verifier is a hole every operator fills privately. This RFC proposes the first defined format that fits it.

The audit schema already carries the field, unbacked. #2473, merged 2026-05-22:

  • approver_did — DID of the approving principal (approval-chain identity)

and, from the same PR:

A new spec subsection §4.3.1 documents the v1.0 caveat (fields recorded but not yet hash-covered)

An identifier for the approver is already persisted. What is missing is anything that makes it more than a label.

2. Three things an operator cannot do today.

An auditor holding the sealed record can confirm the digest matches the action, the policy version, the chain integrity, and the expiry. Those checks are real, and they all pass while the approver claim goes unchecked.

First, nobody can prove a human was involved. An automated process holding the approval service's credentials satisfies every check on both surfaces. ADR-0030 keeps an LLM out of terminal resolution and the implementation enforces it, which is the right intent, and neither reaches a compromised or misconfigured approval service. #3805 raises the same defect against a different AGT record, the verifiable-compliance-receipt proposal, and states the consequence plainly:

every stated verification check (signature validity, chain integrity, policy binding) can pass in full for a receipt chain that is entirely self-signed [...] an auditor reading "signature valid, chain intact, policy hash matches" could reasonably read that as "this was checked," when the honest claim is closer to "this agent process didn't (visibly) lie to itself."

Read that against an approval chain instead of a receipt chain and nothing about it changes.

Second, the assurance label is self-issued. identity_assurance is a value the approval service applies to itself. A verifier cannot tell a strong credential from a weak one, or from none.

Third, a refusal carries no more weight than an approval. A DENIED result is as unbacked as an APPROVED one, which matters in the case where somebody later claims they refused.

3. The same distinction is already argued in this repository, for a different field.

#3658, on data residency:

ADR-0008 models residency as declared metadata (PolicyMetadata.processing_regions: list[str], AgentPolicyClaims.data_residency). A declared region string is unverifiable. Nothing binds it to evidence, and nothing carries it across more than one hop.

The declared-versus-verified distinction is where sovereignty claims actually fail. Deploying in an EU region is necessary and not sufficient.

Substitute the approver name for the region string and the passage needs no other edit. That RFC also says why leaving the weak form in place is worse than leaving the field empty:

Rejected because an unverifiable residency claim is worse than none: it creates documented, auditable confidence in a property nobody checked.

identity_assurance is that claim today. The compliance obligation sits in the same family, and #3658 made that point too:

AGT ships an Annex IV exporter (governance/annex_iv.py) and an evidence pipeline (governance/evidence_pipeline.py), so the toolkit already takes conformity documentation seriously. Residency is the one obligation in that family with no mechanism behind it.

Approver identity is the sibling case.

Worked example, which is the acceptance test I would want.

A gateway lists payments.transfer in sensitive_tools. An agent proposes a transfer of 40,000 to account A. Policy escalates. A person approves on a hardware authenticator.

  • Re-presenting that approval for a transfer of 40,000 to account B fails, and fails at the digest rather than at a lookup.
  • A verifier holding the sealed record, a published public key, and the action confirms that a specific enrolled credential authorized that specific action, with no live call to the gateway, the approval service, or any vendor.
  • The same verifier reads approve or refuse out of the signature, not out of a field sitting beside it.
  • A compromised approval service cannot manufacture a record that passes those checks.
  • If the callback never answers, the call is blocked.

Where those stand today. The first already holds under ADR-0030, which binds the approval to the action digest and revalidates at execution. It has no equivalent on the gateway callback in §4.3, which returns a bare status with nothing to bind. The second, third, and fourth fail on both surfaces, because all three depend on evidence that does not exist. The fifth is unspecified.

So this is not a repair of the binding. The binding is correct. It is the approver claim inside it that nothing checks.

Detailed Design

What to add.

Two schema additions and one normative rule. The timeout rule is a fourth, separate item, and it has its own section below.

1. The callback returns an object instead of a bare status. PENDING blocks correctly today, and the caller cannot learn which approval it waits on, when the wait ends, or how to resume. A reason_code also separates three cases a blocked call currently collapses: a person refused, the request expired unanswered, and no eligible approver was reachable. The third is a staffing failure that presents identically to a security decision. A callback that returns a bare status string keeps working, read as {"status": "<value>"}.

2. That object carries optional approver_evidence, and so does ApprovalChainEntry. An array, because one stage can require several approvers. The first registered format is a WebAuthn assertion. The array is the extension point, so the design does not hard-code one credential technology.

Normative rule. When approver_evidence is present and verifies, the runtime MUST derive identity_assurance from the evidence and MUST NOT accept a transport-asserted value for that entry. Where policy requires evidence, absent, malformed, or failing evidence resolves to deny.

Properties the design must satisfy

Stated as properties first, following the convention #3658 used here, which gives its detailed design "deliberately specified as required properties and interface surface rather than a chosen mechanism." One concrete format is proposed below as the first registry entry. Mechanism detail for other credential classes is a follow-up.

  1. Attested, not declared. An approver assertion must be backed by evidence a verifier can check, not a value the approval service supplies. RFC: verifiable residency across delegation chains (implements ADR-0008 problems 4 and 5) #3658 states the same property for jurisdiction: "A jurisdiction assertion must be backed by evidence, not a configuration string."
  2. Bound to the action, and to the decision. The evidence covers the exact action and whether it was approved or refused. An approval must not verify as a denial of the same action.
  3. Fails closed. Where policy requires evidence, absent, malformed, or failing evidence resolves to deny. A configured callback that does not answer resolves to deny.
  4. Offline-verifiable. RFC: verifiable residency across delegation chains (implements ADR-0008 problems 4 and 5) #3658, on the equivalent requirement: "A relying party must be able to recompute the residency verdict from the record with no live call to any participant." Same requirement here, and the Merkle chaining in governance/audit.py is the same substrate.
  5. Graduated conformance. RFC: verifiable residency across delegation chains (implements ADR-0008 problems 4 and 5) #3658: "A declared region and an attested one must not be indistinguishable to a verifier. The design needs explicit levels so a deployer cannot read the weakest form as the strongest." The assurance tiers below are that requirement applied to approver evidence.
  6. Credential-agnostic. The evidence slot must not hard-code one credential technology, which is why approver_evidence is an array of registered formats rather than a WebAuthn field.
Field detail: the result object and the evidence object
// approval_callback(agent_id, tool_name, params) -> ApprovalResult
{
  "status": "APPROVED",          // APPROVED | DENIED | PENDING, semantics unchanged
  "approval_id": "<string>",     // REQUIRED when status is PENDING
  "expires_at": "<RFC3339>",
  "redrive_url": "<uri>",
  "reason_code": "<string>",     // low-cardinality
  "approver_evidence": [ ]       // optional
}
"approver_evidence": [
  {
    "format": "webauthn.assertion/1",          // registry key
    "decision": "APPROVED",                    // or DENIED, and inside the digest
    "canonicalization_profile": "<string>",
    "credential_id": "<base64url>",
    "client_data_json": "<base64url>",         // carries challenge = nonce || op_hash
    "authenticator_data": "<base64url>",       // rpIdHash, flags (UP/UV/BE/BS), signCount
    "signature": "<base64url>",
    "aaguid": "<uuid>",
    "attestation_ref": "<uri|null>",
    "nonce": "<base64url>",
    "op_hash": "<base64url>",
    "assertion_at": "<RFC3339>",
    "co_signatures": [ ]                       // optional
  }
]

No change to ActionBinding, PolicyDecision, ApprovalRequest, or ApprovalResolution. Schema version bump on ApprovalChainEntry only.

How the signature is bound to the action
op_hash   = SHA-256( JCS( canonicalize({ profile, decision, fields }) ) )   // Unicode NFC
nonce     = 32 random bytes
challenge = nonce || op_hash

WebAuthn lets the relying party choose the challenge freely, so the authenticator signs whatever meaning the challenge carries.

Both halves do work. A challenge equal to the digest alone binds the shape of an action rather than the instance, so two transfers of the same amount to the same account produce an identical challenge and a captured assertion replays against the next one. The nonce makes each ceremony unique.

The decision sits inside the hashed structure. Approve and refuse over the same action therefore produce different digests, which is what lets a verifier read the decision out of the person's signature instead of out of a neighboring field.

The challenge is issued when the approver opens the ceremony, not when the request is created, so assertion freshness stays tight while the human window stays realistic.

Why the signed field set has to be named and versioned

The action object reaches the gateway in different shapes from an MCP tools/call, an external-authorization check, a gateway plugin, and a host hook. Signing over "the action" without saying which fields, in which order, is how a signature ends up covering less than the operator believes.

This repository has already shipped that bug. #3507:

| is legal inside an MCP payload and inside a sender id, so this encoding is not injective: distinct (nonce, timestamp, sender_id, payload) tuples collapse to the same canonical string, and therefore to the same HMAC.

An attacker holding one valid envelope can forge others without the signing key, by moving text across a field boundary and reusing the signature verbatim.

The fix in #3508 length-prefixes every field, and its compatibility note states the principle that applies here too:

That is unavoidable -- the old format cannot be accepted as a fallback without keeping the forgery available.

The mild failure mode is a false reject. The dangerous one is a false accept. So:

  1. A named profile per tool schema or host surface.
  2. The profile ID inside the hashed structure.
  3. Re-verify using the profile ID carried in the evidence, never from ambient configuration.
  4. A registry that rejects an unknown profile ID rather than defaulting.
  5. Any material field present at execution but absent from the signed set is a hard failure.
Verification procedure for webauthn.assertion/1
  1. Parse client_data_json. Confirm type is webauthn.get.
  2. Split the challenge into nonce (leading 32 bytes) and op_hash (trailing 32 bytes). Confirm the nonce matches the one issued for this ceremony and has not been used.
  3. Resolve canonicalization_profile against the registry. Reject an unknown profile.
  4. Recompute op_hash from the fields present at execution and the decision value, using that profile.
  5. Constant-time compare against the digest in the challenge.
  6. Confirm origin and rpIdHash match the configured approval relying party.
  7. Confirm the UP flag, and the UV flag if policy requires it.
  8. Verify signature over authenticator_data || SHA-256(client_data_json) using the registered public key.
  9. Check signCount where the authenticator increments it.
  10. Resolve aaguid against the FIDO Metadata Service and apply configured policy.
  11. Confirm the assertion is inside the freshness window.
  12. Verify any co_signatures cover the same op_hash.
  13. Consume exactly once, safe under concurrency, reusing the existing consume() guard.
Deriving an assurance tier, non-normative
Evidence state Suggested tier
UV=1, BE=0, AAGUID resolves to an MDS entry offering biometric user verification Highest
UV=1, BE=0, non-biometric user verification High
UV=1, BE=1 (backup-eligible, replicated credential) Medium
No approver_evidence Lowest, and current behavior

BE and BS are WebAuthn Level 3 flags recording whether a credential can exist on more than one device. Organizations answer that differently per action class and should stay free to. The point is that the framework cannot record the difference at all right now.

Two clocks, and an optional co-signature
Clock Scope Typical
Pending-approval TTL How long the request stays outstanding and re-drivable Minutes to hours
Assertion-freshness window Maximum age of the assertion at execution Tens of seconds

Pending state has to survive a restart of whatever holds it. Otherwise every restart is a silent denial that presents as correct fail-closed behavior, which is an availability bug wearing a security costume. Worth stating in the spec rather than leaving implementations to discover it.

Some deployments hold a second credential whose value is a portable identity binding rather than proof of human presence. A smart-card signature under FIPS 201-3 and NIST SP 800-73 is the common case, chaining to an enterprise PKI a third party already knows how to validate. co_signatures carries such a signature over the same op_hash, alongside the assertion and never instead of it. A smart-card signature carries no evidence that a biometric verification happened, so a verifier that finds a co-signature and no assertion MUST treat the evidence as invalid. Sketched rather than specified. Say if a concrete second format is wanted.

Interface surface affected

[VERIFY: paths below come from PR descriptions in #3200 and #2363. Confirm against main before filing.]

  • docs/specs/MCP-SECURITY-GATEWAY-1.0.md §4.3 and §4.4: ApprovalStatus becomes a result object, and the callback gains a timeout rule.
  • docs/adr/0030-action-bound-approval-protocol.md: ApprovalChainEntry gains one optional field and one normative rule.
  • agent-governance-python/agent-mesh/src/agentmesh/governance/approval_protocol/models.py
  • agent-governance-typescript/src/approval-protocol/models.ts, for SDK parity per feat: language parity for require_approval approval chains (TypeScript, .NET, Rust, Go) #3083.
  • agent-governance-*/.../approval_protocol/coordinator.*: validateForExecution gains evidence verification alongside its existing digest, version, integrity, expiry, and consume-once checks.
  • agent-os/mcp_gateway.py: the callback result shape and the timeout.
  • agent-os/tests/test_spec_mcp_gateway_conformance.py: new conformance cases, in the style of the 127 already there.

The timeout rule, which can land on its own.

§4.2 fails closed when no callback is configured. §4.4 fails closed on DENIED and PENDING. The spec says nothing about a configured callback that does not answer, which is the normal case when a human sits at the other end.

#3106 took this position for agent-mesh and merged it on 2026-07-01:

CallbackApproval.on_timeout implied timeout auto-approval, which is unsafe at a governance boundary (and was already a dead parameter: a timeout always denies).

Proposed text extends that to this spec:

A gateway MUST define a timeout for approval_callback. On timeout the gateway MUST treat the result as DENIED and MUST NOT permit the invocation to proceed. Where the configured approval mechanism involves a human, a gateway SHOULD return PENDING rather than holding the callback open, and SHOULD NOT require a synchronous response within a fixed sub-second budget.

This is separable from everything above and could merge alone.

Staging.

  1. The timeout rule. Two paragraphs, independent of everything below.
  2. The result object, with approver_evidence unpopulated. Makes PENDING usable and gives reason_code a home.
  3. The evidence object and the normative rule, plus the webauthn.assertion/1 format and the profile registry.
  4. SDK parity across the ports, following the pattern in feat: language parity for require_approval approval chains (TypeScript, .NET, Rust, Go) #3083.
  5. Optional co-signature, and the rendering conformance profile if wanted.

Open questions. These are design details, not blockers. I would rather settle them in review than guess.

  1. Should decision sit inside the hashed structure as proposed, or alongside it with a separate binding rule?
  2. Where do the evidence-format registry and the canonicalization-profile registry live? These specs, a separate registry document, or the ACS manifest schema?
  3. Where does the credential-to-identity mapping live? AgentMesh already binds a sponsor_email to every agent identity, and reusing that beats standing up a second registry.
  4. Is a FIDO Metadata Service snapshot a runtime dependency the toolkit will take, or should AAGUID policy be host-supplied?

What we would contribute.

Token (tokencore.com) builds FIDO2/WebAuthn biometric hardware authenticators with match-on-chip fingerprint verification and device-bound credentials.

We will publish, under Apache 2.0, a reference approval_callback implementation and a conformance suite in the style of the 127 tests that shipped with #2363. The suite is the more useful artifact, because it lets a partner or an assessor hold any implementation to the same bar without reading anyone's source.

The fourteen checks the suite proves
  1. Pending is honored, so nothing executes before an approval exists.
  2. Fail-closed holds under a host timeout, including on hosts that fail open.
  3. The canonicalization profile round-trips: bound field set equals executed field set.
  4. Replay is rejected, and field substitution is rejected.
  5. Single use is consumed exactly once under concurrency.
  6. Both clocks expire independently and correctly.
  7. A partial multi-approver state does not release, and separation of duties holds.
  8. An unsigned or wrongly scoped eligibility assertion is rejected.
  9. The evidence signature verifies against the published public key, and a tampered record fails.
  10. Action content is unreachable before the authentication ceremony completes.
  11. A notification token issued for one candidate approver does not authenticate another.
  12. An edit to routing data changes who is notified and never who is authorized.
  13. Where a co-signature is enabled, both signatures cover the same op_hash.
  14. An assertion produced for approval does not verify as a denial of the same action, and the reverse.

The proposal is vendor-neutral and satisfiable by any WebAuthn authenticator, including platform authenticators, at the assurance tier the evidence supports. We are offering the implementation and the test vectors, not asking for a vendor-specific path.

What I am asking for.

Three answers. The first is the only one that blocks anything.

1. A direction call: is verifiable approver evidence wanted in this repository at all? A no is a useful answer and I will stop there rather than keep refining it. A yes does not commit you to the field names above, which are a starting point for review and not the thing I am asking you to accept.

2. Where it belongs: this issue, or #3668. #3668 is open and asks for a principal on audit entries, which is the same problem from the audit side. I opened separately because this touches the gateway spec and ADR-0030 rather than the audit schema, and because the timeout rule has nothing to do with audit entries. If you would rather see one thread, say so and I will move the evidence half into #3668 and leave the timeout rule here.

3. Whether the timeout rule can proceed on its own. It is two paragraphs, it extends a position #3106 already merged, and it does not depend on anything else in this RFC. If the answer to question 1 is no, I would still like to know whether that piece is worth a PR.

If the direction call is yes, the next artifact from me is a docs(adr): propose ADR-00NN PR carrying the spec text for staging items 1 and 2, with the evidence object following once the registry questions above are settled. The Apache 2.0 conformance suite lands with it.

One timing question, so I do not land across work in flight. Is there anything currently moving through approval_protocol or mcp_gateway.py that this should sequence behind? I would rather wait than merge into a file somebody is mid-refactor on.

No urgency on my side. Happy to take any of this to a call if that is easier than issue comments.

Alternatives Considered

Declared-only, as the specification stands. The cheapest option, and it is what §4.3 and ADR-0030 specify today. Rejected for the reason #3658 gave when it faced the same choice for residency: an unverifiable claim is worse than none, because it creates documented, auditable confidence in a property nobody checked. If the project prefers this anyway, my strong suggestion is the one #3658 made in the same position, which is to label the field explicitly as declared-only in the spec text so identity_assurance cannot be mistaken for enforcement.

Leave it to implementation-level extensions, which §4.3 already allows. No spec change, no maintenance burden, and an operator who wants approver evidence can build it now. Rejected because that is the status quo, and the status quo is what produces the problem. Every operator who answers "who approved this" invents a private response shape, and none of them interoperate. #2473 added approver_did, #3668 proposes reason, argument digest and principal, and that is the same pressure surfacing twice in the audit schema. The hook already exists in response_verifier. The thing a private extension cannot supply is a shared definition of what that hook verifies, and the shared definition is the whole value.

Have the approval service sign a receipt, rather than the person. Much cheaper to deploy: no credential enrollment, no ceremony, no metadata service, and it does add real tamper-evidence over the current state. Rejected as an answer to this particular problem because it moves the signature without moving the trust. The approval service is the party under evaluation. #3805 quotes draft-etcheverry-action-ref-03 §5.2 on exactly this point: "The signer of the Decision record MUST be independent of the actor/executor whose action it authorizes." A service-signed receipt is a reasonable intermediate step, and it cannot carry the assurance tiers, so it should not be recorded as though it can.

Detect after the fact rather than bind before it. Reconciling approvals against executed actions afterwards is cheaper and needs no change to the approval path. Rejected because the hash-linked chain already provides tamper-evidence, and tamper-evidence is not the missing property. A chain proves nobody edited the record. It does not prove that anyone other than the acting service ever looked at the decision, and no amount of after-the-fact reading recovers a signature that was never taken.

Fold the whole thing into #3668. Attractive, and this one is a live question rather than a settled rejection, so it appears in the ask above as well. #3668 asks for reason, argument digest and principal on audit entries, which reaches the same problem from the audit side. I opened separately because the callback result shape and the timeout rule are not audit-schema changes, and because #3668 is scoped to agent-governance-opencode while this touches the gateway spec and ADR-0030. If maintainers would rather have one thread, say so and I will move the evidence half there and leave the timeout rule here.

Security Implications

What this does not prove.

#3658 set the convention for this section by stating its own limit in one line: "Hardware attestation proves what code is running. It does not prove where the machine is." The equivalent limit here is the display, not the geography.

A user-verification flag proves that an enrolled human, holding the credential, consented to a bound challenge. It does not prove the human saw the true action, because the action renders on a display outside the authenticator's trust boundary. A compromised approver host can misrepresent what is being approved. That is a property of any authenticator without a trusted display. W3C attempted transaction display in WebAuthn twice and abandoned it both times, which is why this scopes binding to named profiles instead of promising clear-signing of arbitrary API calls.

#3658 lands on "non-repudiation rather than prevention" as the honest description of what its split claim buys. The claim here is stronger than that, because the key sits with the person rather than with an operator who could sign either way, and it is still not proof that the person saw the true action. Two controls narrow the gap and are worth stating as expectations. Action content stays unreachable until the approver completes an authenticated ceremony, so a notification link on its own discloses nothing. And the notification carries a reference, a severity, and an expiry, with no action parameters.

Closing the remainder honestly takes a conformance profile for the rendering surface: what gets rendered, from which authority, in what order, with what truncation and homoglyph handling, plus a correspondence rule that every displayed material field is signed and every signed material field is displayed. Happy to contribute that if it is in scope.

New attack surface introduced.

Three things this adds that need treatment rather than assumption.

The canonicalization-profile registry becomes an integrity dependency inside the verification path. A substituted or wrongly resolved profile silently narrows what the signature covers, which is the false-accept case, and it is why the profile ID is inside the hashed structure and re-resolved from the evidence rather than from ambient configuration.

The FIDO Metadata Service becomes an availability dependency wherever AAGUID policy is enforced. A fail-closed posture there turns an MDS outage into a denial of every approval, which is a security control behaving as an availability incident. Open question 4 asks whether the toolkit wants that dependency at all, or whether AAGUID policy should be host-supplied.

Enrollment becomes the soft target. Evidence is only as good as the binding between a credential and a person, so whatever registers credentials inherits the trust that used to sit with the approval service. This RFC does not specify enrollment, and a deployment that gets enrollment wrong gets no benefit from the rest of it.

Out of scope, and worth naming so it is not assumed.

Per-action human approval does not fit workflows that propose actions at machine rate. The mechanism that resolves it is a bounded standing grant, where the digest covers a scope expression instead of a single action, with a maximum use count, a magnitude cap, a TTL, and a severity ceiling, all revocable, and no unbounded grant representable. It composes with the evidence object without changing it. Left out here to keep this reviewable.

Migration / Backward Compatibility

No breaking changes intended.

  • approver_evidence is optional on both surfaces. Omitting it preserves current behavior exactly.
  • A callback that returns a bare ApprovalStatus string keeps working, read as {"status": "<value>"}.
  • Schema version bump on ApprovalChainEntry only. No change to ActionBinding, PolicyDecision, ApprovalRequest, or ApprovalResolution.
  • Existing sealed entries keep verifying. An optional field that is absent changes no digest.
  • The timeout rule is the only change that constrains an existing implementation. A gateway with no timeout today has to define one.

If that last item needs a migration path rather than a flag day, #3106 already set the shape for one in this project: warn by default, enforce under an opt-in strict mode, let an environment variable act as a floor that a per-handler setting cannot loosen, and make strict the default in a later major. Reusing that pattern rather than inventing a second one seems right.

Scope

Cross-package (2-3 packages)

Target Placement

Core (agent-os, agent-mesh, agent-hypervisor)

Prior Art

Reference What it says Relationship
#2953 "defines linked approval evidence without requiring signed receipts in the first implementation" This is the deferred piece
#3097 "refuse body-supplied identities that are not backed by a verified assertion" Defines what a verified assertion is, so response_verifier has something to check
#2473 "approver_did — DID of the approving principal" The field exists. This proposes what would populate it verifiably
#3106 Deprecated timeout auto-approval as "unsafe at a governance boundary" The timeout rule here extends that position to the gateway spec, and its strict-mode pattern is the migration shape
#3507, #3508 A non-injective canonical string produced a real forgery Motivates the profile registry
#3668 Asks for reason, argument digest, and principal on audit entries Overlaps reason_code and the evidence field. Happy to fold into that issue instead if maintainers prefer
#3658 "A declared region string is unverifiable. Nothing binds it to evidence." Same distinction, applied to the approver. This RFC follows its structure and its honesty convention
#3805 Quotes draft-etcheverry-action-ref-03 §5.2: "The signer of the Decision record MUST be independent of the actor/executor whose action it authorizes [...] the signer of record must be independent BEFORE the outcome is final, not verifying it afterward." An approver assertion satisfies both halves by construction. The key sits with the person, not the operator's infrastructure, and the signature exists before execution
#3015, #3076, #3096, #3200 ADR-0030 implementation and SDK parity One optional field added to the model those ports share
#2363 The specification this amends

On #3668, the overlap is worth quoting, since it reaches the same problem from the audit side:

When an agent acts under delegated user identity, the target system may record the action as the human's.

Outside this repository, the relevant standards are W3C WebAuthn Level 3 for the assertion and its BE and BS flags, RFC 8785 JCS for canonicalization, which ADR-0030 already uses, and FIPS 201-3 with NIST SP 800-73 for the optional smart-card co-signature. Those are cited as credential and encoding standards, not as validation claims about any product.

Relationship to TRACE

The AgenTrust TRACE specification (agentrust-io/trace-spec, v0.2) binds workload identity, model, runtime measurement, policy hash, and tool transcript to a hardware root of trust, and verifies offline. Human approval is the one link in that chain with no root of trust. TRACE already defines External Execution Evidence, out-of-band receipts matched by linked_call_id and canonicalized per RFC 8785, and a webauthn.assertion/1 entry fits that slot unmodified. Both specifications already use JCS, so the digests line up. Noted because defining the object once here makes it portable instead of AGT-specific.

Checklist

  • I have searched existing issues and RFCs for duplicates
  • I have read the ADR index (adr/index.md) for related decisions
  • I am willing to implement this RFC or help review an implementation

Activity

  1. github-actions commented on Sep 9, 2026

    @github-actions

    Welcome to the Agent Governance Toolkit! Thanks for opening your first issue.
    A maintainer will review this shortly. Check our Contributing Guide.
    For security issues, use private vulnerability reporting.

  2. aetherxeg-source commented on Oct 2, 2026

    @aetherxeg-source

    We are working on ExecSurface (https://github.com/AETHERXGLOBAL/execsurface), an open-source runtime behavioral-integrity / execution-surface drift tool. This RFC already addresses a critical missing property: proving who approved a specific bound action rather than trusting a service-supplied identity.

    One additional boundary may be worth keeping explicit:

    "approved intent != executed realization"

    A valid WebAuthn assertion over "op_hash" proves that an enrolled approver consented to the canonical action represented by that digest. It does not by itself prove that the runtime later executed exactly that same materialized action.

    The gap can appear after approval through:

    • default injection;
    • schema normalization;
    • alias/resource resolution;
    • environment-derived parameters;
    • redirect or endpoint resolution;
    • policy transforms;
    • retry reconstruction;
    • adapter translation between protocol and execution surfaces.

    So I would preserve two separate bindings:

    1. "approver evidence -> approved action identity"
    2. "execution attempt -> executed action identity"

    and require equality or an explicitly verified transformation relation at execution time.

    Conceptually:

    "human assertion -> approved action digest -> execution-time materialization -> executed action digest -> runtime/effect evidence"

    A compact invariant is:

    "valid approval != proof of faithful execution"

    The RFC already mentions execution-time revalidation, which is the right location for this. The part I would make explicit in the conformance model is that revalidation should operate on the final execution-relevant representation, not merely the original approval request object.

    For example, if the approved object says:

    "payments.transfer(account=A, amount=40000)"

    but an adapter later resolves a symbolic alias, injects a destination, applies a transform, or changes a material field before invocation, the execution boundary should either:

    • recompute the action identity and require it to equal the approved identity; or
    • produce a separately verifiable transformation relation proving that the executed representation is semantically authorized by the approved representation.

    Silent semantic widening should fail closed.

    This also gives downstream runtime evidence a clean join point. An independent observer should be able to reference the executed action identity, while the approval record references the approved action identity, and a verifier can inspect the binding between them rather than assuming they are identical.

    That keeps the properties separate:

    "who approved?"
    "what exactly was approved?"
    "what exactly was executed?"
    "what effects were observed?"

    In ExecSurface we have found that collapsing these into one “authorized execution” record makes TOCTOU and adapter-drift failures difficult to detect.

    A useful conformance case might therefore mutate one material field only after the WebAuthn ceremony but before handler invocation and assert that execution is blocked even though the human assertion itself remains cryptographically valid.

    That would complement the RFC’s existing replay/substitution tests by checking the approval-to-execution seam itself.

  3. dvanostrand commented on Oct 2, 2026

    @dvanostrand
    Author

    Agreed on the invariant, and it is worth stating in the RFC rather than leaving it implied. A valid approval is not proof of faithful execution.

    I want to be precise about where the burden sits, because most of it is not on the approval evidence and the RFC should say so plainly rather than quietly implying it has the problem covered.

    Enforcement is the enforcement point's job. An approval component that is called at the escalate decision has no position in the dispatch path. It cannot stop anything. What it can do is return a verdict, and the host either honours that verdict at the right moment or it does not. Every mechanism in your list, default injection through adapter translation, lives on the host side of that boundary. So yes, this is substantially a comment about the enforcement surface rather than about approver evidence.

    What the evidence layer can be held to. Three things, and the RFC currently specifies only the first.

    1. A correct verdict on the representation it is shown. Verification step 4 recomputes op_hash from the fields present at execution, step 5 compares in constant time, and profile rule 5 makes a material field present at execution but absent from the signed set a hard failure. That part is already normative.

    2. A stated contract about which representation it must be shown. This is the gap you found. "Fields present at execution" does not say where in the pipeline the snapshot is taken, so an integrator can satisfy the letter of the check by recomputing before the last mutation. The specification should name the position: the final execution-relevant representation, after every transform, default injection, alias resolution and adapter translation, immediately before handler invocation. The single-use consume belongs at the same point, otherwise an approval is spent against one representation and executed as another.

    3. Detectability when the contract is broken. Which brings me to the part I think matters most.

    Binding prevents nothing. A signature is not a lock. Nothing in this proposal stops an adapter from changing a parameter after the ceremony. The entire value is that the change becomes discoverable by a party who does not trust the component that made it. Worth saying out loud, because "bound" invites the reading that the action is somehow immutable afterwards, and it is not.

    That reframes the design question usefully. The goal is not to prevent drift at the approval layer. It is to make sure drift cannot pass silently, including when the host that was supposed to revalidate did not.

    The construction that gets there. Let the approval travel to the resource, not only back to the host. If the verdict is issued as a signed, single-use, audience-bound artifact carrying op_hash, and the downstream resource verifies that artifact itself and recomputes the digest of the operation it is actually about to perform, then adapter drift between host and resource is caught at the resource. The host is no longer the only checkpoint, so a host that revalidates in the wrong place, or fails open, does not silently widen what executes.

    That has a real implementation cost worth naming: the canonicalization profile has to be resolvable resource-side, because the resource must canonicalize the same way the approval did. Profiles become a shared artifact rather than a host-local one.

    On the transformation relation. I would take equality and decline the verified-transformation path, for a reason that is self-consistent with the rest of this RFC. "Semantically authorized" is a predicate someone renders. Accepting it as a conformance path lets the party under evaluation assert that its own mutation was acceptable, which is the asserted-rather-proved defect the proposal exists to close.

    The simpler construction is ordering. Materialize first, then approve. The challenge is already minted when the approver opens the ceremony rather than when the request is created, which is deliberately late. Push every transform, default and alias resolution before that point and the approved representation is the final one, so equality is the only rule needed and there is nothing left to reconcile. It also fixes a display problem that the transformation-relation approach leaves open: if the action is modified after the human looks at it, whatever they were shown was not what ran, and no amount of post-hoc relation-proving recovers their consent.

    Where a host genuinely cannot materialize before approval, the honest answer is that the approval covers less than the operator thinks, and the specification should make that visible rather than smooth it over.

    Concrete changes.

    1. Normative placement for revalidation and for single-use consume, as in point 2 above.
    2. An executed_op_hash alongside the approved op_hash, which MUST be equal. Not inside the evidence object, which carries the approver's signed material. It belongs in the result or the chain entry, written by whoever observed execution, so the record shows the comparison was made rather than leaving a verifier to assume it.
    3. Your conformance case, as check 15: mutate one material field after the ceremony completes and before handler invocation, assert execution is blocked while the assertion itself still verifies. Distinct from the existing substitution check, which does not specify when the substitution occurs.

    Scope. Your four-way split is the right decomposition. This RFC covers who approved and what exactly was approved, and the changes above extend it to what exactly was executed. What effects were observed is a different instrument and does not belong here. The useful thing a specification can do for that layer is hand it a stable identity to reference, which is what executed_op_hash would be.

    The approval-to-execution seam is a better organizing idea than treating this as another replay variant. Thanks for the framing.

  4. aetherxeg-source commented on Oct 3, 2026

    @aetherxeg-source

    Agreed — that boundary is exactly where I would make the contract mechanically testable.

    The important refinement is that the evidence verifier should not merely consume “fields present at execution”; it should consume the final execution-relevant representation at the last point before the effect becomes irreversible.

    A useful conformance rule could be:

    "approved_representation == final_execution_representation"

    or, where normalization is permitted:

    "approved_representation --verified transform--> final_execution_representation"

    with every material transform included in the evidence chain.

    I would add a negative conformance test that:

    1. obtains a valid approval for representation A;
    2. performs a late default injection / alias resolution / adapter translation producing representation B;
    3. invokes the execution path without rebinding approval;
    4. verifies the operation is rejected before the handler/effect boundary.

    Then add the positive control where the final materialized representation is rebound and revalidated immediately before dispatch.

    That gives the host-side enforcement point a precise obligation:

    "no irreversible effect may occur unless the approval binding is valid for the exact final execution representation"

    It also keeps the boundary between approval evidence and enforcement clean: the evidence layer proves what was approved; the enforcement point proves that the runtime only acted on that bound representation.

    This is also the kind of execution/evidence boundary we are exploring in ExecSurface:
    https://github.com/AETHERXGLOBAL/execsurface

  5. 01men commented on Oct 21, 2026

    @01men

    The approved_representation == final_execution_representation rule covers the action. There is a sibling gap on the subject side that produces the same class of unverifiable record, and it appears earlier in the chain.

    We hit it in production. A token-to-principal resolver on our MCP endpoint returned a machine principal while dropping two identity fields (refType / refId) — the fields that say which agent or app the credential actually belongs to. Nothing about the request was malformed. Every subsequent authorization check passed, because the checks were against a principal that was internally consistent and simply less specific than the truth.

    The observable symptom was not a security event. It was that task-ownership resolution fell through: work got executed and claimed under no owner. The audit record showed the action, the decision, the outcome, and a principal — and the principal could not be resolved back to a real asset. The hash chain over that record computed correctly. It was faithfully recording an anonymised actor.

    Which is the mirror of the problem in §4.3. That RFC asks how to prove a human approved. This one asks how to prove the human was correctly identified in the first place. Both end with a cryptographically sound record whose semantic input was never validated.

    Two things I would want a conformance profile to assert, because both are cheap and both are invisible when broken:

    1. Principal round-trip closure. For every principal that reaches the enforcement point, assert that resolve(principal.id) → asset returns an entity with a type and a stable reference distinct from the credential. A principal whose only resolvable identity is the credential that presented it should fail closed, not proceed with reduced specificity. The failure mode we hit was a silently degraded principal — it looked valid, it just wasn't the actor. Absence of identity is loud; loss of specificity is silent, and that is the dangerous one.

    2. Delegation chains preserve the terminus. On-behalf-of and agent-acts-for-user flows have at least two identities in play. The record needs both, and needs to state which one the authorization was evaluated against and which one the effect is attributed to. If those can drift apart — evaluated against the agent, attributed to the user, or vice versa — the record is technically complete and interpretively ambiguous. We require the delegated terminus as a separate field from the credential principal for exactly this reason, and I would expect a profile to do the same rather than letting implementations collapse them.

    On the negative conformance test you proposed, worth adding one variant: obtain a valid approval for the correct representation and the correct action, but submit it under a principal that has since been narrowed in scope. The representation still matches; the authority no longer does. That case exercises whether the verifier re-resolves the subject at execution time or trusts the subject recorded at approval time — and most implementations I have looked at trust the record, because re-resolving feels like it duplicates the enforcement point's job. It is not duplication; it is the same reason the action representation gets re-checked.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    triageNeeds triage

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions