Evidence receipts
The Authority Record is the signed evidence receipt chain. The API objects
keep their names (evidence_receipts, /v1/evidence-receipts); "Authority
Record" is the concept they make up.
Every decision produces a signed receipt; every later state change (approval, execution, cancellation) produces a new receipt chained to the previous one by hash. The chain is provider-neutral, exportable JSON, verifiable without trusting mnd8t's database.
Each receipt binds principal → agent → mandate version → proposed action → decision. It records why mnd8t approved, rejected or escalated an action at one point in time. It does not prove that the transaction then executed or settled.
Three questions are kept apart throughout the API:
| Question | Where the answer lives |
|---|---|
| Is this a genuine, unmodified record issued by mnd8t? | valid, signature_valid, record_integrity |
| What did mnd8t decide at evaluation time? | the record's decision block |
| Would the agent be authorised now? | current_authority, reported separately |
A record of yesterday's APPROVE stays valid evidence after its mandate is
revoked. That does not mean the agent is still authorised.
Signature verification checks provenance and integrity; it does not establish the truth of customer-reported events. An execution status or outcome evidence in a receipt is what your executor or your systems reported, recorded faithfully — not something mnd8t independently observed.
Shape
{
"receipt_version": "1.1",
"receipt_id": "evd_…",
"organisation_id": "org_…",
"principal": { "type": "ORGANISATION", "reference": "acme_ltd" },
"agent": { "id": "agt_…", "external_id": "procurement_agent_12" },
"mandate": { "id": "mnd_…", "version": 3, "policy_hash": "sha256:…" },
"intent": {
"id": "int_…", "action_type": "PAYMENT", "financial_account_id": "fac_…",
"amount_minor": 84000, "policy_amount_minor": 84000,
"policy_currency": "GBP", "asset": "USDC",
"counterparty": "supplier_acme_cloud", "destination": "0x…",
"purpose": "cloud_infrastructure", "external_reference": "INV-123",
"submitted_at": "…"
},
"action_hash": "sha256:…", // digest of the action alone; see below
"decision": {
"status": "ESCALATE", "effective_status": "ESCALATE", "mode": "ENFORCE",
"reasons": ["HUMAN_APPROVAL_REQUIRED"], "decided_at": "…"
},
"approval": { "status": "APPROVED", "approver_reference": "usr_…", "comment": null, "acted_at": "…" },
"execution": { "status": "CONFIRMED", "provider": "SIMULATED", "external_reference": "tx_…", "confirmed_at": "…", "error": null },
"revocation": null,
"identity_attestation": { // null when none was presented
"protocol": "AFSP", "protocol_version": "0.1", "issuer": "platform.afsp.org",
"clearing_institution": "inst_sunrise", "agent_id": "procurement_agent_12",
"principal_id": null, "session_id": "sess_123", "package_id": "pkg_…",
"clearance_decision": "PROCEED_TO_KYC", "action_classes": ["EXECUTE"],
"not_before": "…", "not_after": "…", "single_use": true,
"token_digest": "sha256:…", "verification": "PRESENTED_UNVERIFIED"
},
"previous_receipt_hash": "sha256:…", // null on the first receipt
"signature": { "algorithm": "Ed25519", "key_id": "key_…", "value": "base64…" }
}
A receipt that records an AFSP assertion export (prototype; see
api.md) also carries external_assertion: { id, protocol, session_id, action_digest, assertion_hash, key_id, not_after }. It is null
on every other receipt.
identity_attestation sits beside mandate, not inside it. Together they
connect principal, session, mandate version and decision: the attestation
records who was acting and in which session their principal authorised; the
mandate records what they were permitted to do. The attestation is copied
into every later receipt for the same decision. verification is always
PRESENTED_UNVERIFIED today: mnd8t records the attestation it was given and
does not check the issuer's signature.
Receipt versions
- 1.1 adds
intent.action_type,intent.financial_account_id,intent.external_referenceand the top-levelaction_hash. - 1.0 receipts have none of these. They still verify;
record_integrityisnullfor them because there is no action hash to check.
Every decision returns a summary of its record straight away:
"authority_record": {
"receipt_id": "evd_…",
"receipt_version": "1.1",
"action_hash": "sha256:…",
"key_id": "key_2026_01",
"url": "/v1/evidence-receipts/evd_…", // GET the signed record
"verify_url": "/v1/evidence-receipts/verify" // POST to verify a presented record
}
Action hash
action_hash is a digest of the proposed action and nothing else. A system
about to execute a payment can compute it from its own instruction and
compare. A record that approved £5,000 to Supplier A for invoice INV-123
cannot then be presented as evidence for £5,500, a different payee, a
different account or a different invoice.
Specification (digest_version 1). Build an object with exactly these keys:
| Key | Value |
|---|---|
digest_version |
the string "1" |
action_type |
"PAYMENT" or "TRANSFER". Required. |
asset |
the asset that moves, e.g. "GBP", "USDC". Required. |
amount_minor |
integer minor units of that asset, ≥ 0. Required. |
financial_account_id |
the account drawn on, or null |
counterparty |
the counterparty's external id, or null |
destination |
the destination address or account, or null |
purpose |
the purpose code, or null |
external_reference |
your invoice or order reference, or null |
Serialise it with the canonicalisation below and take
sha256: + the lowercase hex SHA-256 of the UTF-8 bytes.
Rules that matter for interoperability:
- Absent and null are the same. An omitted optional field is hashed as
null. An empty string is notnull. - No normalisation of strings. No trimming, no case folding and no
Unicode normalisation:
"gbp"is not"GBP", and a precomposedéis noteplus a combining accent. The values are those mnd8t evaluated. Normalise in your own system before you call mnd8t, not afterwards. - The policy-currency valuation is not part of the action. It is
recorded in the receipt's
valuationblock as provenance. - The flat and structured request forms produce the same hash: an
amount_minor+assetrequest and anamount: { asset, asset_amount_minor }request for the same payment hash identically. - Inputs that cannot be represented are refused, not coerced: a decimal
or negative amount, an amount sent as a string, a missing
assetoraction_type.
Test vectors, including equivalent and non-equivalent pairs and refused
inputs: action-digest-vectors.json.
Each valid vector gives the input, its exact canonical serialisation and
the expected action_hash; each invalid one is an input an implementation
must refuse. The gateway and the SDK are both tested against this file
(packages/evidence/test-vectors/action-digest.json in the repository). Any change to these
rules is a new digest_version.
Canonicalisation
Signatures cover the canonical form of the receipt without its signature
field: object keys sorted by UTF-16 code units, no whitespace, standard JSON
string escaping, and integers only (the serialiser rejects floats — all
money is minor units). This is a strict subset of RFC 8785 JCS sufficient for
receipt content. The same algorithm is implemented server-side and in the
@mnd8t/sdk client, so a receipt verifies identically wherever you check it.
Verifying
A record verifies when its Ed25519 signature checks against the published
key named by signature.key_id, and, for a 1.1 receipt, when its
action_hash is the digest of the action recorded in its own intent
block. Pass the action you hold as well, and you also learn whether the
record is for that action.
- Your own code. The receipt is canonicalised JSON with an Ed25519
signature, so any standard crypto library can verify it against the
published public key. Recompute
action_hashfrom the specification above. - SDK (local, independent):
const { receipt } = await client.receipts.get(id); const result = await client.receipts.verify(receipt, { expectedAction: myInstruction }); // result.valid, result.signature_valid, result.record_integrity, result.action_match - CLI:
node scripts/verify-receipt.mjs receipt.json [--expect-action action.json] - Server, for a record you were handed:
POST /v1/evidence-receipts/verifywith{ receipt, expected_action? }. No credential is needed. The response separatessignature_valid,record_integrityandaction_match. Fields from the record (decision,mandate_reference,action_hash) are echoed only when the signature holds. - Server, for a stored record by id:
GET /v1/evidence-receipts/{id}/verify.
The server endpoints ask mnd8t to vouch for itself; 1–3 do not.
Any field change — an amount, a counterparty, the agent, the mandate version,
the decision, a reason code, a timestamp — breaks verification. The
previous_receipt_hash chain makes the sequence of states for one decision
tamper-evident as a whole.
Current authority
POST /v1/evidence-receipts/verify also returns current_authority, kept
apart from the verification result. For a caller from the record's own
organisation holding receipts:read, it reports the mandate as it stands
now: mandate_status (for example PUBLISHED or REVOKED),
current_version, whether the recorded version is still current, expiry and
revocation time. It does not re-evaluate the action against today's budgets
or counterparties; use POST /v1/mandates/{id}/simulate for that. Anyone
else gets { status: "NOT_CHECKED", reason }: holding a record proves what
it says, but whether the authority behind it has since been withdrawn is the
principal's business.
Escalations
An ESCALATE record is never rewritten. When a person approves or rejects,
mnd8t issues a new record for the same decision, carrying the same
action_hash, with previous_receipt_hash pointing at the escalation
record. GET /v1/decision-intents/{id}/receipts returns the chain in order.
Key management
Signing keys are Ed25519, loaded from a secret manager and never committed
anywhere. Each record names the key that signed it in signature.key_id.
GET /.well-known/mandate-keys.json publishes every key that can verify a
record: { keys: [{ key_id, algorithm, public_key_pem, status }] }, where
status is ACTIVE for the one key that signs new records and RETIRED
for earlier ones. Pick the key whose key_id matches the record. Never fall
back to another key: a key_id that is not published means the record was
not signed by this service.
To rotate: generate a new key (pnpm generate:keys) and make it
EVIDENCE_SIGNING_KEY_ID / EVIDENCE_SIGNING_PRIVATE_KEY_PEM. Then add the old key's
public half to EVIDENCE_RETIRED_PUBLIC_KEYS, a JSON array of
{ "key_id", "public_key_pem" }. Records signed with the old key keep
verifying everywhere. The service refuses to start if that list is malformed,
repeats a key id, contains anything but an Ed25519 public key, or contains a
private key.