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
/checkendpoint 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:
- 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.
- Tamper evidence. Any modification to the receipt content after signing is detectable.
- 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.
- 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_idis 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.checkpointevent 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
| Field | Type | Required | Notes |
|---|---|---|---|
schema_version | string | yes | Receipt wire-format version. MUST be exactly "4". Wire versions are plain integers; each distinct wire format has exactly one identity. |
receipt_id | string | yes | Unique receipt identifier. Conventionally rcp_ plus a ULID; ULID random suffixes are not necessarily monotonic within the same millisecond. |
workspace_id | string | yes | Issuer identifier. Used to look up the verification key. |
issued_at | string | yes | RFC 3339 timestamp in the exact YYYY-MM-DDTHH:MM:SS.sssZ UTC millisecond profile. |
decision | string | yes | See §3.3 for allowed values by receipt kind. |
reason | string | yes | Machine-readable reason code. Human-readable strings MUST NOT appear here. |
user_id | string | yes | Opaque 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_id | string | yes | Opaque identifier of the agent or acting principal. Customer-defined. For human-initiated actions, this identifies the actor's role (e.g. controller, dba). |
action | string | absent | conditional | Present 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. |
event | string | absent | conditional | Present on event receipts. One of "authorization.create", "authorization.revoke", "budget.settle", "escalation.resolve", or "receipt.checkpoint". MUST be absent on action receipts. |
resource | string | null | yes | An 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. |
context | object | yes | An 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_id | string | null | yes | The 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_version | string | yes | Version of the issuer's decision logic at time of issue. Format is issuer-defined. |
policy_eval | object | absent | optional | Action 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. |
alg | string | yes | MUST be "Ed25519". This field is part of the signed payload. |
key_id | string | yes | Opaque identifier selecting the issuer public key. SHOULD be the full key-version resource path. This field is part of the signed payload. |
signature | string | yes | Canonical 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).event— MUST be absent.decision— one of"allow","deny","confirm","escalate".authorization_id— the matching authorization, ornullif no authorization matched.resource— an identifier for the action's target, ornull.
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).
action— MUST be absent.decision— one of:"authorization_granted"— paired withevent: "authorization.create"."authorization_revoked"— paired withevent: "authorization.revoke"."budget_settled"— paired withevent: "budget.settle"."escalation_approved"— paired withevent: "escalation.resolve"for a reported approval."escalation_rejected"— paired withevent: "escalation.resolve"for a reported rejection."receipt_set_committed"— paired withevent: "receipt.checkpoint".
authorization_id— the authorization being created, revoked, settled, or escalated. MUST NOT benullon those event receipts. It MUST benullonreceipt.checkpoint.resource— MUST benullfor authorization create/revoke receipts. Forbudget.settleandescalation.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 optionalcsv_hashor similar integrity identifier, an optionalreplacesfield (see below); for revocations arevoked_byfield (user,admin,expired,tombstone, orsupersededwhen the revocation is part of a rule change) and, whenrevoked_byissuperseded, an optionalsuperseded_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 anescalationobject 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
contextSHOULD carryreplaces: "<authorization_id of the predecessor>"(the backward pointer), and - the revocation receipt's
contextSHOULD carryrevoked_by: "superseded"together withsuperseded_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
actionandeventis present. Receipts with both fields, or with neither, are rejected. - If
eventis present, it MUST be one of"authorization.create","authorization.revoke","budget.settle","escalation.resolve", or"receipt.checkpoint". The correspondingdecisionMUST be valid for that event.authorization_idMUST NOT benullexcept onreceipt.checkpoint, where it MUST benull.resourceMUST benullfor authorization create/revoke and checkpoint receipts.policy_evalMUST be absent. Checkpoint context MUST conform to §3.7. - If
actionis present,decisionMUST 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. Ifpolicy_evalis 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:
- Exactly one
authorization.createreceipt (the authorization grant itself). - 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_idalone pins the rule set. - Zero or more
budget.settlereceipts recording client-reported actual costs against estimates. - Zero or more
escalation.resolvereceipts recording client-reported escalation resolutions under this authorization. - At most one
authorization.revokereceipt (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:
| Member | Type | Notes |
|---|---|---|
matched_condition | object | null | The 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_value | string | integer | boolean | null | Snapshot 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_evalMUST 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 insidematched_condition.valuearrays. Fractional thresholds or values MUST be expressed as scaled integers (e.g. basis points, micros) or strings. - Issuers SHOULD emit
policy_evalon every action receipt for actions that carry conditions — includingallowdecisions, wherematched_condition: nullattests evaluated, nothing fired. An absence of flags is itself evidence. - Canonicalization note: the lexicographic key sort places
engine_versionbeforepolicy_evalin 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:
| Member | Type | Notes |
|---|---|---|
period_start | string | Inclusive UTC midnight in the exact timestamp profile. |
period_end | string | Exclusive UTC midnight exactly one day after period_start. |
receipt_count | non-negative integer | Number of committed member receipts. |
merkle_root | string | sha256: followed by 64 lowercase hex characters, computed below. |
previous_checkpoint_id | string | null | Receipt id of the immediately preceding daily checkpoint, or null for the first checkpoint. |
previous_merkle_root | string | null | That 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:
- For each full signed member receipt
R, including itssignature, serialize the entire object using §4.2 without applying §4.1's signature removal, then computeleaf = SHA256(0x00 || full_canonical_bytes). - Sort the 32-byte leaf hashes in ascending unsigned byte order.
- 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. - For an empty member set, the root bytes are
SHA256(0x02). - 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.
- 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.
- Whitespace. No whitespace between tokens. Specifically: no spaces, no tabs, no newlines anywhere outside of string values.
- Object keys. Sorted in lexicographic order by UTF-16 code unit (the default
Array.prototype.sortorder in JavaScript). Applied recursively to every object, including nested objects (such ascontext). 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 beforeU+FF61under UTF-16 but after it by code point). Languages whose default string sort is by code point — including Python'ssorted()— MUST sort keys by their UTF-16 big-endian encoding (in Python,key=lambda k: k.encode("utf-16-be")) to match this rule. - Array order. Preserved as-is. Arrays are ordered data; verifiers MUST NOT sort them.
- Strings. Serialized with double quotes. The following characters MUST be escaped:
"as\",\as\\, and control charactersU+0000throughU+001Fusing\uXXXXlowercase-hex form (e.g.\u000afor newline). Non-ASCII characters MUST NOT be escaped; they appear as their UTF-8 byte sequence. - Numbers. Raw JSON integer tokens MUST be written without a decimal point or exponent (
1is valid;1.0and1e0are 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. - Booleans and null. Serialized as
true,false,null. - Separators.
,between array/object elements,:between object keys and values. No surrounding whitespace. - 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
signatureis 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
datatoasymmetricSign; 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.
- Version check. Assert
R.schema_versionis exactly"4". - Schema check. Assert all required fields are present with the correct types and no unknown top-level fields are present. Assert top-level
algandkey_idare strings andsignatureis 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. IfR.policy_evalis present, assert it conforms to §3.6.1: an object with exactlymatched_condition(an object with exactly the membersfield,op,value— or null;valuemay be a scalar or an array of scalars) andfield_value(string, integer, boolean, or null), and no other members. - Receipt kind and pairing check. Determine the receipt kind from which discriminator field is present, and enforce the corresponding constraints:
- Exactly one of
actionandeventMUST be present. Reject if both are present, or if neither is present. - If
eventis present (event receipt):eventMUST be one of"authorization.create","authorization.revoke","budget.settle","escalation.resolve", or"receipt.checkpoint".- If
event == "authorization.create":decisionMUST equal"authorization_granted". - If
event == "authorization.revoke":decisionMUST equal"authorization_revoked". - If
event == "budget.settle":decisionMUST equal"budget_settled". - If
event == "escalation.resolve":decisionMUST be one of"escalation_approved"or"escalation_rejected". - If
event == "receipt.checkpoint":decisionMUST equal"receipt_set_committed"and context MUST conform to §3.7. authorization_idMUST NOT benull, except it MUST benullforreceipt.checkpoint.resourceMUST benullfor authorization create/revoke and checkpoint receipts.policy_evalMUST be absent.
- If
actionis present (action receipt):decisionMUST 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.
- Exactly one of
- Algorithm check. Assert
R.algequals"Ed25519". - Timestamp sanity. Parse
R.issued_atusing the exactYYYY-MM-DDTHH:MM:SS.sssZprofile. 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). - Canonicalize. Produce the canonical payload bytes per §4.
- Signature verification.
- Look up the public key matching
R.key_idfrom the published key document. - If the key is not found, reject.
- Assert the selected key entry's
algequals"Ed25519"; if an advertisedpublic_key_fingerprintis 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.
- Look up the public key matching
- 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:
- That the action actually happened. An action receipt records what the agent asked about, not what the agent did. An
allowdecision followed by no action still produces a receipt. - That a human approved. An
authorization.createreceipt 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. - That the context is true. Fields like
initiated_byandoriginreflect what the customer's system reported at the time. The issuer does not independently verify them. - That
user_idcorresponds to any particular real-world person. It is an opaque identifier the customer controls. - 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).
- 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 anyissued_at(§10.1). - 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
allowaction receipt with an empty context. - A
denyreceipt withauthorization_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
escalatereceipt with escalation context. - A
denyreceipt with apolicy_evalblock whosematched_conditionfired. - A
confirmreceipt with apolicy_evalblock whosematched_conditionfired (tests §3.6 schema and theengine_version<policy_evalcanonical sort). - A
confirmreceipt with apolicy_eval.matched_condition.valuearray for anincondition. - An
allowreceipt withpolicy_eval.matched_condition: nullandfield_value: null(conditions evaluated, none matched). - A
confirmreceipt withreason: "context_field_missing",matched_conditionset,field_value: null(fail-closed convention). - A receipt whose
contextcontains control characters in a string value (tests the\uXXXXescaping of canonicalization rule 5). - A receipt whose
contextcontains 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.createreceipt with actions, expiry, and acsv_hashsource identifier. - A
authorization.createreceipt carrying areplaceslineage pointer in context (§3.3). - A
authorization.revokereceipt withrevoked_by: "user"in context. - A
authorization.revokereceipt withrevoked_by: "superseded"and asuperseded_byforward pointer (§3.3 lineage convention). - A
budget.settlereceipt with estimated and client-reported actual cost evidence. escalation.resolvereceipts with client-reported approved and rejected resolutions and resource bindings.- Linked
receipt.checkpointreceipts, 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_idwas changed to another published key. - A receipt with
schema_version: "5". - A
receipt.checkpointwith a negativereceipt_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
actionandeventpresent. - A receipt with neither
actionnoreventpresent. - 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"butdecision: "allow"(pairing violation). - A receipt with
event: "authorization.revoke"butauthorization_id: null(pairing violation). - A receipt with
event: "authorization.create"but a non-nullresource(pairing violation). - An event receipt carrying a
policy_evalblock (pairing violation). - An action receipt whose
policy_evalcarries an unknown extra member (schema violation, §3.6.1). - An action receipt whose
policy_eval.matched_conditionis missingop(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_atlacks a timezone offset (timestamp not a full RFC 3339 instant, §7 step 5). - A receipt whose
issued_atuses invalid hour24. - 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_fromand exactly at its exclusiveactive_untilboundary.
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 byencode("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\uXXXXform. Python'sjson.dumpsis 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:
- Receipts are long-lived. A PII-laden
user_idis 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. - 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.
- 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_evalwith 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:
- Accept only well-formed UTF-8 JSON text and any JSON top-level value.
- 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.
- Reject a value tree deeper than 32 levels, where the root value is level 1.
- Reject duplicate object member names after JSON escape decoding. For
example,
"a"and"\u0061"in the same object are duplicates. - Reject strings or object member names containing an unpaired Unicode surrogate. No Unicode normalization is performed.
- Use the RFC 8785 / ECMAScript binary64 number model. Accept finite numbers,
-0, ordinary decimals, and alternate spellings that denote the same model value, such as1,1.0, and1e0. Reject integers outside ±(−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: falseandrecord_matches: false. - A valid signature with invalid SEAL semantics, invalid record input, or a
different record reports
signature_verified: trueandrecord_matches: false, with a machine-readable reason. - Success reports both values as
trueand 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.v1JSON 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_committedworkspace 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.
- Added
-
3 (2026-07-22) — Self-describing version field; integer wire identities.
- Renamed
versiontoschema_version. The field's canonical sort position moves (it now sorts beforeuser_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.settleformally belongs to wire 3. The event and its pairedbudget_settleddecision 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.)
- Renamed
-
2026-07-22 (verifier packages 2.1.0; wire format unchanged at 2.0.0) — Added Appendix A's optional
hmac-v1application convention and thematches_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
algandkey_idinto the signed top-level payload and changedsignatureto 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_untiltyping. - Hardened both reference verifiers against non-object receipts, hostile event names, and deeply nested export lines; expanded shared boundary and canonicalization coverage.
- Moved
-
1.0.0 (2026-06-12) — Stable release. Finalizes draft.6 unchanged; the wire
versionstays"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\uXXXXform, butjson.dumpsemitted 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 claimedjson.dumpswas 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_atmust be a full RFC 3339 instant with an explicit offset (rejecting timezone-less / date-only strings the TS verifier previously parsed in local time), andsignature.valuemust be unpadded base64url with no out-of-alphabet characters (§5.1). - Supersession lineage made bidirectional (non-normative). Added
revoked_by: "superseded"and thesuperseded_byforward pointer onauthorization.revokereceipts, upgraded thereplacesbackward 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 carryauthorization_id: null(§3.3, §3.5, §8). - Clarified the §3.1
contextrow: "preserve byte-for-byte" (unimplementable after JSON parsing) replaced with canonicalize-by-the-same-rules, no value/key/array-order changes. - Wire
versionstays"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.
- 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
-
1.0.0-draft.5 (2026-06-09) — Immutability restored; versioning policy.
- Removed
authorization.updateand theauthorization_updateddecision. 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_evalto exactly{matched_condition, field_value}. With immutable authorizations, the top-levelauthorization_idalone pins the rule set in force. - Added the non-normative
replaceslineage 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_evalschema check tightened to the two-member shape with strictmatched_conditioninternals; update-event pairing rules deleted. Test vectors regenerated (update vectors removed;replaces, unknown-member, and malformed-matched_conditionvectors added).
- Removed
-
1.0.0-draft.4 (2026-06-09) — Conditional policy evaluation.
- Added optional top-level
policy_evalblock 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_valueare 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 thepolicy_eval-on-event prohibition (§7 step 3). Test vectors regenerated. - Compatibility: draft.3 verifiers reject any receipt carrying
policy_evalunder the unknown-top-level-field rule. Deploy verifier updates before issuers enablepolicy_evalemission. Receipts that omitpolicy_evalare byte-identical under draft.3 and draft.4.
- Added optional top-level
-
1.0.0-draft.3 (2026-06-03) — Escalation receipts.
- Added
escalateas an action receipt decision. - Added
escalation.resolveevent receipts withescalation_approvedandescalation_rejecteddecisions. - Clarified that authorization create/revoke events require
resource: null, while escalation resolution may carry the resource binding.
- Added
-
1.0.0-draft.2 (2026-05-09) — Naming refinement.
- Introduced the explicit action-receipt discriminator, paired with the existing
eventdiscriminator 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 fromactionvalues toeventvalues. - All existing pairing checks updated; test vectors regenerated.
- Introduced the explicit action-receipt discriminator, paired with the existing
-
1.0.0-draft (2026-04-21) — Initial public draft.
- Flat receipt structure: no wrapping
subjectoractionobjects.user_id,agent_id,action,resource, andcontextare 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_idandagent_id.
- Flat receipt structure: no wrapping
This specification is maintained at https://github.com/Allowly-AI/allowly-receipt-format. Comments, issues, and pull requests welcome.