MetaMynd Agentic Governance Protocol (MAGP)
Specification — Version 1.0 Status: Stable · Date: 2026-08-20 · Editors: MetaMynd
A protocol for authorizing the actions of autonomous AI agents. An agent presents a signed description of what it is about to do; a verifier evaluates that action against the agent's identity, the authority delegated to it, and the policies in force; and returns a decision the agent must obey. Every decision produces evidence a third party can verify without trusting the verifier.
Status of this document
Version 1.0 supersedes the 0.x drafts. It describes what is built and running: every normative clause below corresponds to code in the reference implementation, and the order of checks in §8.5 is transcribed from the gate itself rather than designed on paper.
The 0.x drafts tagged individual clauses [IMPLEMENTED] or [PROPOSED]. That made sense
while the delta was large; it no longer does, and a document where most sentences carry a
maturity tag reads as provisional even when it is not. Clauses that were still unbuilt at
1.0 have been moved out of the normative text into Appendix D — Future directions. If
it is in the body, it exists.
Scope
MAGP is the interoperability surface. The test for inclusion is narrow and worth stating, because it is what keeps this document implementable:
A thing belongs in MAGP if an independent implementer needs it to reach the same verdict on the same request, or to verify the same evidence without access to the platform that produced it.
That test admits identity, the canonical signed message, the order of checks, reason codes, mandate and delegation semantics, policy-bundle distribution, and the evidence and proof formats. It excludes the governance surfaces a platform builds on top — supervisory access for regulators, sandbox programmes, incident routing, the trust graph, document ingestion. Those are real and specified in the companion document MetaMynd Platform Governance; they are not things a second implementation must reproduce to interoperate.
Conformance language
The key words MUST, MUST NOT, SHOULD, SHOULD NOT and MAY are to be interpreted as described in RFC 2119.
Table of contents
- Terminology
- Roles and entities
- Architecture
- Identity
- Authority: mandates, resource scope, delegation
- Policy artifacts
- Agent posture: containment, operating mode, reassessment
- The authorize protocol
- Secondary controls: rate limits and circuit breakers
- Escalation resolution
- Payment execution binding (x402)
- Effects and reconciliation
- Evidence and anchoring
- Decision records and the governance envelope
- Privacy-preserving verification
- Discovery and channels
- Security considerations
- Extensibility and versioning
- Conformance
- Reference endpoints
- Appendix A — Reason codes
- Appendix B — Canonical examples
- Appendix C — Atom catalog
- Appendix D — Future directions
1. Terminology
Agent — an autonomous software actor that proposes and performs actions. Identified by a DID.
Principal — the legal person or organisation on whose behalf an agent acts. The delegation root: the chain of authority terminates here, at a named legal entity whose verification status — and how it was verified — is reported on every read (§4.4).
Owner — the tenant account that administers an agent. Distinct from the principal: an owner may administer agents acting for several principals.
Mandate — the authority delegated to an agent, expressed as an ODRL policy and issued as a verifiable credential. Says which actions, within which constraints, for how long.
Standard — a published, versioned rule pack an organisation binds agents to. Enforced.
SOP — a standard operating procedure: an organisation's own rule pack, scoped to its agents.
Molecule — one rule within a Standard or SOP: a set of atoms (pure predicates over request context) and the decision to emit when they all hold.
Gate — the verifier. Evaluates a proposed action and returns a verdict. Server- authoritative: the agent does not decide, and a guard that evaluates locally reaches the same verdict from the same signed inputs.
Verdict — the gate's answer: a disposition plus a reason code (§8.6, Appendix A).
Evidence — the record of a governance decision, in a form a third party can verify independently of the platform that produced it.
2. Roles and entities
| Role | Holds | Responsibility |
|---|---|---|
| Principal | A legal identity, verified (KYC/KYB) before it may back a mainnet agent (§4.4) | Issues mandates; answerable for what its agents do |
| Agent | A DID and a private key | Signs and submits proposed actions |
| Issuer | Signing keys for credentials and bundles | Issues mandates; publishes and signs policy bundles |
| Gate | The evaluation logic and state | Returns verdicts; reserves budget; records evidence |
| Guard | A copy of the deterministic evaluator | Refuses locally; re-checks at a counterparty |
| Counterparty | Its own DID | May independently re-evaluate a caller's authority |
| Approver | A platform identity and role | Resolves escalations |
An MCP server is a counterparty that exposes tools. Where it holds its own DID and mandate it is simultaneously an agent, and this protocol treats it as one.
3. Architecture
Two planes.
The control plane issues and publishes: identities, mandates, standards, SOPs, signed policy bundles. It is not on the hot path.
The data plane is the authorize exchange. It MAY be executed:
- against the gate — the central verifier, which owns the stateful parts (nonce consumption, spend reservation, evidence);
- at the edge by a guard — deterministic evaluation against a signed policy bundle, with no round trip;
- at the counterparty — a trustless re-check, so an agent that skips its own guard still cannot get the counterparty to act.
All three MUST produce the same disposition from the same inputs. Where they cannot — because a check is inherently stateful — the edge MUST fail closed and defer (§8.5).
4. Identity
4.1 Agent DID methods
An implementation MUST support at least one of:
did:hedera— anchored on an HCS topic. The DID document is derived from the anchor; the agent's Ed25519 public key is the verification method.did:key— self-certifying. The Ed25519 key is the identifier; no anchor, no issuance ceremony, control provable from the identifier alone.
4.2 Proof of possession
4.2.1. An agent whose key was supplied by its holder (BYOK) MUST prove control before the gate accepts any action from it. The gate issues a challenge; the agent signs it; the gate verifies against the key in the DID and stamps the identity as proven.
4.2.2. Until control is proven the gate MUST deny with AGENT_KEY_UNVERIFIED. This is
not advisory: an unproven key means the signature check (§8.5 step 5) is meaningless,
because nobody has demonstrated that the key belongs to the party claiming it.
4.2.3. Where the platform generated the keypair and returned the private key once, control is established at issuance and no challenge is required.
4.2.4. Proof is per key. A DID minted by rotation or for a second network has its own keypair, so the gate MUST judge possession of the key the request is verified with — that DID's own proof — not the proof recorded for the agent's original key. (A network identity recorded before per-key proofs existed carries neither a proof nor a challenge; it keeps the agent-level proof.)
4.3 Resolution
4.3.1. A verifier MUST resolve the agent's public key from the DID rather than trusting any key material in the request.
4.3.2. GET /did/{did} returns a W3C DID document for both supported methods.
4.3.3. Identity rotation. Rotating an agent's identity on a network (key-loss recovery) replaces
its DID and key there. Mandates are bound to a DID, so every mandate active under the old DID is
reissued under the new one — same terms, and the same cumulative spend (the new mandate supersedes the
old) — and the old one is revoked as superseded (10.4.2); pending reviews do not migrate. A
delegated mandate is reissued delegated, re-validated against its delegator, or else revoked
(5.4.11). The rotation's response reports mandateReissue: reissuedCount, failedCount,
revokedCount and revoked (the delegated mandates revoked instead of reissued).
4.4 Principal assurance
4.4.1. Every mandate MUST name an authorizing principal, and an absent one is refused. Beyond that, how much the principal's verification matters depends on the network the agent is on, and this section states what the reference implementation does rather than a uniform rule:
- Testnet and
did:keyagents — verification is reported, not required. The principal's authority holds whether or not it is verified, and a sandbox or evaluation needs no KYC/KYB. The verdict and the registry carry the principal's status (4.4.2) so a counterparty applies its own risk policy. - Mainnet agents — the principal MUST be verified by a mainnet-eligible provider to
create the identity at all, and the principal's authority holds only within the free-tier
caps (1 mainnet agent per owner, 5 active mandates on the agent). Beyond either cap the
gate refuses with
PRINCIPAL_UNVERIFIED, a code whose name predates this model. A testnet ordid:keyagent is never refused with it.
The mainnet check is unconditional and precedes signature verification, because it is a question about the policy rather than about the requester.
4.4.2. The registry (GET /magp/manifest/{did}, GET /policy/mandate/{ref}/status) MUST
expose the authorizing principal's current verification status on every read, and every
authorize verdict MUST carry it alongside the decision — so a counterparty applies its
own risk policy to an unverified-backed mandate rather than assuming verification.
4.4.2a. The public Authority Manifest (GET /magp/manifest/{did}) MUST also expose the
agent's live containment (7.1) as a sibling of the manifest — containment: { status, since },
status one of suspended, quarantined, decommissioned, or null when the agent is active.
The manifest states the authority the agent was granted; containment does not change that, it
stops the agent using any of it, so a rendering of a contained agent's manifest MUST NOT present
its granted actions as currently permitted. The containment reason is the owner's and MUST NOT
be published. The manifest endpoint itself MUST NOT be served from a cache; a cached public
projection of it (a README badge) SHOULD reflect a containment within a minute: it SHOULD
read the manifest fresh and be cached once, briefly (the reference badge: 30 seconds), never
through two caches in series.
4.4.3. Verified does not mean identity-checked. The registry's principal block reports
not only verified but the basis of the verification — the weakest across the principals
behind the agent's active mandates:
basis |
Meaning |
|---|---|
provider |
An automated third-party provider verified the principal (KYC/KYB). |
manual_review |
A person on the platform's compliance team reviewed and decided. |
self_approved |
The platform approved the principal automatically (sandbox and beta self-serve). No identity check ran. Such a principal cannot create a mainnet agent. |
unrecorded |
Verified, but the record does not say by whom. Never upgraded to a stronger basis. |
A counterparty that needs a real identity check MUST require provider or
manual_review, not merely verified: true.
5. Authority
5.1 Mandates
5.1.1. A mandate is an ODRL policy carried in a verifiable credential. It names a target action and carries permissions and prohibitions, each with constraints.
5.1.2. Evaluation order within a mandate is: validity window, then prohibitions, then a matching permission whose constraints all hold. A prohibition that fires outranks any permission.
5.2 Constraints
A constraint is { leftOperand, operator, rightOperand }. Operators are a fixed set — new
scenarios add operands, not operators:
eq · neq · lt · lteq · gt · gteq · isAnyOf · isNoneOf · isPartOf ·
before · after
5.2.1. An unknown operator MUST fail closed.
5.2.2. Well-known operands map to stable reason codes so a denial names its cause:
mm:payAmount and mm:cumulativeSpend → SPEND_LIMIT_EXCEEDED; mm:merchant →
MERCHANT_NOT_ALLOWED; mm:route → ROUTE_NOT_ALLOWED; mm:counterparty →
COUNTERPARTY_NOT_ALLOWED; mm:jurisdiction → JURISDICTION_REQUIRED when the request has no
effective jurisdiction, JURISDICTION_NOT_ALLOWED when it has one outside the list (§5.2.3). Any
other operand denies with CONSTRAINT_FAILED:<operand>.
5.2.2a. A cumulative cap is per currency. Its limit is a bare number, so the committed spend it is checked against MUST count only holds in the request's currency (compared case-insensitively); summing two currencies has no meaning without a rate, and none is applied. A hold with no recorded currency counts against every currency, so the check fails closed. This changes nothing for a single-currency mandate; for one that allows several currencies (or any), the cap applies to each separately.
5.2.3. Allowed jurisdictions. A mandate MAY restrict the jurisdictions an action may be
performed in. Issuance (POST /policy/mandate) takes an optional allowedJurisdictions: a list
of ISO 3166-1 alpha-2 codes, trimmed, upper-cased, de-duplicated and sorted; the group code EU
expands to the 27 member states. An empty or malformed list is refused (400 MALFORMED_REQUEST); omitting it means no restriction. The term is stored as an ordinary
constraint on the permission:
{ "leftOperand": "mm:jurisdiction", "operator": "isAnyOf", "rightOperand": ["DE", "FR", "SG"] }
- It is judged on the request's effective jurisdiction (§8.3.12) — the country the owner
registered for the payee, else the signed top-level
jurisdiction— never on an itinerary value. With no effective jurisdiction the request is blockedJURISDICTION_REQUIRED; outside the list,JURISDICTION_NOT_ALLOWED. GET /policy/mandate/{ref}/statusreturns it asallowedJurisdictions(null= no restriction).- A reissue (identity rotation, resource-scope pin, payload-binding toggle) MUST carry the
term. A jurisdiction constraint that cannot be read back as an allow-list (a hand-authored
isNoneOf, say) refuses the reissue of that mandate rather than dropping the restriction. - A delegated mandate inherits or narrows it (§5.4.5).
- The zero-knowledge path proves membership in the same list (§15.1.2).
5.3 Resource scope
5.3.1. Authority over actions does not answer which systems and data an agent may
reach. A resource scope is expressed as an ordinary constraint over the resource operand,
which the request declares in its context:
{ "leftOperand": "resource", "operator": "isAnyOf",
"rightOperand": ["crm.contacts", "ledger.postings"] }
5.3.2. The resource is part of the signed message (§8.3): it is cryptographically
committed the same way merchant already is (and a top-level jurisdiction is, §8.3.12), not
merely asserted the way tool or an itinerary value is. A counterparty relaying a buildSignedRequest()-built request cannot
substitute a different resource without invalidating the signature.
5.3.3. An action that declares no resource, evaluated against a mandate carrying a resource constraint, MUST be denied: an action that will not say what it touches cannot be checked against a scope.
5.4 Delegation
5.4.1. An agent MAY delegate a narrowed subset of its own authority to another agent. The delegated mandate is an ordinary mandate; nothing downstream special-cases it.
5.4.2. Narrowing invariant. A delegated mandate MUST NOT widen its parent. Narrowing is asymmetric per operator, and an implementation that gets this wrong grants authority nobody issued:
| Operator | Narrower means |
|---|---|
lteq, lt |
a lower ceiling |
gteq, gt |
a higher floor |
isAnyOf, isPartOf |
a smaller allow-list (child ⊆ parent) |
isNoneOf |
a larger deny-list (child ⊇ parent) |
before |
an earlier deadline |
after |
a later start |
eq |
the identical value |
5.4.3. A parent constraint the child omits is a widening, not a simplification. A child that drops its parent's spend cap holds more authority than its parent.
5.4.4. A child MUST NOT outlive its parent's validity window, and MUST retain every prohibition the parent carried.
5.4.5. Constraints the parent did not have MAY be added freely: they can only remove
authority. The same holds for the permission-level settings that are not constraints: a child
MUST NOT lower or drop its parent's riskTier (§6.3), nor drop its parent's
requirePayloadBinding (§8.3.9), and a value of either that is not a recognised one is refused
rather than read as "not set".
- Allowed jurisdictions (§5.2.3). A child that states no
allowedJurisdictionsinherits its parent's list, and the implementation MUST store it on the child explicitly (so the child's own document carries the restriction, and a later change to the parent cannot widen it). A child that states a list has it normalised as at issuance (EUexpanded) and it MUST be a subset of the parent's (isAnyOfnarrowing, 5.4.2) — otherwise the delegation is refusedCONSTRAINT_WIDENED(operandmm:jurisdiction). A child of an unrestricted parent MAY add a list. A reissue of a delegated mandate carries its list and re-checks it against the parent's current terms (5.4.11): a list no longer within the parent's isDELEGATION_NARROWING_FAILED.
5.4.6. The chain MUST terminate at a bounded depth (reference implementation: 3). Depth SHOULD be stored rather than derived by walking, so the bound is checkable without following the chain and a cycle cannot hang a verifier.
5.4.7. The delegation root MUST remain the original principal at every depth, so an accountability walk from any descendant terminates at the principal that issued the root mandate — a named legal entity, whose verification status and basis (§4.4.3) are reported.
5.4.8. Narrowing SHOULD be verified at issuance. A stored child that has been proven no wider than its parent costs nothing extra to evaluate.
5.4.9. Revoking a mandate revokes everything delegated from it. A child holds authority only
because its parent did, so revoking a mandate — directly (POST /policy/mandate/{ref}/revoke), as
a chain (POST /policy/mandate/{policyId}/revoke-chain), or by decommissioning its agent —
MUST revoke every mandate delegated from it, at every depth, and each one MUST be revoked
exactly as a directly revoked mandate is (8.7.15): made inactive, its pending reviews closed
(10.4.2), its unclaimed holds released. A revoke that only deactivated the delegated mandates would
leave their holds claimable. This holds for a reissue too (identity rotation, resource-scope pin,
payload-binding toggle): the superseded mandate's own reviews close MANDATE_SUPERSEDED, but
nothing was delegated from the new mandate, so every mandate delegated from the old one is
revoked — its reviews close MANDATE_REVOKED — and the delegator re-delegates if it still
wants to. (A reissue could otherwise leave a child wider than its new parent.)
5.4.10. A delegated mandate is only as live as its chain. Independently of the cascade, a gate
MUST refuse to use a delegated mandate unless every mandate it was delegated from, up to the
root, is active — at authorize, at an escalation approval or a reviewer's MODIFY (10.4.1), and on a
claim or a capture of an unclaimed hold under it (8.7.15): MANDATE_REVOKED when an ancestor is
inactive, and DELEGATION_CHAIN_BROKEN when the chain cannot be walked to a root (a parent that no
longer exists, a cycle, or a chain deeper than the bound of 5.4.6). The walk MUST be bounded and
cycle-safe (the reference implementation: one recursive query, at most the depth bound, never
revisiting a mandate).
5.4.11. A reissued delegated mandate stays delegated. When a delegated mandate is reissued — the
delegate's identity is rotated (4.3.3), a resource scope is pinned into it, its payload-binding
requirement is switched — the new mandate MUST keep the delegation: the same parent (and
delegating agent), the same depth, the delegator's tenant as its owner and the same root principal
(5.4.7). Its terms are the delegated mandate's own terms with only that change applied, not a document
rebuilt from what an ordinary mandate can express. Before it is issued it MUST be re-validated as a
delegation is at issuance: every mandate above it active (5.4.10), and the changed terms a narrowing
(5.4.2–5.4.5) of the parent's current terms at the same depth. The failure codes are
MANDATE_REVOKED (an inactive ancestor), DELEGATION_CHAIN_BROKEN (a chain that cannot be walked to
its root) and DELEGATION_NARROWING_FAILED (a widening, with the refusal and operand), each reported
as { policyId, reasonCode, detail }. If either check fails, nothing is issued, and:
- an owner's change — a resource pin, a payload-binding toggle — MUST be refused, and nothing
changes: the delegated mandate stays active, unrevoked, as the agent's authority. The refusal is
all-or-nothing across the agent's mandates: every delegated mandate is checked before anything is
written, and if any fails, none is reissued. The answer is
409in the standard refusal body — the code asmessage,data.reasonCode(the first failing mandate's code),data.detail, anddata.refusedlisting every failing mandate; - an identity rotation MUST instead revoke the delegated mandate (8.7.15) with that code —
the old DID is gone, and the mandate may not be carried forward as ordinary authority — and report
it in
mandateReissue.revoked(withrevokedCount).
Issuing checks, in the same transaction as the insert and under a
lock on both rows, that the parent and the mandate being replaced are still active, so a reissue that
races a revoke of its delegator either lands before the revoke (and the revoke's cascade reaches it) or
is refused MANDATE_REVOKED. A reissue is never a way out of a chain: carried forward as an ordinary
mandate, it would escape both the revoke cascade (5.4.9) and the chain check (5.4.10).
Up to v1.69.2 the chain revoke deactivated the delegated mandates and closed their reviews but left
their unclaimed holds held — still claimable and capturable, their budget still counted — and a
direct revoke of a delegator did not reach its delegated mandates at all. Revoking the delegator
again (or its chain) finishes such a revoke: an already-inactive mandate is still swept. The
reference implementation also finishes them itself, once, at start-up (8.7.15).
Up to v1.69.2 a reissue of a delegated mandate came out an ordinary one: no parent,
depth 0, owned by whoever reissued it (for a rotation, the delegate's tenant) — so after a delegate
rotated its key, revoking the delegator no longer reached the delegate's authority, which kept
authorizing. At start-up the reference implementation finds every active mandate without a parent
whose reissue lineage (supersedes) leads back to a delegated mandate, and re-links it (parent,
depth, delegating agent, owner) when its chain is live, it has no delegated mandates of its own, and
its terms narrow both the delegated mandate it replaced and the parent's current terms; otherwise it
revokes it with the reason above. A reissue made before the lineage was recorded (before v1.68.0)
cannot be traced and is not found.
6. Policy artifacts
6.1 Standards and SOPs
6.1.1. A rule pack is a set of molecules. A molecule fires when all its atoms hold, and emits its declared decision and reason code.
6.1.2. Atoms are pure predicates over request context — no clock, no network, no model. Determinism is what allows the edge and the gate to agree. The catalog is Appendix C.
6.1.3. Across all molecules of all bound packs the most restrictive decision wins.
6.2 Signed policy bundles
6.2.1. GET /policy/bundle/{did} returns the agent's evaluable policy, signed by the
issuer. GET /magp/policy/pubkey publishes the verification key.
6.2.2. A guard MUST verify the signature before evaluating, and MUST reject a bundle whose signature is invalid for any action. A guard holding a pinned verification key MUST also reject a bundle whose signature is absent, for any action — an amount-0 action included. The issuer never serves an unsigned bundle, so under a pin one can only have been stripped in transit, and an amount-0 action is not necessarily a read: a non-financial agent's every action carries no amount.
6.2.3. Bundles carry a maxStaleness. A value-bearing action MUST fail closed on an
unsigned, tampered or stale bundle (POLICY_BUNDLE_UNSIGNED,
POLICY_BUNDLE_SIGNATURE_INVALID, POLICY_BUNDLE_STALE). A guard holding a pinned key
MUST fail closed on a stale bundle for any action, since the issuer re-issues every copy it
serves and a stale one is a replay of authority that may since have been withdrawn. Only a guard
with no pinned key (6.2.4) MAY let an amount-0 action proceed on an unsigned or stale bundle.
6.2.4. A guard that has no pinned verification key cannot perform 6.2.2 and is trusting the
transport instead. It MUST NOT permit a value-bearing action on a bundle that arrived
over an unauthenticated transport (plain http://) — POLICY_BUNDLE_UNVERIFIED — unless its
operator has explicitly opted out for local development. Over TLS it SHOULD warn that the
bundle is trusted on the strength of TLS alone.
6.2.5. maxStaleness bounds the age of the copy a guard holds, measured from the bundle's
issuedAt. The issuer therefore MUST serve a bundle whose issuedAt is the time it is
served, signed at that time — not the time its rules were last compiled, which can be arbitrarily
old for rules that have not changed. A compiled bundle is re-issued on every fetch: issuedAt is
the serve time, and compiledAt and compiledSignature (the compile's own signature, the one
anchored on the agent's topic as policy-update.sigDigest, §5.3.1) are carried inside the
re-signed content. The compiled bundle is recovered by setting issuedAt to compiledAt, removing
those two fields and verifying compiledSignature over the result; a guard confirming the bundle
is the latest anchored one compares the anchor with sha256(compiledSignature), or with
sha256(proof.signature) for a bundle that carries no compiledSignature.
6.2.6. Revoked actions. A bundle MAY carry revokedActions: the actions the agent was
granted a mandate for and no longer holds one for, every mandate for them having been revoked. It
is part of the signed content, and the issuer omits it when it would be empty. A guard that finds
no mandate for an action listed there MUST refuse it MANDATE_REVOKED rather than
NO_MANDATE or NO_PERMISSION_FOR_ACTION, as the gate does (§8.5): the authority was granted and
withdrawn, which is not the same fact as never granted. The field names the refusal and never
decides it, since the action is refused either way. A guard reading a bundle without it refuses as
before. An action granted again is in mandates and is not listed.
6.2.7. Owner. A bundle carries ownerPrincipal: the public DID of the principal that owns the
agent — its organisation principal, else its owner's oldest verified principal — signed with the
rest, and omitted only when the agent has none (it then cannot act: the gate requires a verified
principal). It is how a Service bound to the owner of its credentials (16.3) checks that an
admitted agent belongs to that owner. It never carries the platform's internal owner identifier.
6.3 Context provenance
6.3.1. The context the atoms judge is assembled from sources of very different trust, and a rule that judges what the agent said about itself has judged nothing. A signature does not help: it says who asserted a value, not that it is true, and the counterparty re-check reads the same claim. So every context field MUST carry a provenance — where its value came from — attached only by the party assembling the context, never by the request:
| Provenance | Source | Rank |
|---|---|---|
agent_asserted |
the agent's own unsigned context (itinerary) |
0 |
agent_signed |
covered by the agent's signature: attributable, not verified | 1 |
gateway_derived |
derived by the authenticated counterparty from the real request | 2 |
authoritative |
derived by the issuer (spend, call count, trust) or set by the mandate's owner | 3 |
attested |
backed by a verified attestation or credential | 4 |
An unlabelled field is agent_asserted: no context, however it was built, reads as more
trusted than the agent's own word. An agent cannot set a label — provenance is not part of
any request, and a value the agent sends under a key named "provenance" is just another
unsigned field.
6.3.2. Assembly. Sources are applied in order of trust, each later source winning a collision: unsigned < signed < gateway-derived < server-derived. This is §17.2 extended to the two derived sources.
6.3.3. Risk is a floor, never a ceiling. riskLevel is an ordered, restrictive field, so
it is merged, not overridden: the effective value is the maximum of every trusted source
and the agent's own claim. Trusted sources are the mandate owner's riskTier on the
permission (authoritative), a value the gateway derived from the request (gateway_derived)
and a server-derived one (authoritative). An agent may raise its stated risk; it can never
lower it below what a trusted source says. The effective value takes the provenance of the
most trusted source present. A trusted source that states an unusable riskLevel has said
nothing: it neither overwrites the agent's claim nor lends it the trusted source's label.
riskTier is part of the signed mandate, so the gate and every guard apply the same floor,
and a delegated mandate (§5.4) MUST NOT drop or lower its parent's tier — a lower
tier is less scrutiny, a widening — nor carry a tier that is not a recognised level (refused,
never read as "no floor"). riskLevel is read case- and whitespace-insensitively ("HIGH "
is high); the levels are low, medium, high, critical.
6.3.4. Fail closed on the atoms that judge risk. A molecule containing a
risk-at-or-above atom MUST NOT pass when riskLevel is missing, or is present but not
one of the four levels: it MUST decide at least escalate, with reason code
CONTEXT_UNVERIFIABLE, whatever its combinator (a none rule must not read absence as
"nothing wrong"). If the molecule fires on its own, its own decision and reason code stand
(an observe is raised to escalate). This is deliberately conservative: a molecule that
could not be judged escalates even if another of its atoms would have kept it from firing —
the implementation does not try to prove the rule could not have fired. An implementation's
scenario simulation MUST report the same outcome as the gate, or it would show a control
as "not fired" where the gate holds the request.
6.3.5. Policies declare the trust they need. A molecule MAY declare
requireProvenance: { field: level }. For each named field the molecule needs a well-formed
value whose provenance is at least level; otherwise it decides at least escalate
(CONTEXT_UNVERIFIABLE). An unknown level is read as the strictest (attested), and a
document declaring one is rejected at authoring, so a typo can only make a rule refuse more.
A molecule that declares nothing behaves exactly as before, except as 6.3.4 requires.
6.3.6. Counterparty derivation. A guard SHOULD let the service supply
trustedContext — context the service derived from the real call, never from the agent —
which is applied over the agent's claim and labelled gateway_derived. A trustedContext
the service configured but that yields nothing usable — not an object, or a riskLevel that is
not a level (a lookup that missed and returned nothing) — means its deriver is broken, and the
request MUST be refused; it MUST NOT fall back to the agent's word. The operating-mode
gate (§7.2) judges the effective risk, so an agent in supervised mode cannot skip the
high-risk escalation by claiming low.
6.3.7. What this does and does not establish. It closes omission, garbling and
understating where a trusted source exists. It does not make an agent's claim true: with no
owner riskTier, no gateway derivation and no requireProvenance, riskLevel is still the
agent's word — only hiding or garbling it is now refused. The other agent-supplied fields
(tool, model, consent, …) keep their documented absent-field behaviour
(Appendix C); a rule that must not depend on the agent's word about them declares
requireProvenance. jurisdiction is no longer one of them: the gate enforces only the
effective jurisdiction of §8.3.12 (registered payee country, else the signed field), and an
itinerary jurisdiction is ignored.
7. Agent posture
Three per-agent states that bias or override evaluation. All are gate-side facts; a guard learns them through the bundle.
7.1 Containment
7.1.1. A contained agent is denied all actions until reinstated — AGENT_SUSPENDED,
AGENT_QUARANTINED, or AGENT_DECOMMISSIONED. Containment is checked before any
action-specific evaluation.
7.1.2. Containment MAY be applied automatically by a firing rule (suspend/quarantine),
or manually by an owner (suspend/quarantine/decommission). Reinstating a suspended or
quarantined agent restores gate access, but not mandates or SOP assignments revoked meanwhile.
Decommission retires the agent: it revokes every active mandate and unassigns every SOP, and
it is final — an implementation MUST NOT reinstate a decommissioned agent, nor lower its
containment to suspended or quarantined (which would make it reinstatable); such a request is
refused AGENT_DECOMMISSIONED (HTTP 409). Its work is taken over by a newly provisioned agent.
7.1.3. "All actions" includes the ones already authorized: an unclaimed hold of a contained agent cannot be claimed or captured, and is voided, so a reinstatement never revives an authorization granted before the containment (8.7.17).
7.2 Operating modes
7.2.1. Four rungs, least to most autonomous: read_only · restricted · supervised ·
autonomous.
7.2.2. read_only denies any value-bearing action up front (MODE_READ_ONLY).
7.2.3. restricted and supervised do not deny. They impose an escalate floor
applied after deterministic evaluation: an action the rules would otherwise permit is
routed to a human instead. A rule-driven block or escalate already outranks the floor, so
applying it earlier would only mask a more specific reason.
7.2.4. The effective rung is the operator's manual pin when set, otherwise the trust-derived rung. Mode changes SHOULD be audited.
7.3 Reassessment
7.3.1. A material change to an agent's authority (for example a large increase in a payment
cap) invalidates its current assurance. A flagged agent's value-bearing actions escalate
with REASSESSMENT_PENDING until an owner clears the flag.
8. The authorize protocol
8.1 Endpoint
POST /policy/mandate/authorize (PUBLIC, unauthenticated)
POST /policy/mandate/authorize/{authorizationId}/capture
POST /policy/mandate/authorize/{authorizationId}/void
The gate is public by design: any counterparty may check any agent's authority. Authority to act comes from the mandate, not from an API key.
8.2 Request
{
"agentDid": "did:hedera:testnet:...",
"action": "flight-purchase",
"amount": 150,
"currency": "USD",
"merchant": "skyward-air",
"resource": "crm.contacts",
"jurisdiction": "SG",
"itinerary": { "tool": "book-flight", "riskLevel": "low" },
"nonce": "b1c2…(8–128 chars)",
"issuedAt": "2026-07-10T05:12:00Z",
"signature": "<hex Ed25519>"
}
merchantandresourceare top-level signed fields (§8.3.1), each optional and each the empty string in the signed message when absent.resourcenames what the action touches and is checked against a resource-scoped mandate. The signed values are applied last, so they always take precedence over a same-named key in the unsigneditinerary: a resource placed there cannot satisfy a resource scope.jurisdiction(optional, ISO 3166-1 alpha-2) is a top-level signed field too; a request that carries it is signed over the v2 message (§8.3.12). It is the only jurisdiction the gate enforces — one placed in theitineraryis ignored.itinerarycarries request context — the atom inputs beyond the signed core. It is unsigned: every value in it,riskLevelincluded, is an agent's own claim and is treated as one (§6.3).amountandcurrencymay be omitted together for a non-financial action; a mandate that carries a spend constraint still requires a real amount.nonceis single-use;issuedAtbounds freshness.- An optional
traceobject correlates a multi-step workflow.
8.3 Canonical signed message
8.3.1. The signature MUST be an Ed25519 signature over the UTF-8 string formed by
joining these eight fields, in this order, with | (U+007C), substituting the empty string
for an absent merchant or resource:
agentDid | action | amount | currency | merchant | resource | nonce | issuedAt
8.3.2. Verifiers MUST reconstruct the message from the fields received, never trust a
client-supplied message, and verify against the key resolved from agentDid.
The three clauses that follow exist because each fails as SIGNATURE_INVALID and as
nothing else — a well-formed request, a correct key, and bytes that simply differ from the
ones the verifier rebuilds.
8.3.3. Private key encoding. Managed provisioning issues the key as hex-encoded DER
PKCS#8 (ASN.1 SEQUENCE tag 30), not a raw 32-byte seed. An implementation SHOULD
accept either and MUST NOT assume the raw form.
8.3.4. Number stringification. amount is signed as text, so its rendering is part of
the protocol. The canonical rendering is ECMAScript String(n): an integral value carries
no fractional part (150.0 → "150"). Languages whose default float formatting keeps a
trailing .0 MUST normalise. The disagreement is not only the trailing .0: outside
1e-6 ≤ |n| < 1e21 ECMAScript uses exponent form with its own spelling (5e-7 is "5e-7",
1e21 is "1e+21"), and below 1e-4 a language's default may already differ — Python's
repr(0.00005) is "5e-05" where ECMAScript writes "0.00005". A verifier reconstructs
this text from the JSON number it parsed, so an integer beyond 2^53 is signed as the
double a JSON parser holds, not as its exact digits. Values ≥ 1e21 SHOULD NOT be sent.
8.3.5. Field identity. Every field is signed as the literal string transmitted. Any
RFC 3339 rendering of issuedAt a verifier can parse is acceptable, but the signed and
transmitted renderings MUST be byte-identical. Formatting a timestamp twice — once for
the message, once for the body — is the common violation, and at second precision it fails
only when a second happens to tick between the two calls.
8.3.6. A verifier MAY attach a non-normative hint to a SIGNATURE_INVALID verdict
naming §8.3.3–§8.3.5 as usual causes. The hint is diagnostic only and MUST NOT enter
the digested decision content.
8.3.7. A verifier MAY distinguish a request that verifies against a prior canonical
message shape (e.g. the seven-field message from before resource existed) from a
genuinely invalid signature, returning CLIENT_PROTOCOL_VERSION_UNSUPPORTED instead of
SIGNATURE_INVALID — still refusing the request either way, but naming the real cause
(an outdated client) rather than implying a broken or rotated key.
8.3.8. Conformance vectors. docs/protocol/authorize-vectors.json publishes known-answer
vectors for the signed message: for each, the eight input fields, the exact message, and the
Ed25519 signature a fixed public test key produces over its UTF-8 bytes (Ed25519 is
deterministic, so every implementation must reproduce the same bytes). The vectors cover the
cases that have each broken a client — an integral float (150.0 → 150), a fractional
amount, a sub-cent amount (0.00005), the exponent forms (1.5e-7, 1e+21), an integer
beyond 2^53, an amount near 1e21, an absent merchant and resource, resource alone, a
literal | and \ in a field, and non-ASCII text. An implementation SHOULD reproduce every vector
before it is trusted with a real request. The reference implementation checks them in its own
test suite, and so does the reference Python client, against the same file: a message format
that changes in one place and not the other fails a test instead of shipping — which is what
happened when a client signed seven fields against a gate that required eight.
8.3.9. Payload binding (optional). The eight signed fields do not cover a payee, an account
number, a passenger list, a route — every other field a real tool takes. A gateway can compare
amount, merchant and resource in what it forwards, but a field the signature never
mentioned is either refused (the tool cannot be used) or allowed and unbound. An agent that
wants the WHOLE payload held to what it authorized signs a digest of it:
payloadDigest = "sha256:" + lower-case-hex( SHA-256( UTF-8( canonical(payload) ) ) )
payloadSignature = Ed25519( UTF-8( MAGP-PAYLOAD-v1 | agentDid | action | nonce | issuedAt | payloadDigest ) )
canonicalis RFC 8785 (JSON Canonicalization Scheme): object keys sorted by UTF-16 code unit, no insignificant whitespace, ECMAScript number and string serialisation (§8.3.4 applies to every number in the payload). The input is JSON.undefined,NaN,Infinity, functions, BigInt and an unpaired surrogate are refused, not normalised: two languages would normalise them differently, and a digest they compute differently is a binding that silently never matches. A payload nested deeper than 32 levels or larger than 256 KiB once canonical is refused too. The digest is of the JSON the executing side RECEIVES, so a value a language holds differently from JSON (a Python250.0, an integer beyond 2^53) is digested as the JSON number it becomes.- The fields are escaped exactly as in §8.3.1, and the message is domain-separated from the
§8.3.1 message and from the service messages of §8.7.6: a signature made for one purpose
cannot be replayed for another. It names the agent, action, nonce and
issuedAtof the authorize request it accompanies, so a digest cannot be lifted from one authorization onto another. payloadDigestandpayloadSignatureare two optional top-level request fields, both or neither. They are not a ninth field of §8.3.1, which every existing client signs as eight; a request that carries neither is exactly what it always was.- A verifier MUST, once the §8.3.1 signature verifies (check 5, §8.5), verify the
payloadSignatureunder the same key, and MUST block a request whose binding is present but not valid — a digest with no signature, a signature with no digest, a digest that is notsha256:and 64 lower-case hex, or a signature that does not verify — withPAYLOAD_BINDING_INVALID. A digest nobody signed binds nothing, so it is refused, never stored. - The verifier MUST store the digest with the hold, and with an escalation when the action is held for a human, so that an approved hold keeps it.
- Acknowledgement. An allow, observe or escalate verdict for a request that carried a binding
MUST state the digest the authorization is bound to (
data.payloadDigest,nullfor an unbound one), outside the digested decision content. The binding is not covered by the §8.3.1 signature, so a hop that strips the two fields would otherwise leave the agent believing an unbound authorization is bound, as would an issuer that predates this section and ignores fields it does not know. An agent that sent a digest MUST treat a permit or escalation whosepayloadDigestis not the digest it sent — including an absent one — as not bound: refuse it (PAYLOAD_BINDING_NOT_CONFIRMED) and release the hold it was given. - A reviewer's MODIFY decision (§10) changes the action the agent signed, so the hold it mints —
or the one minted when the re-entered review is approved — carries no digest at first: it is
unbound until the agent binds the payload of the action that WILL run (§8.3.11). An executor
that requires binding does not claim it before then; one that does not require it would run the
modified action without the guarantee. The status a polling agent reads for such a review
reports whether the hold it was handed carries a digest (
payloadBound, a boolean and never the digest itself, present only when there is a hold), so the agent knows it still has to bind. - Owner requirement. A mandate's permission may carry
requirePayloadBinding: true(issued with the mandate, carried in the signed mandate, never dropped by a delegation, §5.4). For such an action the verifier MUST block a request that carries no binding — after the §8.3.1 signature verifies, so only the agent learns the setting — withPAYLOAD_BINDING_REQUIRED, and MUST refuse to grant a claim (§8.7.11) on a hold that carries no digest, with the same code, until the agent has bound one (§8.3.11). Without it, binding stays the agent's or the executor's choice: an agent, or a hop that strips the two fields, can leave a hold unbound. - The comparison that gives the binding its force is made at the claim (§8.7.11).
8.3.10. Payload-binding vectors. docs/protocol/payload-binding-vectors.json publishes
known-answer vectors for §8.3.9: canonical forms and digests (key order, whitespace, numbers in
every exponent form, integers beyond 2^53, string escapes, non-ASCII text, key order beyond the
Basic Multilingual Plane), inputs that MUST be refused, and complete bindings — message and the
Ed25519 signature a fixed public test key produces over it. The reference implementation checks
them in its test suite, and so does the reference Python client, against the same file. The
same file holds the vectors for §8.3.11.
8.3.11. Binding a payload to a hold that already exists. A hold can exist with no digest: a reviewer's MODIFY changes the action the agent signed (§8.3.9), so the hold minted for the modified action has none. The agent binds the payload of the action that will run with
POST /policy/mandate/authorize/{authorizationId}/payload-binding
{ agentDid, action, nonce, issuedAt, payloadDigest, payloadSignature }
payloadSignature = Ed25519( UTF-8( MAGP-PAYLOAD-REBIND-v1 | agentDid | action | authorizationId | nonce | issuedAt | payloadDigest ) )
The endpoint is public: the agent's signature is the credential, exactly as for /authorize.
- The message is the §8.3.9 message with the authorization id added, under a domain of its own
(
MAGP-PAYLOAD-REBIND-v1, escaped as §8.3.1). It names the hold, so a digest cannot be lifted onto another one, and no signature made for one of the two messages can verify as the other. - The verifier MUST check that the hold exists; that
payloadDigestissha256:and 64 lower-case hex; and thatpayloadSignatureverifies under the keyagentDidresolves to — and only THEN say anything about the hold: that it was issued toagentDidand (when its mandate names an action) toaction, so an unauthenticated caller cannot probe a hold's owner or action by authorization id. It MUST then check thatissuedAtis fresh and consumenonceonce, in that order, so a forged request cannot burn a victim's nonce. - An agent that is contained (suspended, quarantined or decommissioned, §7.1) is refused
with its containment code (
AGENT_SUSPENDED,AGENT_QUARANTINED,AGENT_DECOMMISSIONED,403): it is denied everything at/authorizeand does not get to keep arranging what its existing holds will execute. Checked once the signature has proven it is that agent asking, so a stranger learns nothing about the agent's status. - It MUST apply the digest with one conditional update that changes the hold only while it
is live (
held, and inside the same hold window a claim is held to), unclaimed and carrying no digest. That single statement is what makes the binding atomic against a claim and against a second binding. A digest is never overwritten. - The same digest already on the hold is an idempotent success (
200,alreadyBound: true): a retry of a binding whose response was lost — but only for a hold that is otherwise still live; a voided or lapsed hold is reported as such first. A different digest is refused. - The response echoes the digest it now holds (
data.payloadDigest). An agent MUST treat a success that does not echo the digest it sent — an issuer that predates this clause — as not bound.
| Refusal | HTTP | Meaning |
|---|---|---|
AUTHORIZATION_NOT_FOUND |
404 | no such hold |
AUTHORIZATION_AGENT_MISMATCH · AUTHORIZATION_ACTION_MISMATCH |
403 | the hold is another agent's, or for another action |
PAYLOAD_BINDING_INVALID |
403 | a malformed digest of up to 80 characters, an unresolvable key, or a signature that does not verify (a non-string or longer payloadDigest fails request-schema validation with 400) |
AGENT_SUSPENDED · AGENT_QUARANTINED · AGENT_DECOMMISSIONED |
403 | the agent is contained (checked after the signature) |
REQUEST_EXPIRED |
403 | issuedAt outside the freshness window |
REPLAY_DETECTED |
409 | the nonce was already used |
PAYLOAD_ALREADY_BOUND |
409 | the hold already carries a different digest |
AUTHORIZATION_ALREADY_CLAIMED · AUTHORIZATION_VOIDED · AUTHORIZATION_EXPIRED |
409 | the hold is no longer unclaimed and live |
A hold that was bound at authorize time cannot be re-bound. Binding does not change what the hold authorizes (amount, merchant, action): it only names the payload the agent is willing to have executed under it.
8.3.12. Signed jurisdiction (message v2). A request MAY carry a top-level jurisdiction: an
ISO 3166-1 alpha-2 code, sent upper-case. A request that carries it MUST be signed over the v2
message — the eight §8.3.1 fields, then the literal version tag MAGP-AUTH-v2, then the
jurisdiction exactly as transmitted, ten fields, each escaped as in §8.3.1:
agentDid | action | amount | currency | merchant | resource | nonce | issuedAt | MAGP-AUTH-v2 | jurisdiction
- A request that does not carry it is signed over the §8.3.1 message, byte for byte as before: every existing client keeps verifying.
- A verifier MUST choose the message from the request itself —
jurisdictionpresent → v2, absent → v1 — and MUST NOT try the other. Adding the field to a v1-signed request, changing it, or stripping it from a v2-signed one therefore failsSIGNATURE_INVALID. A present value that is not two ASCII letters is refused (MALFORMED_REQUEST). - The effective jurisdiction of a request is, in order: the country the mandate's owner
registered for the request's payee (the signed
merchant's active entries in the owner's payee directory,PUT /policy/payees/country), when it has one — the registry is authoritative; else its signedjurisdiction, upper-cased; else none. It is the ONLY value the gate enforces: the mandate's allowed-jurisdictions term (anmm:jurisdictionisAnyOfconstraint) and thejurisdiction-not-allowedatom are both judged on it. An itineraryjurisdiction/mm:jurisdictionis the agent's unsigned word and is ignored for enforcement. - A registered country stands in for a missing signed value (so a registered payee satisfies the
requirement below). A signed
jurisdictionthat differs from it — or entries for one payee that name more than one country — is blockedJURISDICTION_MISMATCH, a hard block, whether or not the mandate restricts jurisdictions. The decision record'sjurisdictioncontrol names the source (signed|registered), and the rule context labels the valueagent_signedorauthoritativeaccordingly. - When the mandate restricts jurisdictions for the action, or an enforced (non-
observe) SOP or Standard molecule judgesjurisdiction-not-allowed, a request with no effective jurisdiction is blockedJURISDICTION_REQUIRED; one outside the mandate's list is blockedJURISDICTION_NOT_ALLOWED. Both are hard blocks and outrank an escalate (§8.5). With neither a restriction nor such a rule, a request behaves exactly as before, with or without the field. - The verifier MUST store the signed jurisdiction with an escalation (and commit it in the
row's integrity hash); a reviewer cannot change it. An approval or a MODIFY (§10) resolves the
effective jurisdiction again from it and the payee registry AS OF the approval — so an owner who
changed the payee's country since the review opened is heard: a stored signed value that now
contradicts it is refused
JURISDICTION_MISMATCH. With no effective jurisdiction, a review under a jurisdiction-restricted mandate, or under an enforced jurisdiction rule, is refusedAPPROVAL_INPUT_UNAVAILABLE. docs/protocol/authorize-v2-vectors.jsonpublishes known-answer vectors for the v2 message under the §8.3.8 test key; a separate file, so a client that pins the eight-field vectors keeps passing until it implements v2.POST /magp/private-authorize(§15) is unchanged: it never sees the jurisdiction, and proves membership in the mandate's list in zero knowledge.
8.3.13. Context signature (optional). The itinerary, trace and materiality are not in the
signed message, so anything between the agent and a verifier can rewrite them. A request MAY
carry envelopeSignature: an Ed25519 signature, by the same key as signature, over the UTF-8
bytes of the request's envelope hash — the lower-case hex SHA-256 of the GovernanceEnvelope built
from the request as transmitted (no defaults applied; context = the itinerary, omitted when
absent or null, present when {}), minus its integrity block, with object keys sorted recursively
and undefined values dropped. resource and jurisdiction are not in it; the §8.3.1 / §8.3.12
message signs them.
- A verifier MUST check a present
envelopeSignatureaftersignature(§8.5 step 5) and before any rule reads the context, over the very itinerary object it will evaluate. One that does not verify — the context was altered after signing, or it was signed with another key — is refusedCONTEXT_SIGNATURE_INVALID, fail closed. - Absent, the request is judged as before: the signature is optional on the wire (the reference
clients send it by default — agentsafe-guard ≥ 0.17.0, metamynd-client ≥ 0.7.0 — and opt out with
signContext: false/sign_context=False). A relay can also strip it, so a counterparty that relies on the context SHOULD require it: the reference receivers (agentsafe-mcp-guard ≥ 0.17.0, agentsafe-a2a-guard ≥ 0.12.0, agentsafe-http-gateway ≥ 0.15.0) refuse an unsigned context withCONTEXT_SIGNATURE_REQUIREDwhen built withrequireContextSignature: true. That code is receiver-only: the gate has no such option. - It attributes the context to the agent; it does not make it true (§17.4).
docs/protocol/context-signature-vectors.jsonpublishes known-answer vectors under the §8.3.8 test key: the request, the canonical envelope, its hash and the signature.
8.4 Response
200 on a permitting disposition; 403 on a refusing one. Body is
{ success, message, data } where data is the verdict:
{ "decision": "allow", "reasonCode": "AUTHORIZED",
"authorizationId": "…", "remaining": 350, "proofRef": "…",
"expiresAt": null, "escalationId": null,
"envelopeId": "env:…", "envelopeHash": "sha256:…" }
proofRef is the ledger anchor of the hold. An implementation MAY anchor after answering,
so that the ledger round trip is not part of the request; proofRef is then null in the
response, and the anchor is recorded on the hold when it lands. The reference implementation
does this by default (LEDGER_ANCHOR_MODE=sync waits for the anchor and returns it). The same
applies to proofRef on capture. A hold refused at the cap check is not anchored.
An implementation that anchors after answering MUST NOT lose an anchor it owes. The reference
implementation records each owed anchor durably before writing the row it describes, and embeds
an anchorId in the ledger message. A sweep settles any anchor still owed after a grace period.
If the row was never written, the anchor is abandoned. If a message with that anchorId is
already on the ledger, it is recorded. Only otherwise is it submitted again. A ledger that
cannot be searched at that moment is not taken as "absent", and the anchor is retried later.
8.5 Order of checks (normative)
A conformant evaluation MUST apply these in order and MUST stop at the first that
fails, returning its reason code — with one exception: an escalate at steps 11–12 is not a
failure that stops evaluation (see Escalation never outranks a hard block below).
| # | Check | On failure |
|---|---|---|
| 1 | Mandate exists for (agentDid, action) |
MANDATE_REVOKED (every mandate for the action was revoked, §6.2.6) · NO_PERMISSION_FOR_ACTION (the agent holds mandates for other actions) · NO_MANDATE (it holds none) |
| 2 | Principal authority in force — mainnet free-tier caps (§4.4.1); verification is reported, not required, on testnet | PRINCIPAL_UNVERIFIED |
| 3 | Compliance with every enforced Standard | STANDARD_NONCOMPLIANT |
| 4 | Agent exists; key proven | AGENT_NOT_FOUND · AGENT_KEY_UNVERIFIED |
| 5 | Signature valid over §8.3; then a present context signature (§8.3.13) | SIGNATURE_INVALID · CONTEXT_SIGNATURE_INVALID |
| 6 | Not contained | AGENT_SUSPENDED · AGENT_QUARANTINED · AGENT_DECOMMISSIONED |
| 7 | Operating-mode block tier | MODE_READ_ONLY |
| 8 | Freshness, then nonce consumed atomically; then the effective jurisdiction resolved (§8.3.12) | REQUEST_EXPIRED · REPLAY_DETECTED · JURISDICTION_MISMATCH |
| 9 | Rate limit, if enabled | RATE_LIMIT_EXCEEDED |
| 10 | Circuit breakers, if enabled | CIRCUIT_BREAKER_OPEN |
| 11 | Standard rule molecules | the firing molecule's code |
| 12 | SOP molecules; then, if an enforced Standard or SOP rule judges jurisdiction-not-allowed and there is no effective jurisdiction |
the firing molecule's code · JURISDICTION_REQUIRED |
| 13 | Mandate evaluation (§5), the allowed-jurisdictions term on the effective jurisdiction | e.g. SPEND_LIMIT_EXCEEDED · JURISDICTION_REQUIRED · JURISDICTION_NOT_ALLOWED; success AUTHORIZED |
| 14 | Fold in OBSERVE (§8.6.2) | allow → observe |
| 15 | Operating-mode escalate floor; reassessment floor; spend-anomaly floor (graph-backed, opt-in); runtime assurance floor (opt-in) | MODE_* · REASSESSMENT_PENDING · SPEND_PATTERN_ANOMALY · AGENT_NOT_CERTIFIED · ASSURANCE_STALE |
| 16 | Atomic spend reservation | CAP_EXCEEDED |
Notes on the ordering, each of which is a security property rather than an accident:
Steps 1–3 precede signature verification because they are questions about the policy, not the requester. They cost one query each and reveal nothing.
Nonce consumption (8) MUST follow signature verification (5), or a forged request could burn a victim's nonce.
Containment (6) precedes everything action-specific, so a contained agent cannot reach evaluation at all.
The mode escalate floor (15) follows deterministic evaluation, so a specific rule denial is reported instead of a generic mode reason.
Escalation never outranks a hard block. A Standard or SOP molecule that escalates (step 11 or 12) — including
CONTEXT_UNVERIFIABLE, a rule that could not get a trustworthy value for a field it needs, such as a missingriskLevel(§6.4.3) — MUST NOT end evaluation. The escalate is carried; the remaining rule stages and the mandate evaluation (13) still run, and anyblock,suspendorquarantineamong them (an SOP cap, the mandate's per-transaction or cumulative cap, an action the mandate never granted) is the verdict. The carried escalate is the verdict only when everything after it would otherwise permit (or itself escalates), and it keeps its own reason code. Escalation can therefore only lift a would-be permit to human review, never downgrade a block: otherwise an agent could turn any hard cap into a review by omitting a field. (Through v1.69.0 a Standards-stage escalate returned at once, so a request with noriskLevelover an SOP or mandate cap was escalated rather than blocked.) A molecule's ownblockstill stops evaluation at its stage, as before.Jurisdiction is one resolved value, judged in three places. After the signature (5) — which covers a top-level
jurisdictionthrough the v2 message (§8.3.12), a malformed value beingMALFORMED_REQUEST— the gate resolves the request's effective jurisdiction, in this order: (a) the country the mandate's owner registered for the signedmerchantin the payee directory, when it has one — authoritative; (b) else the signedjurisdiction, upper-cased; (c) else none. The itinerary'sjurisdiction/mm:jurisdictionis removed before any rule or the mandate sees the context, so it can neither satisfy nor fail a check. Three refusals follow, all hard blocks that outrank a carried escalate:JURISDICTION_MISMATCH(step 8, before any rule runs): a signed value that differs from the registered country, or a registry naming two countries for one payee. It applies whether or not anything restricts jurisdictions.JURISDICTION_REQUIRED: no effective jurisdiction while one is needed — the mandate's allowed-jurisdictions term (step 13), or an enforced (non-observe) Standard or SOP rule that judgesjurisdiction-not-allowed(end of step 12; the atom itself does not fire on a missing value, so without this the rule would pass silently).JURISDICTION_NOT_ALLOWED(step 13): an effective jurisdiction outside the mandate's list. (An effective jurisdiction outside an SOP/Standard allow-list fires the molecule, with that molecule's own disposition and code.)
Compatibility guarantee. A mandate with no allowed-jurisdictions term, for an agent bound to no enforced jurisdiction rule, is judged exactly as before this field existed — for a v1 client and a v2 client alike, with or without the field — with one exception: a request that signs a jurisdiction contradicting the country the owner registered for its payee is refused
JURISDICTION_MISMATCH. A request that signs nothing is never refused for the registry's sake, and one to an unregistered payee (or a payee with no country) is never refusedJURISDICTION_MISMATCH.An approval cannot exceed the hard caps either (§10). Approving an escalation, or a reviewer's MODIFY that re-enters the gate, re-runs the deterministic layers on the request as it stands at the moment of the decision, with every input the live gate has — the mandate (1: still active and still the agent's current one for the action), the principal (2), Standards compliance (3), the agent's key, containment and mode (4, 6, 7), the rate window (9) including the
callCounta rule reads, circuit breakers (10), committed spend, counterparty trust, the Standard and SOP molecules (11, 12) and the mandate (13) with the signed operands,resourceincluded. Only transport authentication (5, 8) is not repeated. Any block-class verdict refuses the approval with that reason code — the review stays pending, and the reviewer may still deny it — and only escalates are disregarded, being what the reviewer is approving. An input that cannot be reconstructed refuses the approval (APPROVAL_INPUT_UNAVAILABLE); it is never skipped. The atomic reservation (16) then still applies (CAP_EXCEEDED). (Through v1.69.1 the approval's rule context carried the committed spend alone, so a call-count or trust rule could not fire at approval, and the rate ceiling, breakers, mode, compliance and a revoked mandate were not consulted.)The spend-anomaly floor (15, opt-in) only ever lifts a permit to escalate, never downgrades a stricter verdict — same precedence discipline as the mode/reassessment floors it shares a step with. It answers a different question than rate limits (9) or circuit breakers (10): those enforce a threshold a human pre-set; this flags a deviation from the agent's OWN recorded history, which no fixed threshold could have anticipated.
The runtime assurance floor (15, opt-in) escalates on a
blockedor expired deployment-assurance certificate (issued out-of-band viaPOST /assurance/deployment/:ref; not otherwise specified here). Same precedence discipline: only ever lifts an otherwise-permit verdict to escalate, never a hard block — an agent that has been operating is not cut off the instant its certificate lapses; a human reviews before the next action refuses outright. An agent never issued a certificate is unaffected (this floor is opt-in per agent, not retroactive against the whole fleet).Reservation (16) is last and atomic: it is the only step that mutates shared budget, and a lost race MUST return
CAP_EXCEEDED.Steps 9 and 10 are secondary controls and MUST fail open on internal error (§9): a supporting control must not take the gate down.
8.6 Dispositions
8.6.1. Six: allow · observe · block · escalate · suspend · quarantine.
8.6.2. observe permits the action and flags it for monitoring. It does not fail fast;
it is collected across the rule and SOP layers and folded in most-restrictive-wins, lifting
an otherwise-allow verdict.
8.6.3. allow and observe permit execution. Everything else MUST stop the action.
8.6.4. escalate is a hold, not a denial — the action waits for a human (§10). A caller
MUST NOT treat it as permission, and an agent MUST NOT self-approve.
8.6.5. An unknown disposition MUST be treated as a block.
8.7 Spend cap and settlement
8.7.1. On a permitting verdict for a value-bearing action the evaluator MUST atomically reserve the amount against the mandate's cap.
8.7.2. After the real-world charge settles the actor SHOULD capture with the actual
amount. A hold that no counterparty has claimed expires and auto-voids.
8.7.3. A claimed hold is a commitment. Once a counterparty has claimed a hold for
execution (POST /policy/mandate/authorize/{id}/effect/dispatching, effect state
dispatching or later) the effect may already have happened, so the reservation MUST
remain against the cap until it is settled or the claimer proves it did not happen. It
MUST NOT lapse with the hold TTL, and it MUST NOT be releasable by any caller that
is not the claimer.
8.7.4. The capture and void endpoints are public and keyed only by the authorization id,
which the agent holds. A settled amount below the authorized amount is therefore a claim by
an untrusted party. An implementation MUST NOT accept it for a claimed hold, nor void
a claimed hold, unless the caller presents the claim token returned by that hold's
successful claim (data.claimToken, in the body as claimToken). Settling at the full
authorized amount needs no token. Before any claim, nothing can have been executed, and a
lower capture or a void makes the hold unclaimable — but the authorization id travels (the agent
hands it to every Service it asks to execute), so it is not a credential. A capture or void
of a hold nobody has claimed MUST come from one of: the hold's own agent, with
agentProof: { agentDid, nonce, issuedAt, signature } in the body — its signature over
MAGP-SETTLE-v1|verb|agentDid|authorizationId|nonce|issuedAt|...fields (verb capture or
void; the fields those of the call as in 8.7.6: capture amountCharged, bookingRef,
settlementTxHash; void reason; each escaped as in 8.3.1), fresh and single-use like an authorize
request; a counterparty signing as itself (8.7.6) that the owner's registry would let claim
this hold; or — only where an anonymous claim of it would be granted (an owner who registered no
claimant, on testnet) — anyone, since there an anonymous settlement grants nothing an anonymous
claim does not. Anything else is refused with the code a claim by that caller gets
(COUNTERPARTY_AUTH_REQUIRED, COUNTERPARTY_NOT_REGISTERED, COUNTERPARTY_NOT_ALLOWED_FOR_MERCHANT,
HTTP 403), and an agentProof that does not verify AGENT_SETTLE_SIGNATURE_INVALID (401); the
hold is untouched either way.
8.7.5. A claim is single-use and MUST be arbitrated atomically: of any number of
simultaneous claimants exactly one succeeds. Every other claim of that authorization — a
simultaneous loser, a later replay, a claim after the effect was dispatched, went unknown or was
settled — MUST be refused with the one stable code AUTHORIZATION_ALREADY_CLAIMED (HTTP 409),
carrying no claim token. EFFECT_TRANSITION_CONTENDED is reserved for a claim that lost for an
unexplained reason and left the hold claimable, or that overlapped an earlier attempt still being
recorded (a claimant SHOULD retry it once); AUTHORIZATION_VOIDED and AUTHORIZATION_EXPIRED
keep their own meaning. The hold window is enforced in the claim itself, not only in an earlier
read, so a hold that lapses between the two is not claimed. No response may carry storage-layer
error text.
8.7.6. Counterparty identity. A claim token (8.7.4) is a bearer secret: it proves "I made
the claim", not who the claimer is. A counterparty that holds a key SHOULD instead sign
the settlement calls with a did:key or did:hedera identity it controls. The signed
message is MAGP-SERVICE-v1|action|authorizationId|field…|nonce|issuedAt, where action is
one of claim, dispatched, unknown, capture, void, refund, reconcile; the fields are those of the call
(claim: the Idempotency-Key of 8.7.7 when one is sent, then payload=<digest> of 8.7.11 when a
digest is stated; capture: amount, bookingRef, settlementTxHash; unknown/void: reason; refund: amount
— the empty string for a full refund — then reason; reconcile: none), each
escaped for \ and |; and the signature is Ed25519, carried in the headers x-magp-service-did,
x-magp-service-nonce, x-magp-service-issued-at and x-magp-service-signature.
Refund. POST .../refund { amount?, reason?, claimToken? } records a reversal of a captured hold: no money
moves, but the cumulative cap counts less. Because it frees budget, it MUST be accepted only from the hold's
claimer — its signature under the refund action, or for an anonymous claim its claim token — the owner of the
hold's mandate, or a platform admin; anyone else MUST be refused COUNTERPARTY_MISMATCH (HTTP 403, the bare code
as the message and in data.reasonCode). A hold captured without a claim has no claimer: only its owner or an admin
may refund it. refund is its own action, so a void signature can never be replayed as a refund, nor a refund as
a void (earlier drafts signed a refund as void; such a signature is refused COUNTERPARTY_SIGNATURE_INVALID).
- The verifier MUST check the signature against the key the DID itself commits to, reject
a stale or future
issuedAt, and consume the nonce once. A malformed, unsupported, expired, replayed or badly signed identity MUST be rejected with aCOUNTERPARTY_*reason code; it MUST NOT fall back to anonymous handling. - The identity that signs the claim is recorded as the claimer. Every later
capture,voidorunknownfor that hold that lowers or releases the reservation MUST be signed by the same identity. No claim token is issued to an authenticated claimer, so there is nothing to leak to the agent. - An implementation MAY require an authenticated claimer for every claim
(
MANDATE_REQUIRE_COUNTERPARTY_AUTH=true); a claim with no identity is then refused withCOUNTERPARTY_AUTH_REQUIRED. - Scope: this proves the claimer controls a key. On its own it does not prove the key belongs to a counterparty the mandate's owner trusts — an agent could claim under a key of its own. Binding the claimer to a counterparty the owner has registered is the trusted-counterparty registry of 8.7.10, applied at the claim: an owner with any registry entry (and every hold of a mainnet agent, or every owner when the deployment requires it) accepts only a claim signed by an active, registered identity.
- A settlement call that lowers or releases a claimed hold and is not made by its claimer is
refused
COUNTERPARTY_MISMATCH(HTTP 403) — forvoidas forunknown(8.7.12).
8.7.7. Idempotent claim. A claim whose response is lost is ambiguous to its claimant: it
cannot tell "my claim landed" from "someone else claimed", and without more it must abandon a
hold it actually owns, leaving it committed with nobody executing it. A claimant MAY
therefore send an Idempotency-Key header with a claim: 32–128 characters of
[A-Za-z0-9._:~-] (128 bits of randomness, e.g. 32 hex), chosen per claim call and reused
only for retries of that call. A malformed key — including one shorter than 32 characters or
one that contains the authorization id, the value the agent is certain to know — is refused
(400 IDEMPOTENCY_KEY_INVALID), never ignored.
- The issuer records only a hash of the key, bound to the authorization, inside the tamper-evident effect chain; the key itself is never stored or returned.
- A retry is answered as a replay —
200with the original grant andreplayed: true, no second claim recorded — only if all hold: the effect is stilldispatchingon both the chain and the hold row, and the hold is stillheld(not settled, released or parked unknown); the chain's claim carries this key's hash; and it was made by the same identity. Otherwise it is an ordinaryAUTHORIZATION_ALREADY_CLAIMED. A replay after the effect was settled, released or went unknown is therefore refused: a late "claim" MUST NOT invite a second execution. A retry that overlaps its own first attempt (claimed on the row, not yet written to the chain) is answeredEFFECT_TRANSITION_CONTENDED, which the claimant retries. - A claimant that is executing still reads
dispatching, so a delayed duplicate of the same call can be answered with the grant. Single execution rests on the claimant using a fresh key per claim call and never reusing one for a second execution, and on the key staying secret from the agent — for an anonymous claim it is the only thing that lets a retry recover the claim token, which is why an authenticated claim (8.7.6) is preferred. - For an authenticated claim the key is part of the signed fields (8.7.6), so it can be neither added nor stripped in flight.
- The authorization id is the effect's natural idempotency key for the upstream. A gateway
SHOULD send it to the upstream as
Idempotency-Key, and MUST NOT forward anIdempotency-Keythe agent supplied (it is replaced when a hold was claimed and removed when none was), so an upstream that de-duplicates on it makes the effect exactly-once even if the gateway is asked to run the same call twice. It is not a valid key for a claim (above): the agent knows it.
8.7.8. Outcome lookup. GET /policy/mandate/authorize/{id}/effect (public, keyed by the
authorization id) answers what became of an authorization, in addition to its effect state:
outcome |
meaning | nothingExecuted |
retrySafe |
|---|---|---|---|
not_started |
authorized, unclaimed, inside its hold window | true |
false |
expired |
unclaimed, past its window; budget already returned | true |
true |
not_executed |
voided before a claim, cancelled by its claimer, or failed clean | true |
true |
in_flight |
claimed (dispatching / dispatched) |
false |
false |
settled |
settled; the spend is committed | false |
false |
unknown |
ambiguous or under reconciliation | false |
false |
reversing / reversed / reversal_failed |
settled, then compensated | false |
false |
The two bits answer different questions. nothingExecuted is a fact about now: nothing has
executed so far. retrySafe is a promise about the future — nothing can execute later either —
and is the only safe basis for issuing a fresh authorization for the same intent. It is
false for not_started: that hold is still claimable until its window closes, so a request
queued behind a slow gateway could still claim and run it while a re-issued authorization runs
too. To retry a not_started hold, void it first (a void is atomic against a claim) and read
not_executed. An unknown or in_flight outcome MUST NOT be retried blindly.
The effect chain is authoritative (a mandate revoke voids the row without touching the chain,
so a claimed hold revoked mid-flight still reads in_flight), except that a hold row ahead of
the chain — a claim decided on the row but not yet recorded — is honoured and reads in_flight.
An unrecognised state fails closed to unknown. The response also carries claimed,
claimedAt, currency, updatedAt, authorizedAmount (before and after settlement,
8.7.9), settledAmount and settlementEvidence. It never carries a claim token or the
claimant's identity.
Capture and void are not idempotent the way a claim is (8.7.7). Retrying either after it
already succeeded does not replay the original response: POST .../capture on a hold that is no
longer held answers 409 NOT_HELD; POST .../void on one answers
200 with { voided: false, reasonCode: "NOT_HELD" }. Both look like failures to a naive
retry-with-backoff loop, but usually mean the first call already landed — a connection that
dropped after the write, not before it. A caller that retries capture or void after a timeout,
a dropped connection, or a 5xx MUST read GET .../effect before concluding the call
failed: outcome: settled after a capture, or an outcome consistent with what the void was
attempting, confirms it already succeeded. Only a genuine, confirmed failure is safe to retry.
A refused settlement call is a 4xx, never a 5xx, and has one shape. A refused capture, void
or refund answers with the bare reason code as the message and
data: { captured | voided | refunded: false, authorizationId, reasonCode, detail }, where detail
is an explanatory sentence for people — a client MUST branch on reasonCode (or message),
never on detail. Every refusal of these three calls has a code, and its HTTP status follows
from the code alone — the same code is the same status on every call it can occur on:
reasonCode |
HTTP | on | when |
|---|---|---|---|
AUTHORIZATION_NOT_FOUND |
404 | capture · void · refund | no hold under this id (the same answer the effect/* routes give) |
HOLD_UNDER_RECONCILIATION |
409 | capture · void | the owner forced the hold into reconciliation (8.7.13) |
EFFECT_NOT_VOIDABLE |
409 | void | the effect is dispatched or unknown (or later): it may have happened, so it is reconciled, never voided |
EFFECT_NOT_CAPTURABLE |
409 | capture | the effect is unknown (or already resolved): it is reconciled, never captured on faith |
CLAIMED |
409 | void | a claim landed while the void was in flight |
COUNTERPARTY_AUTH_REQUIRED · COUNTERPARTY_NOT_REGISTERED · COUNTERPARTY_NOT_ALLOWED_FOR_MERCHANT |
403 | capture · void | the hold is unclaimed and the caller has no authority over it: not its agent, and not a counterparty the owner's registry would let claim it (8.7.4) |
AGENT_SETTLE_SIGNATURE_INVALID · AUTHORIZATION_AGENT_MISMATCH |
401 · 403 | capture · void | an agentProof that does not verify over MAGP-SETTLE-v1, or that is another agent's (8.7.4); REQUEST_EXPIRED (401) and REPLAY_DETECTED (409) as for an authorize request |
COUNTERPARTY_MISMATCH |
403 | capture · void · refund | the hold is claimed and the caller is not its claimer (8.7.4, 8.7.6) — for capture, one settling below the authorized amount; for refund, not its claimer, owner or an admin |
SETTLEMENT_NOT_CONFIRMED · SETTLEMENT_AMOUNT_MISMATCH · SETTLEMENT_NOT_FOUND |
400 | capture · void | a lowered settlement, or a claimer's release, was not supported by the settlement observer (8.7.9) |
PAYEE_NOT_REGISTERED · PAYEE_NOT_VERIFIED |
400 | capture | a lowered settlement named no registered payee, or the payee directory could not be read (8.7.14) |
NOT_HELD |
409 | capture | the hold is already settled or released (usually: an earlier capture landed — read the outcome) |
AUTHORIZATION_EXPIRED |
409 | capture | an unclaimed hold past its window; its budget already went back to the cap (the code a claim of it gets) |
AMOUNT_EXCEEDS_AUTHORIZED |
400 | capture | amountCharged is more than the hold (8.7.9). Refused; a larger settlement observed by reconciliation is SETTLED_OVER_AUTHORIZED instead |
AMOUNT_INVALID |
400 | capture · refund | an amount that is not a finite number in range |
PASSPORT_EXPIRED · PASSPORT_ALREADY_CONSUMED · PASSPORT_REVOKED |
400 | capture | the hold's Action Passport is no longer usable (§13). Checked on a counterparty's capture only: reconciliation (8.7.12, 8.7.13, an operator's resolution) records what happened remotely and is never refused on the passport |
HOLD_STATE_CHANGED |
409 | capture · refund | the call lost a race — the hold was claimed, settled, voided or refunded between the read and the write; nothing was applied |
NOT_CAPTURED |
409 | refund | the hold is not captured |
ALREADY_REFUNDED |
409 | refund | nothing remains to refund |
AMOUNT_EXCEEDS_REFUNDABLE |
400 | refund | a partial refund larger than what remains captured |
MANDATE_REVOKED |
409 | capture | the hold is unclaimed and its mandate — or a mandate it was delegated from (5.4.10) — has been revoked (8.7.15): it can only be voided. A claimed hold keeps the claimed-hold rules |
DELEGATION_CHAIN_BROKEN |
409 | capture | the hold is unclaimed and its delegated mandate's chain cannot be walked to a root (5.4.10): fail closed; it can only be voided |
MALFORMED_REQUEST |
400 | capture · void · refund | the authorization id is not a UUID, or the body failed validation (the issues in data.issues) |
NOT_HELD on void (already settled or released) remains a 200 with voided: false, as above:
repeating a void that already happened is not an error. Any other failure is unexpected and is a
5xx with a generic message. The effect/* routes (8.7.5, 8.7.8, 8.7.12, 8.7.13) answer a
refusal in the same shape less the captured | voided | refunded flag —
data: { …, authorizationId, reasonCode, detail } — and an unknown id is 404 AUTHORIZATION_NOT_FOUND on every one of them, the outcome lookup included. Where reconciliation or
an operator's resolution settles a hold and the capture is refused, the route answers 409 with the
capture's code. A claim of an unclaimed hold under a revoked mandate is 409 MANDATE_REVOKED
(8.7.15), or 409 DELEGATION_CHAIN_BROKEN (5.4.10).
The platform-admin routes — effect/resolve, effect/compensate/begin, effect/compensate/complete
and effect/reconcile-sweep — answer in the same shape: a signed-in caller who is not a platform
admin is 403 FORBIDDEN; a malformed authorization id, an unknown resolution or outcome, or a
settledAmount that is not a non-negative number is 400 MALFORMED_REQUEST with the validation
issues in data.issues; an unknown id is 404 AUTHORIZATION_NOT_FOUND; and a refusal by the hold's
effect state keeps its code and its 409. No session at all is still 401. Up to v1.69.2 these
routes put a sentence in message ("Platform admin required", "resolution must be …",
"settledAmount must be …", "outcome must be …") with data: null, answered a service refusal with
data carrying no authorizationId or detail, and passed a malformed id to the database (a 500).
Before v1.69.1 a refused capture carried the sentence in message and only { reasonCode } in
data, and an unknown id answered 400 "Authorization not found" on all three calls. Up to v1.69.1
the refusals decided by the hold's state rather than by the observer or the owner — already settled,
expired, over the authorized amount, lowered by a non-claimer (then a 400), an unusable Action
Passport, a lost race, and every refund state refusal — still carried the sentence in message with
data: null, all as 400s (already settled, expired, a lost race, not captured and already refunded
are 409 now: they are the hold's state); a capture of an unknown effect was a 500; a malformed id answered
400 "Invalid authorization id"; effect/unknown and effect/reconcile answered 403 for an
unknown id; and the outcome lookup answered 404 "Not found".
8.7.9. Authorized amount, settled amount, and what the settled amount rests on. The gate MUST record the amount it authorized once, with the hold, and MUST NOT overwrite it at settlement: the settled amount is what the cumulative cap then counts, and a settlement that replaced the authorization would leave nothing to compare it with. A settlement MUST be recorded with the evidence it rests on, one of:
settlementEvidence |
meaning |
|---|---|
unattested |
The full authorized amount, counted for a caller that is not the claiming counterparty. It asserts nothing about what was charged; it is only the most the authorization allowed. |
unclaimed_lowered |
Settled below the authorization before any claim, by whoever held the authorization id. Nothing can have executed (the hold cannot be claimed once settled), so it is a release, not a report of a charge — kept distinct from unattested so that the label for the safe direction is never read on a row whose budget was handed back. |
counterparty_attested |
The claiming counterparty (8.7.6) reported the amount. Believed because of who said it; nothing confirmed it. |
independently_confirmed |
A connector reconciler observed the remote system and it reported this amount. |
operator_resolved |
A platform operator, resolving an ambiguous effect, decided the amount. |
reconciled_at_authorized |
The remote system confirmed the effect occurred but reported no amount, so the full authorized amount is counted. |
A settled amount MUST NOT exceed the authorized amount. Where an independent observer of
the remote system is configured, a claimer settling below the authorized amount MUST be
supported by it: the observed amount must equal the reported one, and the lower figure is
refused — never believed — when the observer is unreachable, still undecided, finds no
settlement, or confirms a settlement without stating its amount (SETTLEMENT_NOT_CONFIRMED,
SETTLEMENT_AMOUNT_MISMATCH, SETTLEMENT_NOT_FOUND, HTTP 400, the code as the message and in data.reasonCode);
the claimer may still settle in full. The amount recorded is the one the observer reported, not the
one the claimer asked for. A refusal names the reason code and never the observer's own figures or
wording, which would be an oracle for what the remote system recorded against an authorization id.
Settling at the full amount needs no confirmation, because it can only spend more.
Releasing a claimed hold is the same act as settling it at zero and needs the same support: where
an observer is configured, a claimer MUST NOT void a claimed hold unless the observer
affirms that nothing was settled (an explicit failed / rejected record). Silence, an absent
record and an unreachable observer are not that. An absent record in particular is never proof
that nothing happened: a facilitator may not yet know the id, or not have the route at all. The
claimer can still capture in full, or mark the effect unknown and leave it to reconciliation. The
refusal is SETTLEMENT_NOT_CONFIRMED, HTTP 400, the status it has on a lowered capture.
Consequence for x402-bound holds (intended, fail-closed). A release carries no settlement
transaction id, so the Hedera mirror-node observer cannot run for it, and the x402 facilitator
observer affirms a failure only from a per-authorization settlement record
(GET {facilitator}/settlement/{authorizationId} answering failed or rejected) that the
standard x402 facilitator routes (/verify, /settle, /supported) do not provide — every
probe then reads as absent, and absence is not proof. So where a facilitator is configured, a
claimer cannot void a claimed x402-bound hold: every attempt is refused
SETTLEMENT_NOT_CONFIRMED. This is the intended behaviour, not a gap. A claimer whose x402
call did not go through MUST instead capture (in full, or lower with the observer's support)
or mark the effect unknown (POST .../effect/unknown) and let reconciliation decide. Reconciliation
of an ambiguous effect settles at the amount the remote system (or the operator) reports, not
blindly at the authorization, and MUST NOT absorb a settlement larger than the authorization:
that effect goes to compensation (SETTLED_OVER_AUTHORIZED) for a person to decide. A hold
captured before the authorized amount was stored reports authorizedAmount: null — unknown,
neither zero nor the settled amount.
8.7.10. Trusted counterparties. Authenticating a counterparty (8.7.6) proves who signed a
claim; it does not say whether the mandate's owner trusts that identity, and the claimer is
the only party that may then settle the hold lower, release it, or mark it unknown. An owner
therefore maintains a registry of the counterparty DIDs that may claim their holds
(GET|POST /policy/counterparties, DELETE /policy/counterparties/{id} — authenticated and
owner-scoped; a DID must be self-verifying, did:key or did:hedera). An entry may name the
merchants it may act for; an entry naming none may claim for any merchant.
Registering an owner's first entry is not like registering their second: it is the call that
flips every one of that owner's holds from open to registered-only (below), which can silently
strand an existing counterparty that was never asked to register. POST /policy/counterparties
therefore MUST refuse a first registration that does not carry confirmEnforcementChange: true (409 ENFORCEMENT_CHANGE_NOT_CONFIRMED, naming the effect in its message) — a second and
later registration needs no such flag, since enforcement is already in effect.
The rule is applied at the claim, against the registry of the owner of the hold's mandate:
- An owner with no entries is open — any authenticated counterparty may claim — unless the
deployment sets
MANDATE_REQUIRE_REGISTERED_COUNTERPARTY=true(every owner registered-only), or the hold's agent is a mainnet identity, which is always registered-only regardless of what the owner has (or has not) registered. The registry-default decision (release-blockers-open-items.md) settled on scoping enforcement to mainnet rather than every deployment or every owner: it protects real settlement without adding friction to the free, no-account testnet sandbox every fresh integration starts in. - An owner with any entry, of any status, is registered-only, and it fails closed: revoking the last entry does not reopen the owner's holds to anyone, it stops every claim until an entry is registered again.
- Under enforcement an anonymous claim is refused (
COUNTERPARTY_AUTH_REQUIRED, HTTP 403), the claimant must be an active entry (COUNTERPARTY_NOT_REGISTERED, 403), and a merchant-scoped entry may claim only holds for its merchants — a hold with no merchant cannot satisfy a scoped entry (COUNTERPARTY_NOT_ALLOWED_FOR_MERCHANT, 403). A refused claim leaves the hold unclaimed and claimable by a trusted counterparty. - A registry that cannot be read is an error, never "no registry".
- An entry has a purpose:
claim(the default, everything above) orreport— a Service registered only to report what it did (16.4), such as a non-financial agent's gateway, which claims no ordinary hold. Areportentry MUST NOT be accepted as a claimant and MUST NOT count toward registered-only enforcement, so registering one changes nothing about who may claim the owner's holds. Registering an activeclaimentry again asreportkeeps it a claimant. The one exception is an approval claim (8.7.18): a claim that asks for a person's approval of a hold whose authorized amount is 0 and which a person approved. An active, trustedreportentry MAY make that claim only for a hold of an agent it was registered for (agents, the agent DIDs the service acts for); an entry registered for no agents makes none, so one of an owner's gateways can never take — and so deny — another agent's approval. It is the only way a value-less tool's gateway, which runs without claiming its allowed calls, can run an approved escalation. The claim is still single-use, bound to the hold's agent and values, and moves no money. - Proof of control. A registration MUST carry the service's own acceptance: the owner obtains
a challenge (
POST /policy/counterparties/challenge, for one DID and purpose, valid 10 minutes), the service signs its messageMAGP-COUNTERPARTY-ACCEPT-v1|<registrant principal DID>|<service DID>|<purpose>|<nonce>|<expiresAt>(fields escaped as in 8.3) with the key its DID embeds, and the registration presents the challenge token and that signature. The issuer binds the challenge to the registering owner, so an acceptance obtained by one owner is useless to another, and refuses a missing, foreign, expired or wrong-purpose proof (COUNTERPARTY_PROOF_REQUIRED/_INVALID/_EXPIRED). Without it any owner could name another tenant's gateway as its own counterparty and have it claim and run that owner's holds. A service MUST sign only a well-formed acceptance naming its own DID — never free text (16.2). Entries registered before this rule are kept but marked unproven; a deployment that setsCOUNTERPARTY_REQUIRE_PROOFtrusts only proven entries, while unproven ones still keep the owner registered-only.
Registration governs who may claim. Once claimed, the claimer's identity is the one recorded on the effect chain and the only one that may settle lower or release (8.7.6, 8.7.9); revoking that identity afterwards does not transfer the hold, which is then resolved by reconciliation.
8.7.11. Payload comparison at the claim. A hold whose authorization carried a payload binding
(§8.3.9) is claimed with the digest of exactly what the claimant is about to execute. The claimant
sends it in the x-magp-payload-digest header, and — when it authenticates (§8.7.6) — the digest is
also one more signed field of the claim message, payload=<digest>, so it can be neither added
nor stripped in flight. The issuer compares it with the digest it stored at authorize time:
- Equal: the claim proceeds as before, and the grant reports the digest the hold is bound to
(
data.payloadDigest;nullfor an unbound hold). - Different: the claim is refused —
PAYLOAD_DIGEST_MISMATCH, HTTP 403 — and refused rather than granted and then checked: the hold stays unclaimed and claimable by an honest executor. - The hold is bound and the claimant states no digest: refused,
PAYLOAD_DIGEST_REQUIRED(403). A claimant cannot ignore the binding by omission. - The hold is unbound and the claimant states a digest: refused,
PAYLOAD_NOT_BOUND_AT_AUTHORIZE(403). The agent never bound a payload, and a claim must not assert that it did. - The hold is unbound and its mandate requires binding (§8.3.9): refused,
PAYLOAD_BINDING_REQUIRED(409, the hold's state). The hold stays unclaimed, and is claimable once the agent has bound a digest (§8.3.11). Decided after the two comparisons above, so a claimant that states a digest against an unbound hold is told the more specificPAYLOAD_NOT_BOUND_AT_AUTHORIZE. - A digest that is not
sha256:and 64 lower-case hex is a malformed request (400 PAYLOAD_DIGEST_INVALID). - Evidence. A granted claim records the digest it was granted against beside the claim on the
effect chain (§8.7.5) as the evidence ref
payload:<digest>(with the idempotency-key hash when the claim sent one). The hold's own digest column is set by a binding; the chain entry is the tamper-evident record of which payload the claimant was held to. An unbound claim records none. - The comparison is the same whether the claimant is authenticated or anonymous. It is made with the registry check of §8.7.10, before a claim is recorded: no refusal above records one.
- The claim is decided against the digest it read, and commits only against that digest: the
conditional update that records the claim (§8.7.5) also requires the hold to still carry exactly
the digest — or the absence of one — the decision was made on. A binding (§8.3.11) that lands
between the decision and the write therefore makes the claim contend
(
EFFECT_TRANSITION_CONTENDED, which the claimant retries, §8.7.5) instead of granting a claim that ran unbound; the retry meets the digest that is now there.
The comparison is made against the digest the ISSUER stored, not against a request the claimant was handed. A gateway that only compared the body it forwards with the signed request in its header could be given a different signed request — a fresh nonce, a different digest — for the same authorization id by an agent that signs whatever it likes. Only the issuer knows which digest the authorization was actually granted for.
The executing side SHOULD also compare, before it claims, the digest of what it will execute
with the one in the signed request it was handed, and refuse a difference without claiming:
cheaper, and it leaves the hold untouched. This is an optimisation and never the authority; a
verifier that omits it is still protected by the comparison above. An executor whose deployment
requires binding SHOULD refuse a request whose authorization carries none
(PAYLOAD_BINDING_REQUIRED), and MUST refuse, rather than skip the check for, a payload it
cannot digest (PAYLOAD_NOT_CANONICALIZABLE; a gateway that received a body for a request that
bound a payload and cannot show it is that payload answers PAYLOAD_NOT_BOUND). The binding covers
the payload, not the address it is sent to: an executor that forwards an HTTP request MUST NOT
forward a URL query string — or a query, path parameter or fragment encoded into the path — that the
signature does not cover, and refuses it before claiming (QUERY_NOT_BOUND), unless its operator
has listed the exact keys it may forward unbound (never a signed value field).
What is digested must mean the same thing to every reader of the bytes that are forwarded. An executor that digests a body it received as JSON MUST refuse, rather than digest, a body that is ambiguous between readers: a repeated object key (a first-wins reader runs a different value from a last-wins one), a number a double cannot carry exactly (an integer of magnitude 2^53 or more, or a non-integer with more than 15 significant digits — send large identifiers and amounts as strings), invalid UTF-8, or a byte-order mark. Otherwise two different bodies share a digest, and the binding covers less than it appears to.
8.7.12. Who may push a claimed hold along. Marking an outcome unknown
(POST .../effect/unknown) and asking for reconciliation (POST .../effect/reconcile) freeze a
hold out of capture and void, so a gate MUST accept them only from the claimer (its signed
call — action unknown or reconcile — or, for an anonymous claim, its claim token), the owner of
the hold's mandate, or an operator. The authorization id alone is not enough (COUNTERPARTY_MISMATCH,
RECONCILE_NOT_PERMITTED).
8.7.13. Owner-forced reconciliation. An owner who no longer trusts the counterparty holding a
claim (typically one just revoked, 8.7.10) MAY force the hold into reconciliation
(POST .../effect/force-reconcile). The hold moves to reconciling with the owner as the actor,
its budget stays committed, and from then on the claimer holds none of its powers: no caller-side
capture or void applies to it at all — not even at the full amount, which would end
reconciliation before it decided (HOLD_UNDER_RECONCILIATION, HTTP 409 on both capture and
void, the code as the message and in data.reasonCode). It settles only on what the
settlement observer establishes or an operator decides. Nothing is released or reassigned: a hold
that may have executed must not run twice.
8.7.14. Payee directory. A claimer settling below the authorization names the account it paid
(payTo), and the observer checks that account was credited — which proves the claimed account
was paid, not that it is the right one. An owner MAY register the accounts each merchant is
paid into (/policy/payees). For a merchant with an active entry, a lowered settlement MUST
name one of them or be refused (PAYEE_NOT_REGISTERED); a gate that cannot read the directory
refuses the lowered settlement rather than skip the check (PAYEE_NOT_VERIFIED). A merchant with
no entry, and a settlement at the full amount, are unaffected.
8.7.15. Revocation and holds. Every path that revokes a mandate — the owner's or an admin's revoke, a delegation-chain revoke, the cascade to delegated mandates (5.4.9, including from a mandate superseded by a reissue), decommissioning or a failed provisioning (which revoke all of an agent's mandates), and the revoke of a mandate superseded by a reissue — MUST do the same thing to it:
- make it inactive and close every review pending under it, in one transaction (10.4.2) — from then
on it authorizes nothing (
MANDATE_REVOKED, unless the action is granted again), and no approval can mint a hold on it (MANDATE_REVOKED); - release every hold under it that no counterparty has claimed: the hold becomes
voided(effect statecancelled, reasonHOLD_VOIDED:<revoke reason>), its budget returns to the cap, and its Action Passport is revoked. The release is conditional on the hold still being unclaimed, so a claim that lands first wins and the hold is treated as claimed; - leave every claimed hold (8.7.3) as it is: still
held, its budget still committed, its passport usable, settled the ordinary way — its claimer captures it or releases it (8.7.9), or it is reconciled, or the owner forces it into reconciliation (8.7.13). A revoke never releases a hold that may have executed; - anchor the revoke and record it as evidence once, when it makes the mandate inactive.
An already-inactive mandate is still swept (reviews closed, unclaimed holds released) but not
revoked twice. Independently of the sweep, a gate MUST NOT let an unclaimed hold under an
inactive mandate — or under a delegated mandate with an inactive ancestor (5.4.10) — become an
execution: a claim of it is refused MANDATE_REVOKED (409, the hold stays unclaimed; or
DELEGATION_CHAIN_BROKEN for a chain that cannot be walked), and so is a counterparty's capture of
it (409, at any amount); it can still be voided. The effect chain is not written by the release
(8.7.8).
Revokes made before this rule (v1.69.2 and earlier: a chain revoke that left unclaimed holds
held, a direct revoke that did not reach delegated mandates, a revoke that left reviews pending)
are finished by the reference implementation once, at start-up: every inactive mandate that still
has an active delegated mandate, an unclaimed held hold or a pending review is revoked again
through the same routine (reason REVOKE_SWEEP), with the evidence, ledger anchor and Action
Passport revocation a live revoke emits. It is idempotent and logged.
8.7.16. Rules changed while a hold waits. A change to the agent's SOPs or bound Standards takes
effect immediately, including for holds already granted but not yet claimed. Before an unclaimed
hold moves to dispatching, the gate MUST evaluate the SOP and Standard molecules bound to the
agent at the time of the claim, deterministically and most-restrictive-wins as at authorize
(6.1, 8.5), over the context the hold was granted on:
- the signed values the hold carries — the action of its mandate, the agent, the authorized amount and currency;
- the rest of the rule context the permit was decided on, recorded with the hold: the agent's
unsigned context, a signed or registered jurisdiction, and the request's call count (a claim is
not another call). The owner's risk tier for the action floors
riskLevel, as at authorize; - committed spend and counterparty trust, read again at the claim, as an approval reads them (10.4.1). Committed spend excludes the hold itself, as it did at authorize.
If the current rules no longer permit the hold — a block, suspend or quarantine from either
layer; an enforced jurisdiction rule with no jurisdiction; or an escalate, unless the hold was
minted by a person's approval or MODIFY (an approval disregards escalates, 10.4) — the claim
MUST be refused AUTHORIZATION_RULES_CHANGED (409) and the hold voided: effect state
cancelled, reason HOLD_VOIDED:<the firing rule's code>, its budget returned to the cap and its
Action Passport revoked. Nothing executes; the agent asks again under the new rules. A claim or
capture refused this way MUST also be recorded as an evidence event (block,
AUTHORIZATION_RULES_CHANGED, the mandate's standardRef), so the evidence trail shows the action
was stopped, not only that it was authorized. An observe
permits. If the rules cannot be evaluated the claim MUST be refused (CONTROL_UNAVAILABLE,
409) and the hold left unclaimed, so it can be claimed once they can. A hold minted before its
rule context was recorded is judged on the values its row carries, and an escalate is not a
refusal for it (it may only mean a field the claim cannot see was not sent). The recorded context carries the agent's own itinerary, so it SHOULD be stored encrypted and bound to its hold, and it MUST be bounded in size without trimming: a request whose context is over the bound is refused at authorize (CONTEXT_TOO_LARGE), since a trimmed context could drop a field a rule reads. A recorded context that cannot be read back at the claim MUST fail closed (CONTROL_UNAVAILABLE, hold left unclaimed), never be judged as unrecorded. The check runs after
the counterparty and payload checks (8.7.6, 8.3.9), so only an otherwise-grantable claim can void
a hold, and only while the hold is live (a lapsed one is answered AUTHORIZATION_EXPIRED).
A hold already claimed (8.7.3) is never re-judged: it may be executing, and settles by the claimed-hold rules. The mandate's own terms are not re-judged here: they cannot change under a hold, because changing them reissues the mandate, and the revoke of the old one releases its unclaimed holds (8.7.15).
A capture of an unclaimed hold — an executor that settles without claiming first — MUST be
re-judged the same way before it commits anything, with the same outcomes: refused
AUTHORIZATION_RULES_CHANGED (409) and the hold voided, or refused CONTROL_UNAVAILABLE (409)
with the hold unchanged. A capture of zero (a release) and a capture by reconciliation are not
re-judged.
A change to the agent's SOPs or bound Standards SHOULD also re-judge the agent's live unclaimed holds as soon as it commits, voiding those the new rules no longer permit exactly as a claim would. Each void is conditional on the hold still being unclaimed, as each claim is, so a change that commits while a claim is between its re-check and its commit voids the hold unless the claim committed first; a claim that committed first is a commitment, like a claim that beat a revoke (8.7.15), and settles by the claimed-hold rules. A hold whose rules cannot be read during this re-judgment is left as it is; the claim-time check still applies to it.
8.7.17. Containment and holds. Containment (suspend, quarantine, decommission — 7.1) stops what
the agent was already allowed to do, not only what it asks for next. Before an unclaimed hold
moves to dispatching, and before a capture of an unclaimed hold commits anything, the gate
MUST read the containment state of the agent the hold was issued to (by its mandate's stable
identity, so a rotated DID cannot escape it; by the hold's DID only for a mandate that names no
identity). Any state other than none that is not a known containment state MUST fail closed
(CONTROL_UNAVAILABLE). The write that commits the claim, or the unclaimed capture, MUST itself
be conditional on the agent not being contained, so a containment that commits after the gate read
the agent as active, but before the claim's write, still stops it — answered and voided exactly as
if the read had seen it. If the agent is contained, the claim or capture MUST be refused
with the containment code — AGENT_SUSPENDED, AGENT_QUARANTINED or AGENT_DECOMMISSIONED
(409) — and the hold voided: effect state cancelled, reason HOLD_VOIDED:<that code>, its
budget returned to the cap and its Action Passport revoked. A claim or capture refused this way
MUST also be recorded as an evidence event (block, the containment code), as for 8.7.16. A
reinstatement therefore never revives an authorization granted before the containment; the agent
asks again. The check runs before WHO is
claiming is considered (8.7.6) and before the rules are re-judged (8.7.16): a contained agent may
not act, whoever offers to execute for it. If the containment state cannot be read, the claim or
capture MUST be refused CONTROL_UNAVAILABLE (409) with the hold left unclaimed. A lapsed hold
is answered AUTHORIZATION_EXPIRED, and a capture of zero (a release) is not refused.
Every path that contains an agent — the owner's or an admin's containment, and the gate's own
suspend/quarantine verdicts — SHOULD also void the agent's live unclaimed holds as soon as
the containment commits, after writing it, returning their budget at once and leaving nothing for a
reinstatement to revive; the claim-time check is the backstop for any hold the sweep does not reach.
Each void is conditional on the hold still being unclaimed. Only a claim whose write committed
before the containment did is a commitment, as for a revoke (8.7.15): it may be executing, is
left held, and settles by the claimed-hold rules. Because "all actions" includes these, an unclaimed
claimant that presents a contained agent's hold learns the containment code rather than a
counterparty refusal; the hold is voided either way and nothing executes.
8.7.18. Executing an approved escalation. An executor that re-evaluates the agent's policy
(8.7.6) judges a request the agent signed with the context that escalated it — and so reaches
escalate again, even after the owner approved that very action and the approval minted a hold
(10.4). The approval, not the request, settles it: when the executor's own verdict is escalate
and the request carries an authorizationId, the executor MAY claim that hold stating
requireHumanApproval: true. The issuer MUST then grant the claim only for a hold minted by a
person's approval (ESCALATION_APPROVED or ESCALATION_MODIFIED_APPROVED), and otherwise refuse
ESCALATION_NOT_APPROVED (409) leaving the hold unclaimed and unchanged — the request still needs
a person, so the executor answers escalate. Every claim grant carries approvedByHuman; the
executor MUST permit only on approvedByHuman: true, and on a grant without it (an issuer that
does not implement this section ignores the flag) MUST release the claim and keep the
escalate. Every other check on the claim applies unchanged — the agent, action, amount, currency,
merchant and payload binding (8.7.6, 8.3.9), the rules as they stand now (8.7.16) and containment
(8.7.17) — so the approval executes exactly what the person approved, once. A modified approval
carries no payload digest (the agent never signed the modified action), so under
requireHumanApproval it MUST be refused PAYLOAD_BINDING_REQUIRED until the agent binds the
payload of what will run (8.3.11). The requireHumanApproval check runs after the counterparty
checks (8.7.6), so only a claimer allowed to claim learns how a hold came about.
Because approvedByHuman in the grant is what lifts the verdict, the executor MUST take this
path only over an issuer channel whose responses are integrity-protected — TLS, or loopback for a
local development issuer — and otherwise answer escalate. Only an escalate is lifted: a
block, suspend or quarantine from the executor's own evaluation is never overridden by an
approval given before it. The approval lifts any escalate the executor reaches for that request
(a rule, an operating-mode floor, a review flag) — consistent with 8.7.16, where an approved hold is
not refused for an escalate either; anything stronger still refuses.
8.7.19. Claim expectations. An executor checks a claim's grant against what it is about to
execute — the agent, action, amount, currency and merchant of the request it verified — and refuses
on any difference. Checked only after the grant, a mismatch leaves the hold claimed with nothing
executed: anyone who learns an authorization id could present it at the hold's gateway under
another agent and strand that agent's hold against its cap. So a claim SHOULD state those
values as expect: { agentDid, action, amount, currency, merchant }, and the issuer MUST
compare them with the hold before granting the claim — after the counterparty checks (8.7.6),
so only a claimer allowed to claim learns anything of the hold — refusing a difference with
AUTHORIZATION_AGENT_MISMATCH, AUTHORIZATION_ACTION_MISMATCH, AUTHORIZATION_AMOUNT_MISMATCH,
AUTHORIZATION_CURRENCY_MISMATCH or AUTHORIZATION_MERCHANT_MISMATCH (403) and leaving the hold
unclaimed and unchanged. A value the hold does not carry is not compared, as the executor skips a
field the grant omits; amounts compare as numbers, and a field sent as null is not stated. A
malformed expect (a field of the wrong type, a non-finite amount) MUST be refused
CLAIM_EXPECTATION_INVALID (400), never partly applied; a key the issuer does not know is
ignored, so a later field can be introduced while the executor's own post-grant check still covers
it. An executor that still finds a mismatch after a grant (an issuer that does not implement this
section) MUST release the claim (8.7.4, with its claim token) before refusing, so the hold is not
stranded — on such an issuer that release voids the hold (its budget returns; the agent asks
again), which is the lesser harm.
9. Secondary controls
Controls that protect the system rather than express authority. Both are optional, and by
default both MUST fail open on internal error — a rate counter or breaker lookup that errors
must not deny an otherwise-valid action. A deployment MAY instead configure its secondary
controls to fail closed (reference implementation: SECONDARY_CONTROLS_FAIL_CLOSED=true): a
rate counter or breaker lookup that errors then blocks with CONTROL_UNAVAILABLE, and the
spend-anomaly and assurance floors, which only ever escalate, escalate with it.
A request MUST NOT be able to cause the internal error itself. A breaker scope value the
request supplies (the itinerary's tool or counterpartyDid) that contains a control character
cannot be looked up, so it is refused MALFORMED_REQUEST before the lookup — otherwise the
fail-open path would skip every breaker for that request, the agent's and tenant's included.
9.1 Rate limits
9.1.1. Per agent, max calls per rolling windowSec. max = 0 disables it.
9.1.2. The running count is server-derived and MUST be injected into rule context signed-last, so a rule-authored rate atom evaluates the same number the built-in ceiling does and an unsigned itinerary cannot shadow it.
9.2 Circuit breakers
9.2.1. Scoped to agent, tenant, tool or counterparty. An open breaker denies with
CIRCUIT_BREAKER_OPEN.
10. Escalation resolution
10.1. escalate parks a pending record. No budget is held.
10.2. GET /policy/escalations/{id}/status is public and keyed by the escalation id,
which is itself the capability. An agent holds no user token and needs none to ask about
its own held action. It returns status, reasonCode, authorizationId, expiresAt,
approvals, required.
10.3. Approval requires M of N distinct approvers, configurable per agent, defaulting to
- An approver MAY be required to hold a named role and a minimum authority level.
10.4. On approval the gate re-runs the deterministic checks and mints an authorizationId.
Approval does not bypass the budget, nor any hard block: containment, an action the mandate
does not grant, a Standard or SOP block (e.g. an SOP per-transaction cap) and the mandate's
per-transaction and cumulative caps are all re-evaluated on the escalated request, and any of them
refuses the approval with its own reason code (the cumulative cap as CAP_EXCEEDED); the
escalation stays pending and may still be denied. Escalates are disregarded — they are what is
being approved (§8.5). The minted hold starts its effect lifecycle at authorized, so it is
claimable like any other. A MODIFY re-enters the gate under the same precedence.
10.4.1. The re-evaluation uses the gate's inputs as of the approval, never those of the moment the review opened, and every one the live gate uses except transport authentication (signature, freshness, nonce — satisfied by the agent's original request):
- The mandate. It MUST still be active, and for a delegated mandate so must every mandate
above it (
MANDATE_REVOKEDotherwise,DELEGATION_CHAIN_BROKENfor a chain that cannot be walked, 5.4.10), and still the agent's current mandate for the action (MANDATE_SUPERSEDEDif a newer one has been issued). Reviews do not migrate to a reissued mandate: a review raised under the old one cannot be approved, and the agent asks again under the new one (10.4.2). - Principal, compliance and the agent — steps 2, 3, 4, 6 and 7 of §8.5 as they stand now.
- The rate window (§9.1): the gate's own ceiling (
RATE_LIMIT_EXCEEDED) and thecallCountgiven to rules. A request is counted once in the window it arrives in. An approval in that same window reads the count (the request is already in it); an approval in a later window is counted in that window, as the live gate counts a request — before judging, so a refused approval there counts too — and a hold granted in a window is therefore always one of its calls. - Circuit breakers (§9.2).
- Committed spend and counterparty trust, applied as server-derived fields (§6.4.2), so the
review's stored itinerary cannot supply
cumulativeSpend,callCountorholTrustScore. - The signed operands,
resourceincluded: the gate keeps the signed resource with the review and applies it last; the unsigned itinerary never stands in for it. - The jurisdiction (§8.3.12). The gate stores the request's signed
jurisdictionwith the review, committed in the row's integrity hash; a reviewer cannot change it, and a MODIFY cannot move the action to another jurisdiction (an itinerary value is ignored, as at authorize). The effective jurisdiction is resolved again from the stored signed value and the payee registry as of the decision: an owner who registered or changed the payee's country since the review opened is heard, and a stored signed value that now contradicts it refuses the approvalJURISDICTION_MISMATCH. The mandate's allowed-jurisdictions term and any enforced jurisdiction rule are then judged on it (JURISDICTION_NOT_ALLOWED, or the molecule's own code). With no effective jurisdiction at the decision — a review stored before the signed jurisdiction was kept, or one whose payee's registered country has since been cleared — a review under either is refusedAPPROVAL_INPUT_UNAVAILABLE, notJURISDICTION_REQUIRED; the review stays pending and the agent may ask again.
An input that cannot be reconstructed — the rate counter, the breaker store or the payee
registry unreadable, a review stored before the signed resource was kept, under a mandate that
constrains resource, or the missing jurisdiction above — refuses the approval with APPROVAL_INPUT_UNAVAILABLE; the review stays pending. A MODIFY
receives the same inputs; its block closes the review as modified rather than leaving it pending.
One known approximation: if the agent's rate limit is switched on after its request escalated, within the same window, that request was never counted, and an approval in that window reads the count one call low. Accepted — it is bounded to one call, in one window, once.
10.4.2. Revoking a mandate closes its pending reviews. In the same transaction that makes the
mandate inactive, every review still pending under it becomes denied, decided by system,
with the reason MANDATE_REVOKED — or MANDATE_SUPERSEDED when the mandate was revoked because
it was reissued (identity rotation, resource-scope pin, payload-binding toggle). This holds for
every revoke path: the owner's or an admin's revoke, decommissioning (which revokes all of an
agent's mandates), a reissue, and a delegation-chain revoke. The closure emits what a reviewer's
deny emits (an anchored block evidence event with that reason code, and the review's
trust-graph snapshot) but no signed vote, since no person decided. The status route reports the
closure code as the review's reasonCode, so a polling agent learns it must ask again. A closed
review can never be approved (ALREADY_DENIED), and no hold is minted on a revoked mandate: the
hold for an approval is inserted only if the mandate is still active, checked under the same
lock the revoke takes, so an approval that races a revoke is refused MANDATE_REVOKED. The
same holds for a live authorize: a revoke that commits after the gate read the mandate as
active, but before its hold is inserted, refuses that authorize MANDATE_REVOKED and mints no
hold.
10.5. An unresolved escalation expires — ESCALATION_EXPIRED. The expiry (24 hours by default)
bounds how stale a review can be; nothing else about its age is checked, because everything
the approval depends on is re-read at approval time (10.4.1), and the hold it mints carries its
own TTL from the moment of approval.
10.6. Callers MUST poll to resolution and act only on an approved status carrying an
authorizationId.
11. Payment execution binding (x402)
11.1. Authorize before pay. Payment MUST be bound to an authorizationId issued by
a permitting verdict.
11.2. The binding is amount-exact and single-use. A payment whose amount differs from the
authorized amount MUST be refused with AMOUNT_MISMATCH; a reused authorization
MUST be refused.
11.3. Settlement is reconciled by capture, carrying the settlement reference.
11.4. No custody. The protocol never holds funds.
12. Effects and reconciliation
12.1. A permitted action has a real-world effect whose outcome may not be known synchronously. Effect state transitions are recorded on a hash-chained ledger: each transition commits to the digest of the previous one plus its own canonical content.
12.2. A verifier MUST reject an invalid transition, a sequence gap, a broken chain link
or a digest mismatch — INVALID_EFFECT_TRANSITION, SEQUENCE_GAP, CHAIN_LINK_BROKEN,
DIGEST_MISMATCH.
12.3. An effect whose outcome is genuinely unknown is recorded as such rather than guessed, and resolved by reconciliation.
13. Evidence and anchoring
13.1. Every decision MUST produce an evidence record.
13.2. Records MAY be anchored individually or batched. In batch mode records are accumulated, hashed into a Merkle tree, and a single root is anchored.
13.3. GET /magp/evidence/{id}/proof returns an inclusion proof: the leaf, the path, and
the anchored root.
13.4. A verifier MUST be able to check inclusion offline, given only the leaf, the proof and the anchored root. Verification MUST NOT require the platform.
13.5. Leaf stability. The set of fields in a leaf is part of the protocol. Adding one changes every digest and invalidates previously anchored proofs; a change to leaf composition is therefore a breaking change (§18).
13.6. Execution Receipt. A second, separate signed-proof artifact, issued after a
value-bearing action settles: POST .../capture best-effort issues one binding what was
actually charged back to the Action Passport (§8) the action was authorized against
(actionPassportId, authorizedPayloadDigest), alongside a digest of what was actually
executed (executedPayloadDigest) and whether the two are within the authorized bound
(payloadMatch). Unlike the Merkle-anchored evidence above, a receipt is a standalone
Ed25519 signature over a canonical (sorted-key JSON) message — verifiable with no anchored
root, no Merkle path, just the issuer's published public key (GET /action-passport/pubkey).
GET /action-passport/receipt/verify/{authorizationId}(public) answersintegrityValid(the signature checks out),payloadMatch,outcomeStatus, andsettlementEvidence(below) for any relying party, with no authentication and no state about who is asking.receiptVersion. Same discipline as leaf stability (13.5), applied to a signed message instead of an anchored leaf: the receipt's field set is versioned, and a version bump adds fields only to future receipts. A verifier reconstructs the exact message a given row was originally signed with — an older row's reconstruction MUST NOT gain the newer version's fields (even asnull/a default), because that is not the string that was actually signed and would make every historical receipt fail verification. Receipt version 2 addedsettlementEvidence; version 1's 9-field message (executionReceiptId,actionPassportId,authorizationId,agentDid,authorizedPayloadDigest,executedPayloadDigest,payloadMatch,outcomeStatus,issuedAt) is unchanged and still verifies.settlementEvidence(receipt version 2). What the capture producing this receipt rested on — the same taxonomy §8.7.9 defines for the hold itself (unattested,unclaimed_lowered,counterparty_attested,independently_confirmed,operator_resolved,reconciled_at_authorized), folded into the signed payload rather than left as an unsigned, mutable side-channel: a relying party checking the receipt's signature also gets a tamper-evident answer to how sure the platform was this settlement was real.
14. Decision records and the governance envelope
14.1. Alongside the anchored leaf, an implementation SHOULD record an off-leaf decision record: per-control results, stage timestamps, and the names of context fields evaluated.
14.2. Context field names are recorded; values MUST NOT be. A conformance assessment needs to distinguish "the control held" from "the situation never arose" — a spend-cap clause that never saw an amount was not tested, and scoring it as passed would be a false assurance. The values themselves are the tenant's.
14.3. The governance envelope is a canonical view over the request, carrying an
envelopeId and an integrity hash so a caller can bind a decision to the exact action
submitted.
14.4. Decision records are best-effort and MUST NOT affect the verdict.
14.5. Where the anchored leaf carries a commitment to the decision record (a single hash covering both, rather
than anchoring twice), and the request carried a payload binding (§8.3.9) that has been VERIFIED — never one
merely claimed — the payload digest SHOULD be folded into that same commitment, so a verifier holding the
anchored digest can also prove which payload the decision was bound to. A new field folded into an existing
digest MUST be additive: omitted from the hashed content entirely when absent (never written as an explicit
null), so a record with nothing to add digests identically to one computed before the field existed. Anything
covered by this digest that has not yet been proven true — e.g. PAYLOAD_BINDING_INVALID, where the very point
of the code is that the claimed digest was never shown to be the agent's — MUST NOT be included.
15. Privacy-preserving verification
Optional. Where a counterparty must verify a property without learning the underlying value.
15.1. Range proofs. An amount may be proven within a mandate's cap without revealing it
(ZK_AMOUNT_PROOF_INVALID, ZK_JURISDICTION_PROOF_INVALID, ZK_POLICY_OK).
15.1.1. Who may ask, and against which terms. A private-authorize request (POST /magp/private-authorize) MUST be signed by the agent's own DID key, with the same key
resolution (including rotated and promoted DIDs), single-use nonce and freshness window as a
cleartext authorization (§8.3). The signed message is MAGP-PRIVATE-AUTHORIZE-v1| followed by
the hex SHA-256 of the canonical JSON (RFC 8785-style) of agentDid, action, nonce,
issuedAt, currency, both commitments, both proofs, transactionCommitment and
sessionBindingDigest (absent members as null). Its own domain means a signature made for it
never verifies as an authorize signature, or the reverse. transactionCommitment is
REQUIRED: a request without one is refused (ZK_TRANSACTION_COMMITMENT_REQUIRED, HTTP 400)
before anything else is checked, so every permitting verdict carries a capability bound to the
transaction it authorizes (§15.3).
15.1.2. The cap and the jurisdiction allow-list MUST be read from the agent's active mandate
for the action, never from the request. The cap is the permission's per-transaction bound
(mm:payAmount lteq, else mm:cumulativeSpend), floored to whole units of its currency; the
allow-list is an mm:jurisdiction isAnyOf constraint, in its stored order — the
allowed-jurisdictions term of §5.2.3, so a mandate issued with allowedJurisdictions (or a
delegated mandate that inherited one) gives the blind path a real list to prove membership in;
before that term existed, a mandate issued the standard way carried none and only the amount
was proven. The list is the same one the cleartext gate enforces, but the blind path proves the
agent's committed jurisdiction against it: it does not consult the payee registry, and
JURISDICTION_MISMATCH does not arise there (it names no payee). A request that
carries its own cap or allowedJurisdictions MUST be refused (ZK_CALLER_POLICY_REJECTED),
not silently re-interpreted. A range proof built against any other cap is refused
(ZK_CAP_MISMATCH); a term the mandate carries whose proof is missing is refused
(ZK_AMOUNT_PROOF_REQUIRED, ZK_JURISDICTION_PROOF_REQUIRED). A mandate with no jurisdiction
term has nothing to prove about jurisdiction — a membership proof is then not evaluated, and the
response and capability say so; a mandate with neither term, or with one in a form set
membership or a range proof cannot express, is refused (ZK_MANDATE_TERMS_UNSUPPORTED).
15.1.3. A contained agent, an inactive (never issued, or revoked) or expired mandate, a principal
whose authority does not hold, and an unmet enforced Standard are refused with the codes a
cleartext authorization uses. Because the amount is hidden and nothing is held, an agent whose
operating mode routes value to a human is refused (MODE_READ_ONLY,
ZK_HUMAN_REVIEW_UNAVAILABLE), as is an action whose mandate requires payload binding
(PAYLOAD_BINDING_REQUIRED) — a transaction commitment is not a payload binding.
15.1.4. What a private-authorize verdict does not establish, and MUST state: it reserves
no hold, so it does not draw down or check the cumulative budget; it does not evaluate merchant,
resource or other constraints, prohibitions, Standards or SOP rules. The response lists these
under notEvaluated. A counterparty that relies on it still has to enforce them.
15.2. Selective disclosure. BBS presentations prove selected credential attributes —
for example that assurance meets a threshold — without disclosing the rest
(BBS_PROOF_INVALID, BBS_KYC_LEVEL_TOO_LOW, BBS_PRESENTATION_OK).
15.3. Capability tokens. A permitting verdict MAY be returned as a signed capability
bound to a transaction commitment and a session, verifiable offline. Authorizing
transaction A and executing transaction B MUST fail with COMMITMENT_MISMATCH; a
capability used outside its session fails with SESSION_MISMATCH; an expired one with
CAPABILITY_EXPIRED.
15.3.1. A capability minted by a private authorization (payload v: 2) also names its subject:
agentDid, mandateId, action, currency, network, and the mandate terms proven (cap,
jurisdictions; null = the mandate had no such term). A verifier SHOULD check at least that
agentDid is the agent it authenticated; presented by another agent the capability fails with
CAPABILITY_AGENT_MISMATCH, and against a different expected mandate, action or currency with
CAPABILITY_SCOPE_MISMATCH. A verifier that requires a subject field MUST fail a capability
that lacks it (a v: 1 capability) rather than accept it.
16. Discovery and channels
16.1. HTTPS is the baseline transport. A DID document's serviceEndpoint locates a peer.
16.2. Peers MUST mutually verify DIDs before transacting. A counterparty SHOULD
independently re-evaluate a caller's authority rather than trusting that the caller's own
guard ran (§3).
Mutual verification has each peer sign the OTHER peer's nonce with its own key, so a peer MUST
sign only a nonce that is a plain random token (16–128 characters of A–Z a–z 0–9 _ -) and refuse
anything else: a nonce that is free text makes the signer an oracle — a malicious peer sends a
canonical authorize message (8.3), or a MAGP-SERVICE-v1 claim, as its nonce and obtains a valid
signature on it. Every MAGP message that authorizes anything contains |, which a token cannot.
16.3. Whom a Service acts for. A counterparty's re-evaluation (16.2) judges the caller by the caller's own policy — written by the caller's owner. It answers "may this agent do this?", never "is this an agent I act for?". A Service that executes with one owner's credentials (an agent's own tool gateway, an executor holding one owner's API key) MUST therefore also establish that the owner of those credentials admitted the caller:
- Admission. The Service MUST be configured with the agents it acts for, and MUST refuse a
validly signed request from any other agent with
AGENT_NOT_ADMITTED— after verifying the request signature, and before fetching the caller's policy or claiming any authorization (8.7.6). An unconfigured admission list is a startup error, never "admit everyone"; a malformed one likewise. - Owner binding. The Service MUST be bound to the principal that owns its credentials
(
gatewayOwnerPrincipal), a startup error when absent (GATEWAY_OWNER_UNBOUND). The policy bundle names the agent's owning principal (ownerPrincipal, signed with the rest); an admitted agent whose bundle names any other owner, or none, MUST be refusedGATEWAY_OWNER_MISMATCHbefore any claim. An agent of another owner can be admitted only by a bilateral delegation the gateway owner grants — not by its own mandate, and not by being listed. - Credential profiles. Admission to the Service is not access to every credential it holds: a
route, tool or skill MAY admit fewer agents, and an admitted agent outside it MUST be refused
CREDENTIAL_PROFILE_NOT_PERMITTED, also before its policy is fetched or anything claimed.
Without these, any agent on the platform whose own owner granted it an action of the same name
executes through that Service, with that Service's credentials, under rules the victim never saw; on
the stateful path the claim does not stop it, because the hold belongs to the caller and the caller's
owner chooses its counterparties (8.7.6). A Service that holds no one owner's credentials — a public
tool, or one that resolves each caller's own credential (the Credential Vault) — MAY admit every
governed agent, but only by saying so explicitly ('any'), never by omission, and it then takes no
owner binding. The reference guards take allowedAgents (agent DIDs, or 'any'),
gatewayOwnerPrincipal, and a per-route / per-tool / per-skill allowedAgents; the reference
gateway reads AGENTSAFE_ALLOWED_AGENTS and AGENTSAFE_GATEWAY_OWNER; create-metamynd-agent
admits the agent it provisioned and binds the gateway to that agent's owner.
16.4. Reporting what a Service did. The issuer sees an execution only when a Service claims an
authorization (8.7.6); a Service that executes without one (a bundle-only, non-financial gateway) or
refuses a request itself (16.3, a rule it evaluated, a binding failure) is otherwise invisible to the
owner whose credentials it holds. Such a Service SHOULD report each governed request it answers to
POST /policy/gateway-reports:
{ servedAgentDid, outcome: "executed" | "refused", reasonCode, httpStatus, request, reportId, occurrences },
where request is the agent's signed request as received (8.3), carrying its payloadDigest and the
authorizationId it presented when it had them, and servedAgentDid is the agent the Service acts for
— the caller when it is served, else another agent it admits. reportId (8–64 characters of
[A-Za-z0-9_-]) is the Service's own id for the report, kept across retries; occurrences is how many
identical refusals the report stands for (an executed report is always 1, REPORT_OCCURRENCES_INVALID
otherwise). The report is signed as the Service (MAGP-SERVICE-v1, action report, the request's nonce in
the authorization-id slot, fields
servedAgentDid | callerAgentDid | action | outcome | reasonCode | httpStatus | reportId | occurrences). The issuer MUST
record it only when that signature verifies and the served agent's owner has registered the signing DID
as an active counterparty — of either purpose; a Service that only reports is registered as report
(8.7.6), which does not make the owner registered-only; where the issuer requires proof of control
(8.7.6), only a proven entry counts — and MUST file it under that owner — never under the caller's — so a
refused foreign agent appears in the victim's audit trail and a party that registers someone else's
Service gains nothing. When the caller's signature verifies and its agent belongs to another owner, the
issuer MUST also show the report to that owner (the caller's side), without naming which of the gateway
owner's agents the Service acts for. An executed report MUST carry a request whose own agent signature verifies
(REPORT_CALLER_UNVERIFIED otherwise), checked against the agent's registered key; a refusal is recorded
either way, marked unverified. An unknown served agent is answered exactly as an unregistered Service
is (COUNTERPARTY_NOT_REGISTERED). The issuer consumes nothing before the row is written: a resent report
(the same service DID and nonce, or the same service DID and reportId re-signed later) is answered
ALREADY_RECORDED only when it was recorded, so a report that failed may be resent — a Service
SHOULD keep one the issuer could not take (unreachable, 429, 5xx) and resend it under its
reportId (the reference gateway's reportSpool). A refusal's request is whatever a caller sent, so a
Service SHOULD aggregate repeated refusals rather than drop them (the reference gateway: the first per
caller and reason at once, the rest of a one-minute window as one report with their occurrences), and
the issuer MAY limit reports per Service (REPORT_RATE_LIMITED, 429). An execution under a claimed
authorization is not reported: the claim already records it. Reports are visibility only — they never
change a decision, a hold or a cap — and are not anchored.
17. Security considerations
17.1. Fail closed. An unreachable gate means block. A guard that cannot verify a bundle for a value-bearing action means block. Only the secondary controls of §9 fail open, only on internal error, and only where the deployment has not configured them to fail closed.
17.2. Signed-last precedence. Server-derived, gateway-derived and signed operands MUST be applied over the unsigned itinerary, so a client-supplied key cannot shadow a signed amount or a server-counted rate (§6.3.2).
17.3. Nonce discipline. Single-use, consumed atomically, only after signature verification.
17.4. The unsigned context is an assertion. tool, jurisdiction and riskLevel are
claims by the agent (resource is signed, §8.3; a jurisdiction the gate enforces is the signed
top-level one, §8.3.12, never the itinerary's). A signature attributes a claim; it does not
verify it, and the counterparty re-check reads the same claim — so a re-check does not, by
itself, catch a lie. The defence is §6.3: every field carries a provenance, an owner-set
riskTier and a gateway-derived value are floors the agent cannot lower, a rule can require a
trusted source with requireProvenance, and a missing or unrecognised risk fails closed. The
optional context signature (§8.3.13) stops a relay rewriting the claim; it does not make it true.
Adding these fields to the signed message would break every deployed client and every
anchored proof, and would still only attribute them; it is not proposed.
17.5. Escalation is not permission. See §8.6.4.
17.6. Delegation cannot widen. See §5.4. An implementation that reverses the isNoneOf
rule silently grants authority nobody issued.
18. Extensibility and versioning
18.1. New operands and atoms extend the protocol. New operators do not.
18.2. An unknown reason code MUST be treated as a block (§8.6.5). An unknown atom MUST NOT silently pass.
18.3. Additive fields on the request or verdict are minor changes. Changes to the canonical message (§8.3), to leaf composition (§13.5), or to the order of checks (§8.5) are breaking.
18.4. GET /magp/schema publishes the request, verdict and rule-pack JSON Schemas, the
reason-code registry with SAFR aliases, the control namespaces and the atom catalog. It is
the machine-readable companion to this document.
19. Conformance
An implementation is conformant if it:
- resolves agent keys from the DID (§4.3.1);
- rebuilds and verifies the canonical message exactly (§8.3);
- applies the order of checks in §8.5, stopping at the first failure;
- treats
allowandobserveas the only permitting dispositions (§8.6.3); - never treats
escalateas permission (§8.6.4); - fails closed as §17.1 requires;
- enforces the narrowing invariant on any delegated mandate it accepts (§5.4);
- emits reason codes from Appendix A, or documents extensions;
- produces evidence verifiable offline (§13.4).
A guard need not implement the stateful steps (8, 9, 10, 16). It MUST defer them to the gate and MUST NOT report a permitting verdict on their behalf for a value-bearing action.
20. Reference endpoints
| Endpoint | Auth | Purpose |
|---|---|---|
POST /policy/mandate/authorize |
public | The gate |
POST /policy/mandate/authorize/{id}/capture |
public | Reconcile a hold |
POST /policy/mandate/authorize/{id}/void |
public | Release a hold |
GET /policy/escalations/{id}/status |
public | Follow a held action |
GET /policy/bundle/{did} |
public | Signed policy bundle |
GET /magp/policy/pubkey |
public | Bundle verification key |
GET /did/{did} |
public | DID document |
GET /magp/evidence/{id}/proof |
public | Inclusion proof |
GET /magp/schema |
public | Machine-readable protocol surface |
GET /standards/atoms |
authenticated | Atom catalog with required context |
Appendix A — Reason codes
Generated from the reference implementation's registry, which is also published at
GET /magp/schema. The SAFR §30 alias column maps each code to the canonical SAFR
vocabulary where one exists; — marks a permit/lifecycle code or a MetaMynd-specific
extension beyond the SAFR floor.
Permit / lifecycle (no SAFR deny alias)
| Code | Decision | Meaning | SAFR §30 alias |
|---|---|---|---|
AUTHORIZED |
allow | All checks passed. | — |
OBSERVED |
observe | Permitted but flagged for monitoring (SAFR §11). | — |
STANDARD_OBSERVE |
observe | A Standard molecule marked the action for monitoring. | — |
SOP_OBSERVE |
observe | An SOP molecule marked the action for monitoring. | — |
Request validation
| Code | Decision | Meaning | SAFR §30 alias |
|---|---|---|---|
MALFORMED_REQUEST |
block | The request body failed schema validation (missing/invalid field, e.g. a negative amount) before any policy evaluation ran. Returned in the same verdict envelope shape as every other decision (decision/reasonCode), not a raw validation-issue array, so a caller checking decision/reasonCode doesn't need a second response shape for this one path. |
— |
Identity (SAFR §6)
| Code | Decision | Meaning | SAFR §30 alias |
|---|---|---|---|
PRINCIPAL_UNVERIFIED |
block | The authorizing principal's authority does not currently hold: a mainnet agent beyond the free-tier caps (1 mainnet agent, 5 mandates). Testnet and did:key agents are not blocked on principal verification — it is reported on the verdict and in the registry, for a counterparty's own risk policy. (The code's name predates the free-tier model.) | IDENTITY_UNVERIFIED |
AGENT_NOT_FOUND |
block | Unknown agent DID. | IDENTITY_UNVERIFIED |
AGENT_KEY_UNVERIFIED |
block | BYOK key not yet proven. | IDENTITY_UNVERIFIED |
IDENTITY_KEY_MISMATCH |
block | Registered public key does not match the key embedded in the agent's own self-certifying DID. | IDENTITY_UNVERIFIED |
MANDATE_REVOKED |
block | Mandate has been revoked. The gate and every guard refuse an action with it when every mandate for that action was revoked (§8.5 step 1; a guard reads the bundle's revokedActions, §6.2.6). Also the reason, decided by system, on every review that was pending under it (§10.4.2), an approval refused because the mandate is no longer active, and a claim or capture of an unclaimed hold under a revoked mandate (§8.7.15). A delegated mandate is refused it too when any mandate it was delegated from is inactive (§5.4.9), and an owner's resource pin or payload-binding toggle that would reissue such a mandate is refused with it (409, nothing changes; §5.4.11). |
IDENTITY_REVOKED |
DELEGATION_CHAIN_BROKEN |
block | A delegated mandate whose chain cannot be walked to its root — a parent mandate is missing, the chain cycles, or it is deeper than the delegation bound (§5.4.9). Fails closed at authorize, at an approval, and on a claim or capture of an unclaimed hold under it, and refuses (409) an owner's resource pin or payload-binding toggle that would reissue it (§5.4.11). | IDENTITY_REVOKED |
DELEGATION_NARROWING_FAILED |
block | A reissue of a delegated mandate whose changed terms would not be a narrowing of its delegator's current terms (§5.4.2–§5.4.5, §5.4.11). An owner's resource pin or payload-binding toggle is refused with it (409, nothing changes); an identity rotation revokes the delegated mandate with it instead of carrying it forward. Also the reason the start-up repair revokes a pre-fix reissue that is wider than its delegation. | MANDATE_SCOPE_VIOLATION |
Envelope integrity (SAFR §5)
| Code | Decision | Meaning | SAFR §30 alias |
|---|---|---|---|
SIGNATURE_INVALID |
block | Ed25519 verification failed. | ENVELOPE_INTEGRITY_FAILURE |
CLIENT_PROTOCOL_VERSION_UNSUPPORTED |
block | The request genuinely verifies against the pre-resource (7-field) canonical message, not the current 8-field one — a real, valid key on an SDK old enough to predate resource (MAGP §8.3), not a broken or rotated key. Still blocked (the old message can't carry a signed resource-scope commitment), but distinguished from SIGNATURE_INVALID so the operator upgrades the client instead of rotating a key that was never the problem. |
ENVELOPE_INTEGRITY_FAILURE |
CONTEXT_SIGNATURE_INVALID |
block | Optional envelope signature present (Tier 1 context-claim binding, SAFR §5) but does not verify against the GovernanceEnvelope hash — the context was altered after the agent signed it, or signed with the wrong key. | ENVELOPE_INTEGRITY_FAILURE |
PAYLOAD_BINDING_INVALID |
block | Payload binding (§8.3.9, or a late binding §8.3.11) present but not valid: a payloadDigest without a payloadSignature (or the reverse), a digest that is not "sha256:" + 64 lower-case hex, or a signature that does not verify under the agent key over MAGP-PAYLOAD-v1 / agentDid / action / nonce / issuedAt / payloadDigest (at authorize) or MAGP-PAYLOAD-REBIND-v1 / agentDid / action / authorizationId / nonce / issuedAt / payloadDigest (a hold that already exists). A digest nobody signed binds nothing, so it is refused, never stored. | ENVELOPE_INTEGRITY_FAILURE |
PAYLOAD_BINDING_REQUIRED |
block | The mandate's owner requires this action's payload to be bound (requirePayloadBinding, §8.3.9) and the request carries no signed payload digest — or, at a claim, the hold carries none yet (a reviewer's MODIFY produces one) and must be bound (§8.3.11) before it can be claimed. Never granted unbound. Fix: send the payload with the request so the agent signs its digest, or bind the hold (POST /policy/mandate/authorize/:id/payload-binding) and then claim. |
ENVELOPE_INTEGRITY_FAILURE |
QUERY_NOT_BOUND |
block | Executor-side (an HTTP gateway, §8.3.9): the request to a governed route carries a URL query string — or a query, path parameter or fragment encoded into its path (%3F, ;, %23) — that the agent's signature and payload binding do not cover. Refused before anything is claimed, so no hold is consumed. A route may list the exact query keys it forwards (agentsafe-http-gateway allowedQuery, never a signed value field); those are forwarded unbound. |
ENVELOPE_INTEGRITY_FAILURE |
REPLAY_DETECTED |
block | Nonce already consumed. | ENVELOPE_INTEGRITY_FAILURE |
REQUEST_EXPIRED |
block | issuedAt outside the freshness window. | ENVELOPE_INVALID |
Mandate (SAFR §7)
| Code | Decision | Meaning | SAFR §30 alias |
|---|---|---|---|
NO_MANDATE |
block | The agent holds no mandate at all, and none was ever revoked for this action (that is MANDATE_REVOKED). | MANDATE_MISSING |
MANDATE_EXPIRED |
block | Outside mandate validity window. | MANDATE_EXPIRED |
MANDATE_NOT_YET_VALID |
block | Before the mandate validity window. | MANDATE_EXPIRED |
NO_PERMISSION_FOR_ACTION |
block | Action not granted by the mandate. | ACTION_NOT_PERMITTED |
PROHIBITED |
block | A mandate prohibition fired. | REGULATORY_PROHIBITION |
CONSTRAINT_FAILED |
block | A mandate constraint failed. | MANDATE_SCOPE_VIOLATION |
JURISDICTION_REQUIRED |
block | The request signed no jurisdiction (§8.3.12) while one is required: the mandate restricts jurisdictions for this action (its allowed-jurisdictions term), or an enforced SOP or Standard rule judges jurisdiction-not-allowed. Only the SIGNED top-level jurisdiction counts — an itinerary jurisdiction / mm:jurisdiction is the agent's unsigned word and is ignored. A hard block: it outranks an escalate. |
EVIDENCE_MISSING |
JURISDICTION_NOT_ALLOWED |
block | The request's signed jurisdiction (§8.3.12) is not in the mandate's allowed-jurisdictions list for this action. | MANDATE_SCOPE_VIOLATION |
JURISDICTION_MISMATCH |
block | The request's signed jurisdiction (§8.3.12) differs from the country the owner registered for the payee (payee directory, PUT /policy/payees/country) — or the owner's entries for that payee name more than one country. A registered country is authoritative: it is the effective jurisdiction, so the agent's contrary word is refused. Judged at authorize and again, on the registry as of that moment, at an approval or MODIFY. A hard block: it outranks an escalate. |
COUNTERPARTY_NOT_ALLOWED |
Exposure / counterparty (SAFR §22/§23)
| Code | Decision | Meaning | SAFR §30 alias |
|---|---|---|---|
SPEND_LIMIT_EXCEEDED |
block | Mandate budget constraint failed. | EXPOSURE_THRESHOLD_EXCEEDED |
CAP_EXCEEDED |
block | Lost the atomic cap-reservation race. | EXPOSURE_THRESHOLD_EXCEEDED |
AMOUNT_NOT_DETERMINABLE |
block | The gate could not establish how much value the action moves, so no cap could be applied to it (amount-unknown atom). | EVIDENCE_MISSING |
MERCHANT_NOT_ALLOWED |
block | Merchant outside mandate. | COUNTERPARTY_NOT_ALLOWED |
COUNTERPARTY_NOT_ALLOWED |
block | Counterparty outside mandate. | COUNTERPARTY_NOT_ALLOWED |
ROUTE_NOT_ALLOWED |
block | Route outside mandate. | ACTION_NOT_PERMITTED |
RATE_LIMIT_EXCEEDED |
block / escalate / observe | A time-windowed rate limit fired (rate-limit-exceeded atom). | RATE_LIMIT_EXCEEDED |
CONTROL_UNAVAILABLE |
block / escalate | A secondary control could not run and the deployment is configured to fail closed on them (SECONDARY_CONTROLS_FAIL_CLOSED): the rate window or circuit-breaker lookup blocks; the spend-anomaly check or assurance lookup escalates to a person. Off by default, where those controls fail open (§9). | EVIDENCE_MISSING |
Policy / SOP / standards (SAFR §8)
| Code | Decision | Meaning | SAFR §30 alias |
|---|---|---|---|
UNAUTHENTICATED_MCP_CALL |
block | An MCP tool call arrived without an authenticated principal for the gate to attribute it to (seeded MCP default policies). | IDENTITY_UNVERIFIED |
STANDARD_NONCOMPLIANT |
block | Missing/stale pin for an enforced Standard. | POLICY_VIOLATION |
STANDARD_RULE |
block / escalate / suspend / quarantine | A bound Standard molecule fired. | POLICY_VIOLATION |
SOP_RULE |
block / escalate / suspend / quarantine | A bound SOP molecule fired. | SOP_VIOLATION |
AUTHORIZATION_RULES_CHANGED |
block | A claim (or a capture without a claim) of an unclaimed hold refused because the agent's CURRENT SOP or bound-Standard rules no longer permit it (§8.7.16): the rules were tightened after the hold was granted. The hold is voided — its budget returns to the cap, the firing rule's own code is recorded as HOLD_VOIDED:<code> — and nothing executes; the agent asks again under the new rules. A hold already claimed is never re-judged. |
POLICY_VIOLATION |
ESCALATION_NOT_APPROVED |
escalate | A claim that asked to execute only a hold a person approved (§8.7.18) — the executor's own policy judged the request as needing review — presented a hold that no approval minted. The hold is left unclaimed and unchanged; the request still needs a person, so the agent escalates and waits for the decision. | HUMAN_REVIEW_REQUIRED |
CONTEXT_TOO_LARGE |
block | An agent with bound SOP or Standard rules sent a rule context (its itinerary and envelope metadata) too large to record whole with the hold (§8.7.16). Refused rather than trimmed: a trimmed context could drop a field a rule reads and let the hold slip the claim-time re-check. Send a smaller context. | ENVELOPE_INVALID |
SANDBOX_CALLER_SHARE_EXCEEDED |
block | The shared public sandbox only: this caller (told apart by a keyed hash of its network) already holds its share of the sandbox's rolling spend budget, so one visitor cannot keep the budget drained for everyone else. The budget heals as the window rolls past; a real tenant's mandate never returns this. | POLICY_VIOLATION |
Evidence-quality controls (SAFR §24). Author-facing codes for the evidence atoms.
| Code | Decision | Meaning | SAFR §30 alias |
|---|---|---|---|
EVIDENCE_INSUFFICIENT |
block / escalate | A required evidence type was not attested (evidence-requirement atom). | EVIDENCE_MISSING |
EVIDENCE_CONFIDENCE_LOW |
block / escalate | Attested evidence confidence below the required minimum (evidence-confidence-below atom). | EVIDENCE_QUALITY_LOW |
Containment / circuit breaker (SAFR §21)
| Code | Decision | Meaning | SAFR §30 alias |
|---|---|---|---|
AGENT_SUSPENDED |
suspend | Agent contained (suspended) by a prior firing rule — reinstate to act again. | CIRCUIT_BREAKER_ACTIVE |
AGENT_QUARANTINED |
quarantine | Agent contained (quarantined) by a prior firing rule — reinstate to act again. | CIRCUIT_BREAKER_ACTIVE |
AGENT_DECOMMISSIONED |
decommission | Agent decommissioned (retired) by its owner — final: it is never reinstated; a new agent takes over its work. | CIRCUIT_BREAKER_ACTIVE |
CIRCUIT_BREAKER_OPEN |
block | A scoped circuit breaker (agent/tenant/tool/counterparty) is open — tripped manually or by failure rate. | CIRCUIT_BREAKER_ACTIVE |
Human review (SAFR §12)
| Code | Decision | Meaning | SAFR §30 alias |
|---|---|---|---|
MODE_READ_ONLY |
block | READ_ONLY operating mode denies a value-bearing action. | ACTION_NOT_PERMITTED |
MODE_REVIEW |
escalate | Operating-mode floor routed to human review. | HUMAN_REVIEW_REQUIRED |
MODE_RESTRICTED_REVIEW |
escalate | RESTRICTED mode: every value-bearing action needs a human. | HUMAN_REVIEW_REQUIRED |
MODE_SUPERVISED_REVIEW |
escalate | SUPERVISED mode: high-consequence action needs a human. | HUMAN_REVIEW_REQUIRED |
CONTEXT_UNVERIFIABLE |
escalate | A rule needed a context field (e.g. riskLevel) and could not get a well-formed value from a source it trusts — missing, unrecognised, or less trusted than the rule requires (spec §6.3). | HUMAN_REVIEW_REQUIRED |
REASSESSMENT_PENDING |
escalate | A material authority change awaits owner review. | HUMAN_REVIEW_REQUIRED |
SPEND_PATTERN_ANOMALY |
escalate | Amount deviates from the agent's own recorded spend history (graph-backed baseline, not a preset cap). | HUMAN_REVIEW_REQUIRED |
AGENT_NOT_CERTIFIED |
escalate | The agent's latest deployment-assurance certificate is 'blocked' (a required assurance gate failed or was never assessed). | HUMAN_REVIEW_REQUIRED |
ASSURANCE_STALE |
escalate | The agent's latest deployment-assurance certificate has passed its validUntil — the assessed facts are no longer current. | HUMAN_REVIEW_REQUIRED |
ESCALATION_PENDING |
escalate | Held awaiting approver resolution (§9a). | HUMAN_REVIEW_REQUIRED |
ESCALATION_EXPIRED |
block | Escalation TTL elapsed (§9a.4). | HUMAN_REVIEW_TIMEOUT |
ESCALATION_CONTEXT_ALTERED |
block | The pending escalation's stored request context no longer matches its row-integrity commitment made at escalate time (Tier 2 context-claim binding, SAFR §5) — reservation refused. | ENVELOPE_INTEGRITY_FAILURE |
MANDATE_SUPERSEDED |
block | Approval refused: the mandate the review was raised under is no longer the agent's current mandate for the action (a newer one has been issued) — the agent asks again (§10.4.1). Also the reason a reissue closes the reviews pending under the mandate it replaced (§10.4.2). | MANDATE_MISSING |
APPROVAL_INPUT_UNAVAILABLE |
block | Approval refused: an input the gate judges the request on could not be read or reconstructed at approval time (rate counter, breaker store, or the signed resource or signed jurisdiction of a review stored before it was kept) — refused, never skipped; the review stays pending (§10.4.1). | EVIDENCE_MISSING |
ESCALATION_APPROVED |
allow | Approver upgraded the held request (§9a). | — |
ESCALATION_DENIED |
block | Approver denied the held request (§9a). | — |
Decision token / capability binding (SAFR §20)
| Code | Decision | Meaning | SAFR §30 alias |
|---|---|---|---|
AMOUNT_MISMATCH |
block | x402 amount exceeds the authorized amount (§7a). | DECISION_TOKEN_INVALID |
COMMITMENT_MISMATCH |
block | Executed transaction does not match the authorized commitment. | DECISION_TOKEN_INVALID |
SESSION_MISMATCH |
block | Capability bound to a different agent↔MCP session. | DECISION_TOKEN_INVALID |
CAPABILITY_EXPIRED |
block | Capability past its expiry (§7.7). | DECISION_TOKEN_INVALID |
CAPABILITY_SIGNATURE_INVALID |
block | Capability signature invalid or wrong issuer key. | DECISION_TOKEN_INVALID |
CAPABILITY_OK |
allow | Capability signature + transaction/session binding verified. | — |
CAPABILITY_AGENT_MISMATCH |
block | The capability was issued to a different agent DID than the one presenting it — or carries no agent at all (a v1 capability) while the verifier requires one (§15.3). | DECISION_TOKEN_INVALID |
CAPABILITY_SCOPE_MISMATCH |
block | The capability names a different mandate, action or currency than the verifier expects, or names none (a v1 capability) where one is required (§15.3). | DECISION_TOKEN_INVALID |
Evidence quality (SAFR §24)
| Code | Decision | Meaning | SAFR §30 alias |
|---|---|---|---|
BBS_KYC_LEVEL_TOO_LOW |
block | Disclosed KYC level below the required floor. | EVIDENCE_QUALITY_LOW |
MetaMynd-specific extensions beyond the SAFR floor (no §30 alias)
| Code | Decision | Meaning | SAFR §30 alias |
|---|---|---|---|
HOLD_VOIDED |
void | Reservation expired or explicitly voided (§7a.4). | — |
SETTLEMENT_FAILED |
block | Facilitator could not verify/settle (§7a). | — |
AGENT_NOT_ADMITTED |
block | A Service-side guard refused a validly signed request from an agent it does not admit (allowedAgents, §16.3) — before fetching its policy or claiming anything. | COUNTERPARTY_NOT_ALLOWED |
CREDENTIAL_PROFILE_NOT_PERMITTED |
block | The agent is admitted to the Service but not to this credential profile (a route, tool or skill that admits fewer agents, §16.3) — refused before its policy is fetched or anything claimed. | COUNTERPARTY_NOT_ALLOWED |
GATEWAY_OWNER_MISMATCH |
block | The agent's signed bundle names an owner other than the principal that owns this Service's credentials (gatewayOwnerPrincipal, §16.3), or names none — refused before any claim. | COUNTERPARTY_NOT_ALLOWED |
GATE_UNREACHABLE |
block | Guard fail-closed on transport error: the issuer could not be reached (a network failure, or a 5xx). A Service-side guard returns it when the policy bundle fetch fails that way; a 4xx answer is not an outage. | — |
ZK_POLICY_OK |
allow | All zero-knowledge predicates verified blind. | — |
ZK_AMOUNT_PROOF_INVALID |
block | Bulletproofs range proof (amount ≤ cap) failed. | EXPOSURE_THRESHOLD_EXCEEDED |
ZK_JURISDICTION_PROOF_INVALID |
block | Set-membership proof (jurisdiction ∈ allow-list) failed. | REGULATORY_PROHIBITION |
ZK_CALLER_POLICY_REJECTED |
block | A private-authorize request carried its own policy (cap, allowedJurisdictions). The terms are read from the agent's active mandate; a caller-chosen policy is refused (HTTP 400) rather than ignored, so an old client fails loudly (§15.1). |
ENVELOPE_INVALID |
ZK_TRANSACTION_COMMITMENT_REQUIRED |
block | A private-authorize request carried no transactionCommitment (HTTP 400). Every allow returns a capability bound to the transaction it authorizes, so a request with no transaction to bind to is refused rather than answered with an unbound verdict (§15.1). |
ENVELOPE_INVALID |
ZK_CAP_MISMATCH |
block | The range proof was built against a cap other than the agent's mandate cap, so it proves nothing about this mandate. Rebuild it against the cap in the agent's policy bundle (§15.1). | ENVELOPE_INVALID |
ZK_AMOUNT_PROOF_REQUIRED |
block | The mandate caps this action but the private-authorize request carries no amount commitment + range proof (§15.1). | EVIDENCE_MISSING |
ZK_JURISDICTION_PROOF_REQUIRED |
block | The mandate restricts jurisdictions for this action but the private-authorize request carries no jurisdiction commitment + membership proof (§15.1). | EVIDENCE_MISSING |
ZK_MANDATE_TERMS_UNSUPPORTED |
block | The mandate has no term the zero-knowledge path can prove for this action (no spend cap, no jurisdiction allow-list), or has one in a form it cannot prove (a non-upper-bound cap, a jurisdiction exclusion). Use /policy/mandate/authorize (§15.1). | MANDATE_SCOPE_VIOLATION |
ZK_CURRENCY_NOT_ALLOWED |
block | The private-authorize request names a currency outside the mandate cap's currencies, or names none where the cap allows several (§15.1). | MANDATE_SCOPE_VIOLATION |
ZK_HUMAN_REVIEW_UNAVAILABLE |
block | The agent's operating mode (SUPERVISED/RESTRICTED) routes value-bearing actions to a human, and the zero-knowledge path can neither see the amount nor hold anything for a reviewer — so it refuses. Use /policy/mandate/authorize (§15.1). | HUMAN_REVIEW_REQUIRED |
BBS_PRESENTATION_OK |
allow | Issuer-signed attributes verified via BBS selective disclosure. | — |
BBS_PROOF_INVALID |
block | BBS proof invalid, wrong issuer key, or nonce mismatch (replay). | ENVELOPE_INTEGRITY_FAILURE |
POLICY_BUNDLE_OK |
allow | Policy bundle signature + freshness verified. | — |
POLICY_BUNDLE_STALE |
block | Bundle older than maxStaleness — fails closed for any action under a pinned key, else for a value-bearing one. | POLICY_VIOLATION |
POLICY_BUNDLE_UNSIGNED |
block | Bundle carries no signature — fails closed for any action under a pinned key, else for a value-bearing one. | POLICY_VIOLATION |
POLICY_BUNDLE_SIGNATURE_INVALID |
block | Bundle signature invalid or tampered (any tier). | ENVELOPE_INTEGRITY_FAILURE |
POLICY_BUNDLE_UNVERIFIED |
block | No pinned policy key and the bundle came over plain http — nothing authenticates it, so a value-bearing action fails closed. | ENVELOPE_INTEGRITY_FAILURE |
Action Passport (MetaMynd Governed Execution scope, Module E/F)
| Code | Decision | Meaning | SAFR §30 alias |
|---|---|---|---|
PASSPORT_EXPIRED |
block | The Action Passport bound to this authorization has passed its expiry — capture refused. | MANDATE_EXPIRED |
PASSPORT_ALREADY_CONSUMED |
block | The Action Passport was already consumed by a prior capture — single-use, replay refused. | DECISION_TOKEN_INVALID |
PASSPORT_REVOKED |
block | The Action Passport was revoked (its hold was voided) before capture. | DECISION_TOKEN_INVALID |
Automated containment (MetaMynd Governed Execution scope, Module H)
| Code | Decision | Meaning | SAFR §30 alias |
|---|---|---|---|
REPEATED_DENIALS_CONTAINED |
quarantine | Automated containment: this agent crossed the repeated-denial threshold within the rolling window and was quarantined (AUTO_CONTAINMENT_MODE). | CIRCUIT_BREAKER_ACTIVE |
Appendix B — Canonical examples
Canonical message
did:hedera:testnet:zEEZ…|flight-purchase|150|USD|skyward-air|9f2c…|2026-07-10T05:12:00Z
A narrowing delegation — parent caps at 500 across two merchants; the child caps at 50 against one of them, and repeats the parent's cumulative constraint because omitting it would widen:
{ "target": "flight-purchase",
"permission": [{ "target": "flight-purchase", "constraint": [
{ "leftOperand": "mm:payAmount", "operator": "lteq", "rightOperand": 50 },
{ "leftOperand": "mm:cumulativeSpend", "operator": "lteq", "rightOperand": 500 },
{ "leftOperand": "mm:merchant", "operator": "isAnyOf", "rightOperand": ["skyward-air"] }
]}]}
A refused widening — the same child with mm:payAmount raised to 5000 is rejected at
issuance with CONSTRAINT_WIDENED; omitting mm:payAmount entirely is rejected with
CONSTRAINT_DROPPED.
Appendix C — Atom catalog
Atoms are pure predicates over request context. Generated from the reference
implementation; GET /standards/atoms returns the live catalog and is authoritative.
New governance requirements are added as new atoms, which does not require a protocol
version bump (§18.1).
| Predicate | Required context | Fires when |
|---|---|---|
amount-over |
amount |
Fires when a single action amount exceeds a configured limit (per-transaction cap). |
amount-unknown |
amount |
Fires when the action carries no usable amount, or a NEGATIVE one — the gate cannot trust either for capping. A deny-by-default control for value-moving actions: author it with BLOCK ahead of a spend cap, otherwise an amount that is missing, unparseable, or negative passes the cap untested (amount-over only ever fires above the limit, so a negative amount clears every positive cap). A genuine $0 amount does NOT fire this — only attach it to actions that must always carry a real, non-negative amount. |
cumulative-over |
amount, cumulativeSpend |
Fires when cumulative spend (already-spent + this transaction) exceeds a configured total budget. |
risk-at-or-above |
riskLevel |
Fires when the assessed risk level is at or above the configured threshold. |
data-source-not-approved |
dataSourceId |
Fires when the action uses a data source not on the approved list. |
consent-missing |
consent |
Fires when explicit consent is absent for the action. |
text-matches |
prompt, output |
Fires when the prompt or output contains any of the configured terms. |
jurisdiction-not-allowed |
jurisdiction |
Fires when the action's jurisdiction is not on the allow-list. |
data-residency-violation |
dataResidency |
Fires when data would be processed in a region not on the allow-list. |
model-not-allowed |
model |
Fires when the agent uses an LLM model not on the approved list. |
tool-not-allowed |
tool |
Fires when the agent invokes a tool/function not on the approved list. |
pii-present |
piiPresent |
Fires when the action is flagged as involving personal data (PII). |
rate-limit-exceeded |
callCount |
Fires when the rolling call count exceeds a configured maximum. |
hol-trust-below-review |
holTrustScore |
Routes to human review when the counterparty's MetaMynd Trust Index (HCS-28) score is below a soft review line. Guidance, not a hard block — author it with an ESCALATE decision. The score is resolved server-side; no counterparty score → the atom does not fire. |
evidence-requirement |
evidenceTypes |
Fires when the action is not backed by every REQUIRED evidence type the agent attests to in evidenceTypes (missing evidence — including none supplied). A REQUIRE control (SAFR §24): author it with ESCALATE or BLOCK so an under-evidenced action is stopped or reviewed. |
evidence-confidence-below |
evidenceConfidence |
Fires when the attested evidence confidence is below a required minimum — or absent (SAFR §24). A min of 0 / unset is no requirement. Author with ESCALATE to route low-confidence actions to review. |
Appendix D — Future directions
Not implemented at 1.0. Recorded so implementers know the intended direction, and kept out of the normative text so nothing in the body is aspirational.
- Content-addressed rule packs. Each published rule-pack version addressed by content hash, so a bundle can name an exact immutable pack.
- HCS-10 channels. An on-chain, auditable channel binding for peers that require message-level durability rather than TLS request/response.
- WebSocket transport. A duplex binding for streaming interactions.
- HCS-2 registry discovery. Trustless discovery of DIDs and service endpoints via a mirror node, with the issuer offline.
- Signed bundle staleness enforcement at the edge, beyond the risk-tiered rules of §6.2.
Changes from 0.4
- Order of checks (§8.5) rewritten from 9 steps to 16, transcribed from the implementation. Containment, operating modes, rate limits, circuit breakers, OBSERVE folding and the reassessment floor were all absent.
- Reason codes: 26 → 59, now generated from the registry rather than maintained by hand.
- New: resource scope (§5.3), delegation and the narrowing invariant (§5.4), agent posture (§7), secondary controls (§9), effects and reconciliation (§12), decision records and the governance envelope (§14), privacy-preserving verification consolidated (§15).
- §8.3 gains the three signing details (key encoding, number stringification, field identity) that previously cost every non-JavaScript implementer a day each.
- Platform governance surfaces — supervisory access, sandbox programmes, incident routing, the trust graph — removed from scope into MetaMynd Platform Governance. They are not required for interoperability.
[PROPOSED]clauses moved to Appendix D; the body is now implemented-only.
