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_reference and the top-level action_hash.
  • 1.0 receipts have none of these. They still verify; record_integrity is null for 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 not null.
  • No normalisation of strings. No trimming, no case folding and no Unicode normalisation: "gbp" is not "GBP", and a precomposed é is not e plus 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 valuation block as provenance.
  • The flat and structured request forms produce the same hash: an amount_minor + asset request and an amount: { 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 asset or action_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.

  1. 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_hash from the specification above.
  2. 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
    
  3. CLI: node scripts/verify-receipt.mjs receipt.json [--expect-action action.json]
  4. Server, for a record you were handed: POST /v1/evidence-receipts/verify with { receipt, expected_action? }. No credential is needed. The response separates signature_valid, record_integrity and action_match. Fields from the record (decision, mandate_reference, action_hash) are echoed only when the signature holds.
  5. 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.

8 min read · Need something that isn't here? Browse all documentation