Allowly Receipt Format Specification

September 16, 2026 · View on GitHub

Version: 4 Status: Stable License: This specification is published under CC-BY 4.0. Reference implementations are Apache 2.0.


1. Purpose

A receipt is a signed, tamper-evident record of a single authorization event — either an authorization decision made when an authorization-check service handled a request, an event tied to an authorization, or a daily checkpoint committing to a set of signed receipts. A receipt does not prove that a downstream action occurred. This document specifies the canonical format, signature scheme, and verification algorithm so that any party holding a receipt and an authenticated issuer public key can verify the receipt offline, without contacting the issuer.

Two kinds of receipts share the same format:

  • Action receipts record a single authorization decision: at time T, the issuer decided that a request for action A by agent G on behalf of user U was allowed, denied, required confirmation, or required escalation, under authorization C. These are produced by the issuer's /check endpoint and do not prove the action occurred.
  • Event receipts record authorization lifecycle events and workspace receipt checkpoints: creation, revocation, client-reported budget settlement, client-reported escalation resolution, or a daily commitment to a signed receipt set.

Both kinds of receipts use the same JSON structure, the same canonicalization, the same signature scheme, and the same verifier. They differ only in the values of a few fields (§3.3). An auditor presented with a dispute typically needs both: event receipts show what the issuer recorded about creation, settlement, resolution, and receipt-set commitments, while action receipts show the decisions the issuer recorded under that authorization. Neither proves the truth of client-supplied facts or that a downstream action occurred.

The goals of this format are, in order:

  1. Third-party verifiability. An auditor, regulator, or end-user who retained an issuer public key authenticated directly or against a retained fingerprint can verify a receipt without the issuer's continued cooperation or uptime.
  2. Tamper evidence. Any modification to the receipt content after signing is detectable.
  3. Portability. A retained receipt and trusted public key remain verifiable if the issuer goes out of business, the customer switches vendors, or the verifier is written in a different language.
  4. Simplicity. The verification rules are explicit and dependency-light enough to implement consistently across languages.

Non-goals: this spec does not define the decision logic that produced the receipt, the policy language used to describe permissions, or the transport used to deliver receipts. It defines only the signed artifact and how to verify it.

2. Terminology

The key words MUST, MUST NOT, SHOULD, SHOULD NOT, and MAY in this document are to be interpreted as described in RFC 2119 and RFC 8174.

  • Receipt — a JSON object conforming to §3 plus its signature. Either an action receipt or an event receipt. A receipt always carries a real Ed25519 signature; an in-flight pending state is a transport-layer concept the issuer surfaces separately (§5.3).
  • Action receipt — a receipt recording a single authorization decision when an action was checked or requested. See §3.3.
  • Event receipt — a receipt recording an authorization-related event (creation, revocation, budget settlement, escalation resolution, or receipt checkpoint). See §3.3.
  • Issuer — the entity that produced and signed the receipt. workspace_id is its signed claim; the verifier authenticates that identity by independently trusting the expected workspace and key (§7).
  • Subject — the end-user identifier on whose behalf the client says the agent acts, or for whom it registers an authorization. Identified by user_id.
  • Verifier — any party validating a receipt.
  • Checkpoint receipt — a receipt.checkpoint event receipt whose signed context commits to one UTC day's registered signed non-checkpoint receipt set (§3.7).
  • Canonical form — the byte sequence produced by applying the canonicalization rules in §4 to a receipt's payload.

3. Receipt structure

A receipt is a JSON object with the following top-level fields. All fields are required except where marked conditional (action/event, by receipt kind) or optional (policy_eval, §3.6). Unknown fields MUST NOT be present; verifiers MUST reject receipts containing unknown top-level fields.

{
  "schema_version": "4",
  "receipt_id": "rcp_01HXZ2B3QW4N5M6P7R8S9T0V1W",
  "workspace_id": "ws_01HXA1B2C3D4E5F6G7H8J9K0L1",
  "issued_at": "2026-04-21T14:32:17.482Z",
  "decision": "allow",
  "reason": "authorization_granted_action_active",
  "user_id": "emp_8821",
  "agent_id": "referral_outreach",
  "action": "outreach.send",
  "resource": "edge:emp_8821:conn_9f2a",
  "context": {
    "initiated_by": "user",
    "origin": "chat",
    "session_id": "sess_7f2"
  },
  "authorization_id": "auth_01HXZ2A0K1L2M3N4P5Q6R7S8T9",
  "engine_version": "2026-04-17.1",
  "alg": "Ed25519",
  "key_id": "projects/allowly-prod/locations/global/keyRings/allowly-signing/cryptoKeys/ws_01HXA1/cryptoKeyVersions/3",
  "signature": "base64url(...)"
}

3.1 Field definitions

FieldTypeRequiredNotes
schema_versionstringyesReceipt wire-format version. MUST be exactly "4". Wire versions are plain integers; each distinct wire format has exactly one identity.
receipt_idstringyesUnique receipt identifier. Conventionally rcp_ plus a ULID; ULID random suffixes are not necessarily monotonic within the same millisecond.
workspace_idstringyesIssuer identifier. Used to look up the verification key.
issued_atstringyesRFC 3339 timestamp in the exact YYYY-MM-DDTHH:MM:SS.sssZ UTC millisecond profile.
decisionstringyesSee §3.3 for allowed values by receipt kind.
reasonstringyesMachine-readable reason code. Human-readable strings MUST NOT appear here.
user_idstringyesOpaque identifier of the end-user. Customer-defined; issuers and verifiers MUST NOT assume any particular structure. SHOULD NOT contain personally identifiable information (§10.6).
agent_idstringyesOpaque identifier of the agent or acting principal. Customer-defined. For human-initiated actions, this identifies the actor's role (e.g. controller, dba).
actionstring | absentconditionalPresent on action receipts. The action name being checked (e.g. email.send, contact.enrich). Format is issuer-defined but conventionally dotted. MUST be absent on event receipts.
eventstring | absentconditionalPresent on event receipts. One of "authorization.create", "authorization.revoke", "budget.settle", "escalation.resolve", or "receipt.checkpoint". MUST be absent on action receipts.
resourcestring | nullyesAn identifier for the target of the action, or null. Always null for authorization create/revoke receipts. For budget.settle and escalation.resolve, this MAY carry the resource the event was bound to. Issuers MUST NOT include the resource's contents, only an identifier.
contextobjectyesAn opaque object of additional facts the issuer considered. Contents are issuer-defined. Verifiers MUST canonicalize the object by the same §4 rules as the rest of the payload and MUST NOT alter its values, drop or add keys, or reorder arrays; nested object keys are re-sorted per rule 3 like any other object. MAY be empty ({}).
authorization_idstring | nullyesThe authorization record this receipt relates to. For action receipts: the authorization that authorized the decision, or null if no authorization matched. For authorization-related event receipts: the related authorization_id. For workspace-wide receipt.checkpoint, MUST be null.
engine_versionstringyesVersion of the issuer's decision logic at time of issue. Format is issuer-defined.
policy_evalobject | absentoptionalAction receipts only; MUST be absent on event receipts. Records the outcome of per-action condition evaluation: which condition (if any) routed the decision, and the evaluated context value. The rules in force are pinned by authorization_id (§3.3). See §3.6.
algstringyesMUST be "Ed25519". This field is part of the signed payload.
key_idstringyesOpaque identifier selecting the issuer public key. SHOULD be the full key-version resource path. This field is part of the signed payload.
signaturestringyesCanonical unpadded base64url encoding of the 64-byte Ed25519 signature. See §5.

3.2 Extensibility

Verifiers implementing this specification accept only "4" and MUST reject every other value. Future incompatible receipt shapes require a new wire version and matching verifier release. Issuers MUST NOT emit fields or event values under a version that does not define them.

3.3 Receipt kinds

Receipts come in two kinds, distinguished by which of two mutually exclusive fields is present:

Action receipts record a single authorization decision when an action is checked or requested. They are produced by the issuer's decisioning endpoint (conventionally POST /v1/check) and do not prove the action later occurred.

  • action — present, set to the action name being checked. Conventionally dotted (email.send, contact.enrich, payment.approve).
  • eventMUST be absent.
  • decision — one of "allow", "deny", "confirm", "escalate".
  • authorization_id — the matching authorization, or null if no authorization matched.
  • resource — an identifier for the action's target, or null.

Event receipts record an authorization-related event or a workspace-wide receipt checkpoint. Authorization lifecycle events are produced when the issuer records creation/revocation or an authenticated customer client reports cost settlement or escalation resolution.

  • event — present, one of:
    • "authorization.create" — an authenticated customer client registered a set of actions for an agent. Issuer-specific context or reason fields may say what the client reported about approval; the event itself does not prove a human action.
    • "authorization.revoke" — the issuer recorded revocation, with issuer-defined context describing its source.
    • "budget.settle" — an authenticated customer client reported an actual cost to reconcile with the estimate.
    • "escalation.resolve" — an authenticated customer client reported an approved or rejected escalation resolution.
    • "receipt.checkpoint" — the issuer committed to one UTC day's registered signed non-checkpoint receipt set (§3.7).
  • actionMUST be absent.
  • decision — one of:
    • "authorization_granted" — paired with event: "authorization.create".
    • "authorization_revoked" — paired with event: "authorization.revoke".
    • "budget_settled" — paired with event: "budget.settle".
    • "escalation_approved" — paired with event: "escalation.resolve" for a reported approval.
    • "escalation_rejected" — paired with event: "escalation.resolve" for a reported rejection.
    • "receipt_set_committed" — paired with event: "receipt.checkpoint".
  • authorization_id — the authorization being created, revoked, settled, or escalated. MUST NOT be null on those event receipts. It MUST be null on receipt.checkpoint.
  • resourceMUST be null for authorization create/revoke receipts. For budget.settle and escalation.resolve, this MAY carry the resource the event was bound to.
  • context — conventionally carries lifecycle metadata: the full action set at creation, expires_at, requires_confirm_for, requires_escalation_for, the creation source (csv_upload, onboarding_modal), an optional csv_hash or similar integrity identifier, an optional replaces field (see below); for revocations a revoked_by field (user, admin, expired, tombstone, or superseded when the revocation is part of a rule change) and, when revoked_by is superseded, an optional superseded_by: "<authorization_id of the successor>" forward pointer (see below); for budget settlements the originating check receipt id and client-reported estimated/actual cost evidence; and for escalation resolution an escalation object containing the escalation id, action, approver label, reported resolution status, and client-reported resolver identity.

Authorizations are immutable. There is no update event. Any change to an authorization — its actions, per-action constraints, or verb-routing rules (requires_confirm_for, requires_escalation_for, conditional routing) — is expressed as revoking the existing authorization and creating a new one, producing one signed authorization.revoke receipt and one signed authorization.create receipt. Because each authorization_id therefore refers to exactly one immutable rule set, the id alone pins the rules in force for every action receipt that references it; no revision counter is needed.

Lineage convention (non-normative). When a new authorization supersedes an old one, the change surfaces as a revoke of the predecessor plus a create of the successor. To make that supersession auditable rather than guessable, the two receipts SHOULD cross-reference each other:

  • the creation receipt's context SHOULD carry replaces: "<authorization_id of the predecessor>" (the backward pointer), and
  • the revocation receipt's context SHOULD carry revoked_by: "superseded" together with superseded_by: "<authorization_id of the successor>" (the forward pointer).

The forward pointer matters because a revocation otherwise carries no signal distinguishing a rule change from a genuine, standalone shutdown: an auditor seeing only a revoke cannot tell whether a successor exists, and absence of an optional backward pointer on some later create proves nothing. With revoked_by: "superseded" on the revoke receipt, the classification is local to that one signed receipt — no forward scan over later creates, and no time-window ambiguity (a successor that has not yet appeared versus one that never will). An issuer that wants auditors to be able to rely on "a revocation without a supersession marker is a standalone revocation" therefore SHOULD emit the forward pointer whenever, and only whenever, the revocation is part of a rule change.

Emitting the successor id on the revoke receipt requires the successor's authorization_id to be known at revoke time; see §8 for the recommended ordering. Verifiers do not validate replaces, revoked_by, or superseded_by — these are audit conveniences, not chain-integrity mechanisms (the whole receipt is issuer-signed, so a dishonest issuer can write any pointer it likes; the value is in making an honest issuer's intent legible).

Verifiers MUST enforce the following:

  • Exactly one of action and event is present. Receipts with both fields, or with neither, are rejected.
  • If event is present, it MUST be one of "authorization.create", "authorization.revoke", "budget.settle", "escalation.resolve", or "receipt.checkpoint". The corresponding decision MUST be valid for that event. authorization_id MUST NOT be null except on receipt.checkpoint, where it MUST be null. resource MUST be null for authorization create/revoke and checkpoint receipts. policy_eval MUST be absent. Checkpoint context MUST conform to §3.7.
  • If action is present, decision MUST be one of "allow", "deny", "confirm", or "escalate". The reserved event-only decisions (authorization_granted, authorization_revoked, budget_settled, escalation_approved, escalation_rejected) MUST NOT appear on action receipts. If policy_eval is present, it MUST conform to §3.6.

The two-field discriminator design (rather than a single overloaded field) makes the receipt kind explicit at the schema level. A field's presence tells you what kind of receipt it is; pairing rules become trivial to enforce.

3.4 Example authorization receipt

{
  "schema_version": "4",
  "receipt_id": "rcp_01HXZAUTHORIZATIONCREATE0000000",
  "workspace_id": "ws_01HXA1B2C3D4E5F6G7H8J9K0L1",
  "issued_at": "2026-04-21T14:30:00.000Z",
  "decision": "authorization_granted",
  "reason": "authorization_creation_reported_by_client",
  "user_id": "emp_8821",
  "agent_id": "referral_outreach",
  "event": "authorization.create",
  "resource": null,
  "context": {
    "actions": [
      "contact.enrich",
      {
        "name": "hiring.publish_feedback",
        "constraints": {
          "confirm_when": [
            { "field": "transcript_completeness", "lt": 100 },
            { "field": "candidate_ai_opt_out", "eq": true }
          ],
          "escalate_when": [
            { "field": "reviewer_id", "exists": false }
          ]
        }
      }
    ],
    "requires_confirm_for": ["outreach.send"],
    "expires_at": "2026-12-31T00:00:00Z",
    "source": "csv_upload_v2",
    "csv_hash": "sha256:abc123..."
  },
  "authorization_id": "auth_01HXZ2A0K1L2M3N4P5Q6R7S8T9",
  "engine_version": "2026-04-17.1",
  "alg": "Ed25519",
  "key_id": "projects/allowly-prod/locations/global/keyRings/allowly-signing/cryptoKeys/ws_01HXA1/cryptoKeyVersions/3",
  "signature": "base64url(...)"
}

3.5 The authorization chain

When a complete receipt set is available, an auditor can reconstruct the recorded story of an authorization from receipts with a given authorization_id. The chain consists of:

  1. Exactly one authorization.create receipt (the authorization grant itself).
  2. Zero or more action receipts (each check that matched this authorization). The rules in force for every one of them are exactly those recorded in the creation receipt — authorizations are immutable (§3.3), so authorization_id alone pins the rule set.
  3. Zero or more budget.settle receipts recording client-reported actual costs against estimates.
  4. Zero or more escalation.resolve receipts recording client-reported escalation resolutions under this authorization.
  5. At most one authorization.revoke receipt (if and when the authorization was revoked).

Every receipt in the chain is independently signed and cryptographically tied to the same authorization_id; a consumer can order presented receipts by issued_at. Verification proves each presented receipt is genuine and untampered; it does not prove the presented set is complete — a party can omit receipts (e.g. a revoke or a deny) and the remainder still verifies. Parties relying on completeness need separate evidence or an operational source they trust for that property; this format alone cannot authenticate the set as complete.

Rule changes never mutate a chain. Changing actions, constraints, or verb routing is expressed as revoking the old authorization and creating a new one (§3.3); the creation receipt SHOULD carry replaces and the revocation receipt SHOULD carry revoked_by: "superseded" with superseded_by, so successive chains form a walkable, bidirectionally linked lineage. Note that no-match denies (an action receipt with authorization_id: null, §3.1) pin no chain at all; if such a deny is issued during the gap between a revoke and its successor create, it belongs to no authorization and is reconstructable only from issued_at ordering. §8 recommends an ordering that minimizes this gap.

3.6 Conditional policy evaluation (policy_eval)

Issuers that route action decisions through per-action conditions MAY record the evaluation outcome in an optional top-level policy_eval object on action receipts. The block records which condition routed the decision. The rules that were evaluated are pinned by the receipt's top-level authorization_id — authorizations are immutable (§3.3), so the creation receipt for that id is the signed snapshot of the rules in force. The condition language itself remains issuer-defined and outside this format, consistent with the non-goals in §1.

Issuer convention (non-normative). An issuer MAY model conditional routing as per-action constraint attributes such as deny_when, confirm_when, and escalate_when. A deliberately small shape is recommended: each condition names one context field and one operator (eq, neq, lt, lte, gt, gte, in, or exists), condition lists are ORed, and there is no nesting or expression language. For example, an issuer might store { "field": "transcript_completeness", "lt": 100 } on an authorization, then normalize the matched condition in the action receipt as { "field": "transcript_completeness", "op": "lt", "value": 100 }. The receipt format only standardizes the normalized evidence in policy_eval; it does not standardize the authorization API's policy authoring syntax.

{
  "schema_version": "4",
  "receipt_id": "rcp_01J0Z7Q4BORDERLINE0CONFIRM",
  "workspace_id": "ws_01HXA1B2C3D4E5F6G7H8J9K0L1",
  "issued_at": "2026-06-09T17:04:09.114Z",
  "decision": "confirm",
  "reason": "confirm_condition_matched",
  "user_id": "cand_55ab2",
  "agent_id": "scout_referrals",
  "action": "hiring.publish_feedback",
  "resource": "application:req_2207:cand_55ab2",
  "context": {
    "initiated_by": "agent",
    "reviewer_id": "emp_3301",
    "transcript_completeness": 82,
    "candidate_ai_opt_out": false
  },
  "authorization_id": "auth_01HXZ2A0K1L2M3N4P5Q6R7S8T9",
  "engine_version": "2026-06-01.2",
  "policy_eval": {
    "matched_condition": { "field": "transcript_completeness", "op": "lt", "value": 100 },
    "field_value": 82
  },
  "alg": "Ed25519",
  "key_id": "projects/allowly-prod/locations/global/keyRings/allowly-signing/cryptoKeys/ws_01HXA1/cryptoKeyVersions/3",
  "signature": "base64url(...)"
}

3.6.1 Members

When policy_eval is present, both members MUST be present:

MemberTypeNotes
matched_conditionobject | nullThe single condition that routed the decision, expressed as the triple {"field": string, "op": string, "value": string | integer | boolean | null | array} — exactly those three members, no others. Arrays are for membership-style operators such as in and MUST contain only strings, integers, booleans, or nulls. null attests that conditions were evaluated and none matched. The set of operator names is issuer-defined; this spec constrains only the shape.
field_valuestring | integer | boolean | nullSnapshot of the evaluated context field at decision time. null when matched_condition is null or when the referenced field was absent from the request context. See §10.7 for snapshot-minimization guidance.

3.6.2 Rules

  • policy_eval MUST NOT appear on event receipts. Verifiers MUST reject event receipts that carry it.
  • Per §4.2 rule 6, non-integer numbers MUST NOT appear anywhere inside policy_eval, including inside matched_condition.value arrays. Fractional thresholds or values MUST be expressed as scaled integers (e.g. basis points, micros) or strings.
  • Issuers SHOULD emit policy_eval on every action receipt for actions that carry conditions — including allow decisions, where matched_condition: null attests evaluated, nothing fired. An absence of flags is itself evidence.
  • Canonicalization note: the lexicographic key sort places engine_version before policy_eval in the canonical form.

3.6.3 Fail-closed convention (non-normative)

If a condition references a context field that is absent from the request, issuers are encouraged to fail closed: return confirm with reason: "context_field_missing", set matched_condition to the unevaluable condition, and set field_value: null. The rationale: if the caller cannot demonstrate a fact the policy depends on (e.g. an opt-out flag), the safe decision is human review, and the receipt should record that this was the issuer's decision. The format permits but does not mandate this behavior.

3.6.4 What policy_eval does not prove

Like context (§7.1), the evaluated values originate from the customer's system. policy_eval attests that the issuer evaluated the stated condition against the stated value and routed accordingly — not that the value was true in the world.

3.7 Daily receipt checkpoints

A checkpoint is a workspace-wide event receipt with event: "receipt.checkpoint", decision: "receipt_set_committed", authorization_id: null, and resource: null. Its context MUST contain exactly:

MemberTypeNotes
period_startstringInclusive UTC midnight in the exact timestamp profile.
period_endstringExclusive UTC midnight exactly one day after period_start.
receipt_countnon-negative integerNumber of committed member receipts.
merkle_rootstringsha256: followed by 64 lowercase hex characters, computed below.
previous_checkpoint_idstring | nullReceipt id of the immediately preceding daily checkpoint, or null for the first checkpoint.
previous_merkle_rootstring | nullThat checkpoint's merkle_root, or null for the first checkpoint. Both previous fields MUST be null or both non-null.

The checkpoint issued_at MUST be at or after period_end. At construction time the issuer MUST define the member set as every signed, non-checkpoint receipt registered in that workspace whose signed issued_at lies in the half-open interval [period_start, period_end). Member receipt_id values MUST be unique. Checkpoint receipts are excluded to avoid self-reference. A verifier recomputes the commitment from the exact member receipts it was given and rejects a count or root mismatch.

3.7.1 Merkle root

The commitment uses SHA-256 with one-byte domain separation and no dependency on presentation order:

  1. For each full signed member receipt R, including its signature, serialize the entire object using §4.2 without applying §4.1's signature removal, then compute leaf = SHA256(0x00 || full_canonical_bytes).
  2. Sort the 32-byte leaf hashes in ascending unsigned byte order.
  3. For each adjacent pair compute SHA256(0x01 || left || right). If a level has an odd count, duplicate its final hash and pair it with itself. Repeat until one hash remains.
  4. For an empty member set, the root bytes are SHA256(0x02).
  5. Encode the root as sha256: plus lowercase hexadecimal.

Sorting leaf hashes makes the commitment a deterministic set commitment; array or export order is irrelevant. Including the full signed receipt bytes binds both the issuer signature and every signed field. Verifiers reject duplicate receipt ids, and receipt_count makes omissions explicit before root comparison.

3.7.2 Prior linkage and limits

Except for the first checkpoint, previous_checkpoint_id and previous_merkle_root SHOULD identify the most recent earlier checkpoint. When that prior receipt is available, verifiers SHOULD verify its signature, root equality, id equality, and that its period ends no later than the current period begins. A gap remains visible in the signed periods instead of making later checkpoints unverifiable.

A verified checkpoint proves that the presented member set exactly matches the signed count and Merkle root. It does not prove that the issuer registered every real-world event, retained every receipt before constructing the checkpoint, or published the checkpoint to an immutable external system. Prior links are signed issuer statements, not external timestamps. External anchoring is outside this specification.

4. Canonical serialization

To produce a byte sequence suitable for signing and verification, a verifier MUST canonicalize the receipt payload as follows.

4.1 Payload

The payload is the receipt object with the signature field removed. All other top-level fields are included.

4.2 Canonicalization rules

The canonical form is the payload serialized as JSON with the following normative rules. This is a custom deterministic JSON profile inspired by RFC 8785, not an RFC 8785-compatible subset: it excludes non-integer numbers and requires \uXXXX escapes for every control character. Raw JSON interchange MUST use unique object member names. A parsed-object verifier cannot recover duplicate names or the original spelling of number tokens; callers of such an API are responsible for rejecting non-conforming raw input before parsing.

  1. Encoding. Well-formed UTF-8, no BOM. Every string value and object member name MUST be a sequence of Unicode scalar values; unpaired UTF-16 surrogates MUST be rejected before canonicalization.
  2. Whitespace. No whitespace between tokens. Specifically: no spaces, no tabs, no newlines anywhere outside of string values.
  3. Object keys. Sorted in lexicographic order by UTF-16 code unit (the default Array.prototype.sort order in JavaScript). Applied recursively to every object, including nested objects (such as context). Caution: this is not Unicode code-point order, and the two diverge for keys containing characters outside the Basic Multilingual Plane (e.g. an emoji key sorts before U+FF61 under UTF-16 but after it by code point). Languages whose default string sort is by code point — including Python's sorted()MUST sort keys by their UTF-16 big-endian encoding (in Python, key=lambda k: k.encode("utf-16-be")) to match this rule.
  4. Array order. Preserved as-is. Arrays are ordered data; verifiers MUST NOT sort them.
  5. Strings. Serialized with double quotes. The following characters MUST be escaped: " as \", \ as \\, and control characters U+0000 through U+001F using \uXXXX lowercase-hex form (e.g. \u000a for newline). Non-ASCII characters MUST NOT be escaped; they appear as their UTF-8 byte sequence.
  6. Numbers. Raw JSON integer tokens MUST be written without a decimal point or exponent (1 is valid; 1.0 and 1e0 are invalid). Non-integer numbers MUST NOT appear in receipts; if present, verifiers MUST reject the receipt. Integers are further bounded to the I-JSON safe range, ±(2⁵³−1) inclusive (as in RFC 8785); a magnitude outside this range cannot be represented exactly by an IEEE-754 double consumer and MUST be rejected. Values that need a larger range MUST be expressed as strings.
  7. Booleans and null. Serialized as true, false, null.
  8. Separators. , between array/object elements, : between object keys and values. No surrounding whitespace.
  9. Resource limits. The payload root has depth 1 and counts as one node. Each array item and each object value adds one node; object member names do not add separate nodes. Payload depth MUST NOT exceed 32 and node count MUST NOT exceed 50,000. Verifiers MUST reject larger payloads before recursive serialization.

4.3 Reference canonical form

The payload for the example in §3 canonicalizes to (linebreaks for display only; the actual canonical form is one line):

{"action":"outreach.send","agent_id":"referral_outreach","alg":"Ed25519","authorization_id":"auth_01HXZ2A0K1L2M3N4P5Q6R7S8T9",
"context":{"initiated_by":"user","origin":"chat","session_id":"sess_7f2"},
"decision":"allow","engine_version":"2026-04-17.1","issued_at":
"2026-04-21T14:32:17.482Z","key_id":"projects/allowly-prod/locations/global/keyRings/allowly-signing/cryptoKeys/ws_01HXA1/cryptoKeyVersions/3","reason":"authorization_granted_action_active",
"receipt_id":"rcp_01HXZ2B3QW4N5M6P7R8S9T0V1W","resource":
"edge:emp_8821:conn_9f2a","schema_version":"4","user_id":"emp_8821",
"workspace_id":"ws_01HXA1B2C3D4E5F6G7H8J9K0L1"}

Note: action and event are mutually exclusive — the canonical form contains exactly one of them, never both. The lexicographic key sort places action before event, which matters for canonicalization correctness when generating event receipts vs action receipts.

5. Signature

The receipt carries exactly one signature over the canonical payload: an Ed25519 signature from the issuer's published key.

5.1 Algorithm

  • Algorithm: Ed25519 per RFC 8032.
  • Key: an asymmetric key held by the issuer. The public key is published at a well-known URL per §6.
  • Input: the canonical payload bytes from §4, passed to Ed25519's sign operation as the message.
  • Output: the 64-byte Ed25519 signature, base64url-encoded without padding per RFC 4648 §5.

Verifiers MUST use standard RFC 8032 Ed25519 verification over the raw canonical payload bytes. A signer MUST NOT pre-hash the canonical payload before passing it to an Ed25519 sign operation; doing so produces a different scheme that fails this verification algorithm. How a signing service implements the standard Ed25519 operation internally is outside this specification.

5.2 Protected algorithm and key identifier

Format 4 places alg and key_id at the top level. Removing only the signature field for canonicalization leaves both fields in the signed bytes, so neither the algorithm nor trust-anchor selector can be changed after issue. The signature field contains only the canonical unpadded base64url signature string. It is never empty or a placeholder; see §5.3.

5.3 Pending state is not part of the receipt format

Issuers commonly sign asynchronously to keep KMS latency off the decisioning hot path. During the gap between decision time and signature completion, no receipt object exists in this format — only an in-flight pending state, which is the issuer's responsibility to expose through its API in a structurally distinct way (typically as a small object carrying receipt_id, an estimated ready time, and a URL where the eventual signed receipt can be fetched).

This separation is deliberate. Pending is a transport-layer concern, not a receipt-format concern. Keeping pending out of the signed-receipt schema means:

  • Verifiers cannot accidentally accept an unsigned object. The schema check in §7 step 2 rejects anything whose signature is not a canonical base64url string of the right length.
  • Customers cannot accidentally serialize a pending response as audit evidence. The pending response and the signed receipt have different shapes; passing the wrong one to a verifier or a long-term store fails immediately, not silently.
  • The receipt format spec stays focused on a single artifact: the signed receipt.

Issuers MUST NOT emit any object claiming to be a format-4 receipt with signature set to a non-signature value (e.g. a placeholder string). Verifiers MUST reject any such object on the schema check.

5.4 Implementation notes on signing (non-normative)

These notes describe how signers typically produce the Ed25519 signature. They are informative only; none affects the wire format or verification algorithm.

  • Pure-software Ed25519 libraries (PyNaCl, libsodium, Go's crypto/ed25519): pass the raw canonical payload as the message. The library handles RFC 8032 internally.
  • Google Cloud KMS Ed25519: pass the raw canonical payload as data to asymmetricSign; do not send a caller-computed digest. KMS applies the algorithm's required hashing internally, and the resulting signature verifies against the original canonical payload bytes.
  • AWS KMS: Ed25519 is signed in MESSAGE mode (raw payload). Pre-hashing is not required.

In all cases the verifier's behavior is identical: standard Ed25519 verification over the raw canonical payload. If a signer produces signatures that fail standard verification, the bug is in the signer, not the format.

5.5 Internal integrity checks (non-normative)

Issuers MAY compute internal integrity checks on receipts between decision time and signing time, for example to detect corruption in their own storage layer. A common implementation is an HMAC computed with a service-wide key at decision time and stored alongside the receipt in the operational database.

Such checks are strictly internal to the issuer. They MUST NOT appear in the signed payload, the receipt's wire format, exported receipts, or any verifier's validation algorithm. They provide consistency signals for the issuer's own operations (for example: detecting a database row that was rewritten between decision and signing) but provide no security guarantees against attackers who have compromised the issuer's infrastructure — such an attacker can recompute the internal check. They are not tamper evidence.

Implementers are cautioned against presenting internal integrity checks to customers or auditors as security features. The only signature that matters for third-party verification is the Ed25519 signature in §5.1.

6. Public key distribution

Issuers MUST publish the Ed25519 public keys for each workspace at a stable, HTTPS-served URL. The canonical URL pattern is:

https://{issuer-domain}/v1/workspaces/{workspace_id}/keys

The response is a JSON document:

{
  "workspace_id": "ws_01HXA1B2C3D4E5F6G7H8J9K0L1",
  "keys": [
    {
      "key_id": "projects/allowly-prod/...cryptoKeyVersions/3",
      "alg": "Ed25519",
      "public_key": "base64url-encoded 32-byte Ed25519 public key",
      "public_key_fingerprint": "sha256:lowercase-hex-of-decoded-public-key",
      "active_from": "2026-04-01T00:00:00.000Z",
      "active_until": null
    },
    {
      "key_id": "projects/allowly-prod/...cryptoKeyVersions/2",
      "alg": "Ed25519",
      "public_key": "...",
      "public_key_fingerprint": "sha256:...",
      "active_from": "2026-01-15T00:00:00.000Z",
      "active_until": "2026-04-01T00:00:00.000Z"
    }
  ]
}

The document's workspace_id MUST be a non-empty string. Every key entry's alg MUST equal "Ed25519", public_key MUST decode to the raw 32-byte Ed25519 public key, active_from MUST be a string, and active_until MUST be either a string or null. Timestamp strings MUST use the same exact YYYY-MM-DDTHH:MM:SS.sssZ UTC millisecond profile as issued_at; other offsets or precisions are invalid. The active window is half-open: active_from <= issued_at < active_until. A null active_until means the window has no end.

Issuers SHOULD include public_key_fingerprint as sha256: followed by the 64 lowercase hexadecimal characters of SHA-256 over the decoded raw 32-byte public key. When present, verifiers MUST reject a fingerprint that does not match public_key. The field is a convenient identifier, not a trust anchor merely because it appears beside the key; an offline verifier must obtain the expected fingerprint through caller-trusted configuration or another authenticated channel.

Keys MUST remain published even after rotation so historical receipts remain verifiable. active_until being non-null indicates the key is retired but receipts signed during its active window remain valid.

Verifiers SHOULD honor this document's Cache-Control directive. Issuers SHOULD set Cache-Control: max-age=3600 or similar when cached key state is acceptable; otherwise they may send no-store.

7. Verification algorithm

A verifier given a receipt R and the issuer's public keys MUST perform all of the following steps in order, and MUST reject the receipt if any step fails.

  1. Version check. Assert R.schema_version is exactly "4".
  2. Schema check. Assert all required fields are present with the correct types and no unknown top-level fields are present. Assert top-level alg and key_id are strings and signature is a non-empty string. The signature text MUST be canonical unpadded base64url and decode to exactly 64 bytes; verifiers MUST reject non-zero unused pad bits by decoding and re-encoding before comparison. If R.policy_eval is present, assert it conforms to §3.6.1: an object with exactly matched_condition (an object with exactly the members field, op, value — or null; value may be a scalar or an array of scalars) and field_value (string, integer, boolean, or null), and no other members.
  3. Receipt kind and pairing check. Determine the receipt kind from which discriminator field is present, and enforce the corresponding constraints:
    • Exactly one of action and event MUST be present. Reject if both are present, or if neither is present.
    • If event is present (event receipt):
      • event MUST be one of "authorization.create", "authorization.revoke", "budget.settle", "escalation.resolve", or "receipt.checkpoint".
      • If event == "authorization.create": decision MUST equal "authorization_granted".
      • If event == "authorization.revoke": decision MUST equal "authorization_revoked".
      • If event == "budget.settle": decision MUST equal "budget_settled".
      • If event == "escalation.resolve": decision MUST be one of "escalation_approved" or "escalation_rejected".
      • If event == "receipt.checkpoint": decision MUST equal "receipt_set_committed" and context MUST conform to §3.7.
      • authorization_id MUST NOT be null, except it MUST be null for receipt.checkpoint.
      • resource MUST be null for authorization create/revoke and checkpoint receipts.
      • policy_eval MUST be absent.
    • If action is present (action receipt):
      • decision MUST be one of "allow", "deny", "confirm", or "escalate".
      • The reserved event-only decisions (authorization_granted, authorization_revoked, budget_settled, escalation_approved, escalation_rejected, receipt_set_committed) MUST NOT appear.
  4. Algorithm check. Assert R.alg equals "Ed25519".
  5. Timestamp sanity. Parse R.issued_at using the exact YYYY-MM-DDTHH:MM:SS.sssZ profile. Assert it is a real calendar instant, is not in the future (allowing a small skew, e.g. 5 minutes), and is not absurdly far in the past (spec does not mandate a cutoff; verifier policy).
  6. Canonicalize. Produce the canonical payload bytes per §4.
  7. Signature verification.
    • Look up the public key matching R.key_id from the published key document.
    • If the key is not found, reject.
    • Assert the selected key entry's alg equals "Ed25519"; if an advertised public_key_fingerprint is present, recompute and compare it.
    • If the caller supplied trusted public-key fingerprints, assert the selected key's sha256:<hex> fingerprint is in that trusted set.
    • If the key's active window does not include R.issued_at, reject.
    • Verify the Ed25519 signature against the canonical payload bytes per RFC 8032. If verification fails, reject.
  8. Accept. If all checks pass, the receipt is valid.

Workspace and key binding. A key_id identifies a key, not a workspace, and a key document's own workspace_id claim does not authenticate that document. The caller MUST obtain the expected workspace ID and keys, or their fingerprints, through trusted configuration or an authenticated channel. The reference libraries expose optional expected_workspace_id / expectedWorkspaceId and trusted-fingerprint arguments. The Python CLI requires caller-supplied --workspace-id and at least one repeatable --trusted-key-fingerprint; it compares the workspace against both the key document and every receipt, then requires each selected receipt key to be pinned. A key document or fingerprint bundled beside receipts is useful input but is not independently trusted by its location in the bundle.

A valid action receipt attests that: at issued_at, the issuer identified by workspace_id made decision about action by agent_id on behalf of user_id, under authorization_id, with engine version engine_version.

A valid event receipt attests that: at issued_at, the issuer identified by workspace_id recorded the signed event bytes. Authorization events use authorization_id; a checkpoint instead attests to its signed daily set commitment (§3.7).

7.1 What verification does NOT prove

Verifiers and users of verified receipts MUST NOT assume the following:

  1. That the action actually happened. An action receipt records what the agent asked about, not what the agent did. An allow decision followed by no action still produces a receipt.
  2. That a human approved. An authorization.create receipt records that an authenticated customer client registered the authorization. If the client reports human approval, the receipt does not independently prove that input occurred, that the UI was clear, or that the user understood it.
  3. That the context is true. Fields like initiated_by and origin reflect what the customer's system reported at the time. The issuer does not independently verify them.
  4. That user_id corresponds to any particular real-world person. It is an opaque identifier the customer controls.
  5. That the issuer's registry or real-world history was complete. Without a checkpoint, signatures prove only each presented receipt. A verified checkpoint proves equality to its signed member-set commitment, but without external anchoring it cannot prove the issuer registered or retained every receipt before constructing that commitment (§3.7.2).
  6. That a receipt was signed at issued_at. The timestamp is a signed claim, not a proof of signing time; a compromised key can produce receipts bearing any issued_at (§10.1).
  7. That a workspace or key is authentic without an external trust decision. A signature proves only that the holder of the selected private key signed the payload. The verifier still must authenticate the expected workspace and public key, or its fingerprint, as described in §7.

These limits are intentional. The receipt attests to what the issuer observed and recorded, not to ground truth about the world.

8. Revocation

Receipts are immutable and never revoked. A revocation of an authorization is itself a new event that produces a new authorization.revoke receipt. After revocation, subsequent action checks against the same authorization_id return deny with reason: "authorization_revoked", each producing its own signed action receipt. When a complete receipt set is available from a source trusted for completeness, the history — creation, actions, revocation, and any post-revocation denies — can be reconstructed via the authorization chain (§3.5).

Authorizations are likewise immutable (§3.3): there is no update event, and a change to actions, constraints, or verb-routing rules is expressed as revoke + create, never as mutation of an existing authorization.

Ordering of a supersession pair (non-normative). When a revoke + create expresses a rule change, the two receipts are independently signed and the format does not constrain their relative issued_at. Issuers SHOULD nonetheless allocate the successor's authorization_id first, then issue the create at or before the revoke (create.issued_at <= revoke.issued_at), and set the cross-references described in §3.3 on both. This keeps the successor active for the whole window, so an action arriving mid-change matches the new rule set rather than falling into a gap where neither authorization is active and the resulting deny carries authorization_id: null (§3.5). The reverse ordering (revoke first) opens exactly that gap; the format permits it but auditors lose the ability to attribute denies issued inside the window to any rule set.

9. Test vectors

Reference test vectors are provided in test-vectors.json. Implementations MUST pass all vectors in the should_verify group and MUST reject all vectors in the should_reject group with the specified reason.

Vectors include:

Action receipts that MUST verify:

  • A minimal allow action receipt with an empty context.
  • A deny receipt with authorization_id: null.
  • A receipt with non-ASCII characters in multiple fields (tests UTF-8 handling).
  • A receipt with a rich nested context object (tests canonicalization correctness).
  • An escalate receipt with escalation context.
  • A deny receipt with a policy_eval block whose matched_condition fired.
  • A confirm receipt with a policy_eval block whose matched_condition fired (tests §3.6 schema and the engine_version < policy_eval canonical sort).
  • A confirm receipt with a policy_eval.matched_condition.value array for an in condition.
  • An allow receipt with policy_eval.matched_condition: null and field_value: null (conditions evaluated, none matched).
  • A confirm receipt with reason: "context_field_missing", matched_condition set, field_value: null (fail-closed convention).
  • A receipt whose context contains control characters in a string value (tests the \uXXXX escaping of canonicalization rule 5).
  • A receipt whose context contains a key outside the Basic Multilingual Plane alongside a BMP key (tests the UTF-16 code-unit key sort of rule 3).

Event receipts that MUST verify:

  • A authorization.create receipt with actions, expiry, and a csv_hash source identifier.
  • A authorization.create receipt carrying a replaces lineage pointer in context (§3.3).
  • A authorization.revoke receipt with revoked_by: "user" in context.
  • A authorization.revoke receipt with revoked_by: "superseded" and a superseded_by forward pointer (§3.3 lineage convention).
  • A budget.settle receipt with estimated and client-reported actual cost evidence.
  • escalation.resolve receipts with client-reported approved and rejected resolutions and resource bindings.
  • Linked receipt.checkpoint receipts, including an empty day and a two-member Merkle commitment.

Receipts that MUST be rejected:

  • A non-object top-level receipt (null, array, string, or number).
  • A receipt with a tampered payload (signature fails).
  • A receipt with a forged signature (zero bytes).
  • A receipt with an unknown key_id.
  • A receipt whose signed key_id was changed to another published key.
  • A receipt with schema_version: "5".
  • A receipt.checkpoint with a negative receipt_count.
  • A receipt with an unknown top-level field.
  • A receipt with a required field missing.
  • A receipt with decision: "maybe" (invalid decision value).
  • A receipt with both action and event present.
  • A receipt with neither action nor event present.
  • A receipt with event: "authorization.update" (unknown event — removed in draft.5).
  • A receipt with a prototype-named event such as toString (unknown event).
  • A receipt with event: "authorization.create" but decision: "allow" (pairing violation).
  • A receipt with event: "authorization.revoke" but authorization_id: null (pairing violation).
  • A receipt with event: "authorization.create" but a non-null resource (pairing violation).
  • An event receipt carrying a policy_eval block (pairing violation).
  • An action receipt whose policy_eval carries an unknown extra member (schema violation, §3.6.1).
  • An action receipt whose policy_eval.matched_condition is missing op (schema violation, §3.6.1).
  • An action receipt with a non-integer number in policy_eval.field_value (canonicalization rule 6).
  • An action receipt with an integer outside the I-JSON safe range, ±(2⁵³−1) (canonicalization rule 6).
  • A receipt whose issued_at lacks a timezone offset (timestamp not a full RFC 3339 instant, §7 step 5).
  • A receipt whose issued_at uses invalid hour 24.
  • A receipt whose signature carries padding, non-base64url characters, or non-canonical trailing pad bits (§5.1).
  • An action receipt with an event-only decision such as authorization_granted (reserved decision misuse).
  • Receipts immediately before a key's active_from and exactly at its exclusive active_until boundary.

10. Security considerations

10.1 Key compromise

If an issuer's Ed25519 private key is compromised, all receipts signed under that key are untrustworthy from the moment of compromise until rotation. Issuers SHOULD rotate keys at least annually. Verifiers SHOULD consult the key document's active_until field when verifying old receipts; a key retired for compromise should have active_until set to the compromise time. Note the limit of this mitigation: the window check compares active_until against the receipt's claimed issued_at, and an attacker holding the key controls that claim — a receipt backdated to before the compromise time still verifies (§7 item 6). Retiring a key bounds honest use; it does not detect backdated forgeries. Detecting those requires evidence outside this format, such as an external timestamp or the issuer's own receipt log.

Format 4 signs alg and key_id, so changing either one invalidates the signature.

10.2 Canonicalization fragility

Incorrect canonicalization is the most common implementation bug in JSON signing schemes. Two pitfalls deserve specific warning, because a naive use of stdlib JSON serializers gets them wrong while still passing simple test vectors:

  • Key sort order (rule 3). A stdlib "sort keys" option typically sorts by Unicode code point, not UTF-16 code unit. The two agree for all-BMP keys and diverge only for keys containing supplementary characters — so the bug is invisible until such a key appears. Python's json.dumps(sort_keys=True) is wrong here; sort keys explicitly by encode("utf-16-be") instead (see rule 3).
  • Control-character escaping (rule 5). Stdlib serializers emit short escapes (\n, \t) for control characters; rule 5 requires the \uXXXX form. Python's json.dumps is wrong here too. A hand-rolled string encoder (as in both reference verifiers) is required.

Because of these, json.dumps(sort_keys=True, separators=(",", ":"), ensure_ascii=False) is not a conforming canonicalizer despite being a common suggestion; the reference Python verifier uses a hand-rolled serializer. JavaScript's default Array.prototype.sort does implement UTF-16 order, but JSON.stringify still mishandles control characters, so the TypeScript verifier also hand-rolls string encoding. Implementers MUST verify against the reference test vectors — which now include a supplementary-plane key and embedded control characters — before trusting their implementation.

10.3 Replay

The receipt format does not include a replay-protection mechanism beyond receipt_id and issued_at. Receipts are not bearer tokens — they do not authorize any action and cannot be "replayed" to cause an action. They attest to past decisions. Duplicate receipts (same receipt_id appearing twice) indicate a bug, not an attack.

10.4 Privacy of receipts

Receipts contain user_id, agent_id, action (or event), and resource. These may be sensitive. Issuers and customers SHOULD treat receipt exports with the same care as other logs containing user identifiers. The receipt format does not encrypt content; if confidentiality is required, transport- or storage-layer encryption MUST be applied separately.

10.5 Signing window and issuer SLA

Because signing is often asynchronous, an issuer that goes offline between decision time and signing time produces decisions whose receipts remain in pending state — outside this format — until signing resumes. Issuers SHOULD publish and enforce a maximum signing window after which operators are alerted (typical SLA: receipts are signed within 60 seconds of issuance). Customers SHOULD treat the issuer's pending response as transient and follow the issuer's documented retrieval mechanism (typically a poll-the-receipt-URL pattern) before treating any artifact as audit evidence. The receipt format itself does not include a pending state — pending is a transport-layer concept the issuer's API surfaces in a structurally distinct response.

10.6 PII in identifiers

user_id and agent_id are opaque strings to the issuer and verifier. Customers choose what they mean. Customers SHOULD NOT use identifiers that contain personally identifiable information — raw email addresses, phone numbers, or legal names — for three reasons:

  1. Receipts are long-lived. A PII-laden user_id is embedded in every signed receipt for that user, for as long as receipts are retained. Retention policies that would normally apply to PII (deletion on request, region restrictions) are much harder to enforce against an immutable signed ledger.
  2. Identifiers should be stable across user lifecycle. Email addresses change when people marry, change companies, or get domains renamed. An ID that changes breaks audit continuity; an ID that's PII and changes breaks both.
  3. Enumeration risk. If an identifier is guessable (emails in a known domain), a leaked receipt snippet reveals more than just "a receipt exists" — it reveals who it's about.

Recommended: customers use their internal opaque identifier (a ULID, UUID, or equivalent) as user_id, and maintain the mapping to human identities in their own systems, outside the receipt ledger. If a customer needs to key identity on email, they SHOULD hash the email with a per-workspace salt before using it as user_id, producing an opaque-but-stable identifier.

10.7 Snapshot minimization in policy_eval.field_value

field_value copies a piece of evaluated customer context into a signed, immutable, long-lived record. Anything snapshotted here inherits every retention problem described in §10.6: it cannot be deleted, redacted, or regionally restricted after signing.

Issuers and customers SHOULD:

  • Snapshot the minimum value the condition actually compared — the number or flag, not the record it came from. "field_value": 82 (a transcript-completeness percentage) is appropriate; copying a free-text note or a full profile attribute is not.
  • Never route conditions on raw sensitive personal attributes (health status, protected-class data, salary, precise location). Where a sensitive signal is genuinely needed for routing, evaluate and snapshot a derived form — a boolean, a bucket, a scaled score — so the signed record carries the decision-relevant abstraction rather than the underlying attribute.
  • Treat exports containing policy_eval with the same care as receipt exports generally (§10.4).

The format cannot police the semantics of what customers evaluate; this guidance marks the boundary between an audit artifact and a data-retention liability.

Appendix A. hmac-v1 keyed pseudonym references

This appendix defines an optional application convention for pseudonymous strings carried inside an existing receipt context. It adds no receipt field, does not change canonicalization or signing, and does not change wire version 4. Verifiers that do not use this convention continue to verify the same receipt bytes.

A.1 Key and field separation

An issuer using this convention MUST generate a separate random key of at least 128 bits for each integration. The pseudonym key MUST NOT be the URL, API, signing, or capability-lookup key. A customer that needs to recompute references receives the pseudonym key through a secure show-once flow and retains old versions when the key rotates.

The only field names defined by hmac-v1 are the ASCII strings project, record, actor, and full_tuple. Field-name separation prevents the same raw value from receiving the same reference in two semantic fields.

A.2 Reference computation

For a byte string key, an allowed ASCII field_name, and a Unicode string value:

message = ASCII(field_name) || 0x00 || UTF8(value)
ref = "hmac-v1:" || lowercase_hex(HMAC-SHA256(key, message))

The digest MUST NOT be truncated. value is encoded exactly as supplied: no whitespace trimming, case folding, or Unicode normalization occurs.

The full_tuple value is the following five strings joined by the single byte 0x1F, with an absent component represented by an empty string:

record || 0x1F || event || 0x1F || instrument || 0x1F ||
repeat_instrument || 0x1F || repeat_instance

Components MUST NOT contain U+001F. An application using versioned keys SHOULD carry the integer key version alongside the refs in context (for example, context.ref_key_version) so an auditor selects the right retained key.

A.3 Test vectors

For the 32-byte key whose hexadecimal form is 000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f:

field_name = "record"
value      = "MRN-48291"
ref        = "hmac-v1:ecfc67ffb7bac447c05df24d1a25d75ebe7e765320d0fb1b4d22be332341599e"

For the same key and tuple components MRN-48291, baseline_arm_1, demographics, demographics, and 2:

field_name = "full_tuple"
ref        = "hmac-v1:c5fa890e042ea330013bd07bcc47c3312136373493fe87a490907b0e93c5727a"

A.4 Security and privacy

This is a pseudonymization convention, not encryption or de-identification. Anyone holding the key can test guesses and correlate equal values within that integration. Issuers and customers MUST protect both the key and retained references as sensitive data and apply the relevant retention obligations. A remote reference-resolution endpoint is unnecessary and creates a guessing oracle; customers can use the key and the reference verifier locally.

Appendix B. SEAL JSON record profile

SEAL is an application profile for recording a SHA-256 commitment to a JSON value inside an ordinary wire-4 action receipt. It adds no receipt fields and does not change the receipt canonicalization or signature algorithm. The profile identifier is allowly.seal.jcs-sha256.v1.

B.1 Raw JSON input

A conforming raw-input implementation MUST:

  1. Accept only well-formed UTF-8 JSON text and any JSON top-level value.
  2. Reject raw input larger than 1,048,576 UTF-8 bytes. The resulting canonical JSON MUST also be no larger than 1,048,576 UTF-8 bytes.
  3. Reject a value tree deeper than 32 levels, where the root value is level 1.
  4. Reject duplicate object member names after JSON escape decoding. For example, "a" and "\u0061" in the same object are duplicates.
  5. Reject strings or object member names containing an unpaired Unicode surrogate. No Unicode normalization is performed.
  6. Use the RFC 8785 / ECMAScript binary64 number model. Accept finite numbers, -0, ordinary decimals, and alternate spellings that denote the same model value, such as 1, 1.0, and 1e0. Reject integers outside ±(2532^{53}−1), overflow, a nonzero token that underflows to zero, and a token whose significant decimal value would be changed by conversion to the binary64 model. Implementations MUST use the shared vectors to make this check consistently rather than relying on a parser that silently rounds.

After validation, the implementation MUST serialize the value with RFC 8785 JSON Canonicalization Scheme (JCS), hash the canonical UTF-8 bytes with SHA-256, and encode the digest as exactly 64 lowercase hexadecimal characters:

record_sha256 = lowercase_hex(SHA256(UTF8(JCS(parsed_json))))

Object source order, insignificant whitespace, and equivalent number spellings therefore do not affect the digest. Array order, JSON types, string contents, and object member names do affect it.

An API that accepts an already-parsed value MUST clearly label that boundary. Parsing has already erased duplicate names and original number-token spellings, so the parsed-value API cannot apply all raw-input checks above.

B.2 Signed receipt binding

A SEAL receipt is a valid action receipt under the base verification algorithm with all of these signed values:

{
  "action": "record.seal",
  "decision": "allow",
  "agent_id": "allowly.seal",
  "user_id": "allowly:seal",
  "context": {
    "seal_profile": "allowly.seal.jcs-sha256.v1",
    "record_sha256": "<64 lowercase hex>"
  }
}

The context may contain other application metadata. A verifier MUST NOT bind a particular authorization_id; a managed SEAL authorization may renew without changing the profile. The caller MUST supply the expected workspace ID and public keys from an authenticated, trusted source. A workspace or key claim copied only from the receipt is not a trust anchor.

B.3 Verification result

A conforming SEAL verifier performs base wire-4 receipt verification first, then checks the action, decision, fixed identities, profile, digest syntax, and locally computed record digest. Its structured result MUST keep receipt signature verification separate from record matching:

  • A base receipt or signature failure reports signature_verified: false and record_matches: false.
  • A valid signature with invalid SEAL semantics, invalid record input, or a different record reports signature_verified: true and record_matches: false, with a machine-readable reason.
  • Success reports both values as true and has no failure reason.

A pending transport envelope is not a signed receipt and cannot verify. A verified match proves that the workspace key signed a commitment to those JSON bytes under this profile. It does not prove the record's claims are true or that an external action occurred.

B.4 Test vectors

The normative cross-language profile vectors are vectors/seal/profile-v1.json. Signed receipt and failure-result vectors are vectors/seal/verification-v1.json. Implementations MUST pass both files.

11. Changelog

  • Verifier packages 4.1.0 (wire format unchanged at 4) — Added Appendix B's allowly.seal.jcs-sha256.v1 JSON commitment profile, strict raw hashing, structured SEAL verification, and shared Python/TypeScript vectors.

  • 4 (2026-08-01) — Daily signed receipt-set checkpoints.

    • Added receipt.checkpoint / receipt_set_committed workspace event receipts.
    • Defined a deterministic SHA-256 Merkle commitment over full canonical signed receipt bytes, using byte-domain-separated leaves/nodes, sorted leaf hashes, duplicate-last odd levels, and a distinct empty root.
    • Added exact UTC-day periods, member counts, and prior checkpoint id/root linkage.
    • Added Python and TypeScript checkpoint recomputation against shared vectors. A verified checkpoint proves equality to the signed set commitment, not real-world or issuer-registry completeness; external anchoring remains out of scope.
    • Added caller-trusted key-fingerprint pinning, stricter key-document checks, duplicate-name rejection in the Python CLI, and point-in-time JSON snapshots in TypeScript.
    • Corrected claims about receipt-ID ordering, canonicalization, Google Cloud KMS input, and workspace/key trust, with cross-language fingerprint and rotation vectors.
  • 3 (2026-07-22) — Self-describing version field; integer wire identities.

    • Renamed version to schema_version. The field's canonical sort position moves (it now sorts before user_id), so wire-2.0.0 and wire-3 signatures are not interchangeable. The only accepted value is "3".
    • Wire versions are plain integers from now on ("3", "4", …). A signed wire format is an exact-match contract, so dotted versions carry no range semantics and invite mislabeling. Verifier packages version as <wire>.<minor>.<patch>, so default caret ranges can never cross a wire boundary.
    • budget.settle formally belongs to wire 3. The event and its paired budget_settled decision were mistakenly added to the 2.0.0 spec text without a wire-version bump, so receipts carrying the new event still claimed "2.0.0" — a claim 2.0.0-only verifiers reject. This release corrects the label.
    • Breaking release with no deprecation window: made pre-launch, with no deployed external verifiers and no receipts in the wild. (Supersedes a same-day interim cut labeled 2.1.0 that was never published.)
  • 2026-07-22 (verifier packages 2.1.0; wire format unchanged at 2.0.0) — Added Appendix A's optional hmac-v1 application convention and the matches_ref (Python) / matchesRef (TypeScript) helpers. Receipt schema, signing, canonicalization, and wire version are unchanged.

  • 2.0.0 (2026-07-20) — Protected signature header and verifier hardening.

    • Moved alg and key_id into the signed top-level payload and changed signature to the base64url signature string. The only accepted wire version is "2.0.0".
    • Required well-formed Unicode scalar strings, unique raw JSON member names, integer token spelling without decimal/exponent forms, and canonical base64url trailing pad bits.
    • Made the reference depth-32 and node-count-50,000 limits normative.
    • Required exact UTC millisecond timestamps for receipts and key active windows, with a half-open key window and strict active_until typing.
    • Hardened both reference verifiers against non-object receipts, hostile event names, and deeply nested export lines; expanded shared boundary and canonicalization coverage.
  • 1.0.0 (2026-06-12) — Stable release. Finalizes draft.6 unchanged; the wire version stays "1.0". No format, canonicalization, or verifier behavior changes from draft.6 — this entry only drops the draft label.

  • 1.0.0-draft.6 (2026-06-10) — Canonicalization correctness; supersession lineage.

    • Fixed two canonicalization defects in the reference verifiers that broke cross-language signature verification (§4.2, §10.2). (1) Key sort: §4.2 rule 3 mandates UTF-16 code-unit order, but the Python verifier's json.dumps(sort_keys=True) sorted by code point — divergent for supplementary-plane keys. (2) Control characters: rule 5 mandates the \uXXXX form, but json.dumps emitted short escapes (\n). The Python verifier now uses a hand-rolled serializer; both defects are covered by new test vectors. Corrected the §4.2 rule 3 and §10.2 prose, which had wrongly claimed json.dumps was conforming.
    • Bounded integers to the I-JSON safe range ±(2⁵³−1) (§4.2 rule 6). Out-of-range integers lose precision in IEEE-754 consumers and could render with an exponent; both verifiers now reject them.
    • Stricter input validation in both verifiers: issued_at must be a full RFC 3339 instant with an explicit offset (rejecting timezone-less / date-only strings the TS verifier previously parsed in local time), and signature.value must be unpadded base64url with no out-of-alphabet characters (§5.1).
    • Supersession lineage made bidirectional (non-normative). Added revoked_by: "superseded" and the superseded_by forward pointer on authorization.revoke receipts, upgraded the replaces backward pointer from MAY to SHOULD when a creation supersedes, and added an ordering recommendation (create.issued_at <= revoke.issued_at) so a rule change does not open a gap in which denies carry authorization_id: null (§3.3, §3.5, §8).
    • Clarified the §3.1 context row: "preserve byte-for-byte" (unimplementable after JSON parsing) replaced with canonicalize-by-the-same-rules, no value/key/array-order changes.
    • Wire version stays "1.0" per the §3.2 lockstep policy; spec, both verifiers, and vectors regenerated together. The validation changes reject some inputs draft.5 verifiers accepted, but every receipt that verified under draft.5 and contains no supplementary-plane keys or control characters is byte-identical under draft.6.
  • 1.0.0-draft.5 (2026-06-09) — Immutability restored; versioning policy.

    • Removed authorization.update and the authorization_updated decision. Authorizations are immutable: any change to actions, constraints, or verb-routing rules is expressed as revoke + create (§3.3, §8). The authorization chain (§3.5) is again create → actions → escalation resolutions → revoke.
    • Tightened policy_eval to exactly {matched_condition, field_value}. With immutable authorizations, the top-level authorization_id alone pins the rule set in force.
    • Added the non-normative replaces lineage convention: a creation receipt's context MAY name the predecessor authorization it supersedes (§3.3, §3.5).
    • Added the versioning policy (§3.2): pre-final drafts ship spec/verifiers/vectors in lockstep under wire version "1.0"; after 1.0.0 final, additive optional fields require a minor wire-version bump and verifiers declare accepted version sets.
    • Added §10.7: snapshot-minimization guidance for policy_eval.field_value (no raw sensitive attributes in signed records; snapshot derived forms).
    • Verifier: policy_eval schema check tightened to the two-member shape with strict matched_condition internals; update-event pairing rules deleted. Test vectors regenerated (update vectors removed; replaces, unknown-member, and malformed-matched_condition vectors added).
  • 1.0.0-draft.4 (2026-06-09) — Conditional policy evaluation.

    • Added optional top-level policy_eval block on action receipts (§3.6). Receipts now record why a verb fired without defining the condition language (still a non-goal per §1).
    • Added update-style authorization event receipts in this draft; later drafts returned to immutable authorizations with revoke + create.
    • Value typing: condition values and field_value are restricted to integers, strings, booleans, or null, per canonicalization rule 6 (no floats).
    • Documented the fail-closed convention (reason: "context_field_missing") as non-normative issuer guidance (§3.6.3).
    • Verifier: schema check extended for policy_eval (§7 step 2); pairing rules extended for the update event and the policy_eval-on-event prohibition (§7 step 3). Test vectors regenerated.
    • Compatibility: draft.3 verifiers reject any receipt carrying policy_eval under the unknown-top-level-field rule. Deploy verifier updates before issuers enable policy_eval emission. Receipts that omit policy_eval are byte-identical under draft.3 and draft.4.
  • 1.0.0-draft.3 (2026-06-03) — Escalation receipts.

    • Added escalate as an action receipt decision.
    • Added escalation.resolve event receipts with escalation_approved and escalation_rejected decisions.
    • Clarified that authorization create/revoke events require resource: null, while escalation resolution may carry the resource binding.
  • 1.0.0-draft.2 (2026-05-09) — Naming refinement.

    • Introduced the explicit action-receipt discriminator, paired with the existing event discriminator for event receipts.
    • Field's presence now carries the receipt kind explicitly. Pairing rules are simpler. Verifier logic shorter.
    • Reserved authorization-lifecycle event names (authorization.create, authorization.revoke) moved from action values to event values.
    • All existing pairing checks updated; test vectors regenerated.
  • 1.0.0-draft (2026-04-21) — Initial public draft.

    • Flat receipt structure: no wrapping subject or action objects. user_id, agent_id, action, resource, and context are all top-level fields.
    • Two receipt kinds share the same format: action receipts (decisioning) and event receipts (lifecycle).
    • Single Ed25519 signature over the canonical payload.
    • Asynchronous signing handled at the transport layer; pending receipts are not part of the receipt format.
    • Internal integrity checks (e.g. HMAC) are permitted but explicitly outside the wire format.
    • Explicit guidance against PII in user_id and agent_id.

This specification is maintained at https://github.com/Allowly-AI/allowly-receipt-format. Comments, issues, and pull requests welcome.