Everything a receipt references must be retrievable by hash for seven years (spec §8), and receipts are verified offline against these artifacts for as long as they exist (§6.4). So the shapes below are frozen from the first production write. A change is a new version (a new receipt_, a new key prefix, a new content type), never an edit of an existing one.
1. Receipt schema: delegus-receipt-v1 #
The receipt is spec §6.2. The engine emits exactly these fields, in this order in the JSON body; the signed payload is the JCS of the body without receipt, so field order never affects verification.
| Field | Present | Notes |
|---|---|---|
receipt_version | always | "delegus-receipt-v1" |
receipt_id | always | drc_<ULID> |
decision, reason | always | reason is null on ALLOW, else a §5.2/§5.3 code |
evaluated_at | always | RFC 3339 UTC with milliseconds; the historical clock |
relying_party | always | RP DID |
principal, agent, grant_id, authority, expires_at | only once P3 passed (§6.4) | authority is the matching Capability, or null on DENY |
grant_hash, proof_hash | always | sha256:<hex> over the compact JWS strings as received |
request_hash | when the action canonicalizes | sha256:<hex> over UTF-8 of JCS(action) |
evidence.issuer_resolution | once the issuer lookup succeeded | did, did_document_hash, resolved_at |
evidence.status | once the revocation state was read | credential_url, credential_hash, valid_from (if the credential parsed), checked_at, status_list_index |
evidence.trust_config_version, evidence.trust_config_hash | always | |
protocol | always | result PASS/FAIL, profile delegus-base-v1, profile_hash, checks P1…P20 each true \| false \| "skipped" |
trust | always | result, policy_version delegus-trust-v1, checks T1, T2, T3, T5 |
receipt | always | non-detached compact JWS: alg EdDSA, typ delegus-receipt+jwt, kid did:web:<domain>#receipt-<n> |
Reason codes, check ids and the two result values are the enums in docs/ (ReasonCode, ProtocolCheckId, TrustCheckId); the engine's tests assert the document equals the code.
1.1 Receipt schema: delegus-receipt-v2 (v0.3-rc1, frozen at the v0.3 freeze)#
Spec v0.3 §6.2 and §6.4. The same rules as v1 (body order, JCS payload, evaluated_ as the historical clock) with these differences: the matched Capability is never in the receipt in plaintext, and the signing input is at most 4,096 bytes whatever the Grant or the number of dependencies.
| Field | Present | Notes |
|---|---|---|
receipt_version | always | "delegus-receipt-v2" |
receipt_id, decision, reason, evaluated_at, relying_party | always | as v1; reason may also be a v0.3 code (P21, D1–D7) or ISSUER_NOT_TRUSTED |
principal, agent, grant_id, expires_at | only once P3 passed | principal.verification is domain, managed or unverified |
grant_hash, proof_hash | always | as v1 |
request_hash | when the action canonicalizes | as v1 |
requires_hash | when the request carried requires | sha256:<hex> over UTF-8 of JCS(requires) as submitted |
authority_commitment | when a Capability matched (ALLOW, or a DENY decided after P16) | the §R.6 commitment over the matched Capability; the Capability and the opening go to the relying party beside the receipt and are kept privately (§5). There is no authority field in a v2 receipt |
transaction | ALLOW only | the 22-character pairwise handle (§R.3) |
relies_on_hash | when the Action declared relies_on | sha256:<hex> over UTF-8 of JCS(the entries with their per-entry results), returned beside the receipt |
evidence.issuer_resolution, evidence.status | as v1 | |
evidence.consumption | a budgeted Capability matched, or P21 refused on one | snapshot_hash, as_of; capability_key_commitment, capability_index for the Capability chosen; exhausted (total, period or uses) when P21 refused; consumed when the spend ran |
evidence.trust_config_version, evidence.trust_config_hash | always | trust_0003 |
protocol | always | profile delegus-base-v3, profile_hash, checks P1…P21 and D1…D7 |
trust | always | policy_version delegus-trust-v3, checks T1, T2, T3, T5, T7 |
receipt | always | as v1 |
2. Evidence store (S3, Object Lock)#
| Bucket | one per environment (delegus-evidence-<env>-<account>, infra) |
| Object key | sha256/<64 lowercase hex>: the artifact's own hash, so retrieval is GET /evidence/sha256:<hex> and the key can never disagree with the content |
| Write | PutObject with If-None-Match: *; a 412 means the bytes are already there (same hash, same bytes) and counts as success. No ServerSideEncryption or SSEKMSKeyId headers; bucket default encryption applies. |
| Read | GetObject by key; served with the stored content type, Cache-Control: public, max-age=31536000, immutable, ETag = the hex hash. Consumption snapshots (v0.3) are the exception: GET /evidence answers them with the same 404 as an unknown hash (§5) |
| Delete | never issued by the API |
| Retention | Object Lock COMPLIANCE, 7 years, in production; GOVERNANCE, 1 day, in the dev environment (infra) |
Artifact kinds and their content types:
| Artifact | Hash in the receipt | Content type | Bytes |
|---|---|---|---|
| DID document snapshot | evidence.issuer_resolution.did_document_hash | application/did+json | the response body exactly as fetched (hosted) or exactly as generated (managed) |
| Status-list credential | evidence.status.credential_hash | application/vc+jwt | the compact JWS string, ASCII |
| Profile document | protocol.profile_hash | text/markdown; charset=utf-8 | docs/profiles/delegus-base-v1.md, 1836 bytes, sha256:23d4ace09635816acf058cf86f0db15dae3ea802fce4ab2e916df0412ac7aa4e |
| Trust-configuration descriptor | evidence.trust_config_hash | application/json | docs/trust-config/trust_0001.json, sha256:a075fa93bed9b85cf2697df1ea80f65c1d4b0ca00afcaab788d486e477cb1196 |
Profile document, delegus-base-v3 (v0.3-rc1, not frozen) | protocol.profile_hash | text/markdown; charset=utf-8 | docs/profiles/delegus-base-v3.md; the bytes and hash are fixed when v0.3 is frozen (until then they move with every rc1 edit: shasum -a 256 the file). Written at startup once DELEGUS_V3_ENABLED is set; before that, by each API process before its first v3 evaluation |
Trust-configuration descriptor, trust_0003 (v0.3-rc1, not frozen) | evidence.trust_config_hash | application/json | docs/trust-config/trust_0003.json; fixed when v0.3 is frozen (until then shasum -a 256 the file). Written with the v3 profile document |
| Consumption snapshot (v0.3, private) | evidence.consumption.snapshot_hash | application/vnd.delegus.consumption+json | UTF-8 of JCS({ grant_id, as_of, version, capabilities: [{ key, spent_total, spent_in_period, period_start, uses_spent }] }); written only for a receipt that pins it, after that receipt is signed. Same bucket and sha256/<hex> key as the public kinds; never served by GET /evidence (§5) |
| Receipt JWS (Layer 1, private) | none: the key is sha256 over the receipt's own compact JWS, ASCII | application/jose | the receipt member exactly as issued. Written for every receipt (v1 and v2, ALLOW and DENY) after it is signed and before its row is stored; a failed write is a 503 and no receipt is stored. Never served by GET /evidence (§5) |
| Private record (Layer 1, v0.3, private) | none: the hash is kept on the receipt's row (private_evidence_hash, migration 026) | application/vnd.delegus.private+json | UTF-8 of JCS({ receipt_id, authority, authority_opening, capability_key_opening, dependency: { fingerprint, opening, transaction, handle, grant_kid } \| null, disclosure }): exactly what the §5 columns hold for that receipt, openings in their 43-character wire form. Written beside the JWS for a v2 receipt with anything private (a v1 receipt, and a v2 DENY decided before any Capability matched, have none). No claimant, terms, organization or key material. Never served by GET /evidence (§5) |
| Passkey approval record (private) | none in a receipt; returned beside the Grant by POST /principals/{did}/grants/{id}/approve as approval_record_hash | application/vnd.delegus.approval+json | UTF-8 of JCS of the delegus-approval-v1 record (docs/custody/passkey-approval.md): the Grant id and hash, the signing-input hash, the key id, and per approval the approver (org id, id, name), the passkey (credential id, algorithm, public key), the relying party id, the full WebAuthn assertion, its hash and the times. Written once, when a Delegus-held key signs an approved Grant. Never served by GET /evidence (§5) |
The private kinds (consumption snapshot, receipt JWS, private record, passkey approval record) share the bucket and the sha256/ key with the public kinds; GET / answers them with the same 404 as an unknown hash, by content type (PRIVATE_ in apps/). They are read only through the receipt's own routes, by its entitled parties.
Receipts issued before Layer 1 are brought in by the backfill (apps/): a dry run by default that counts what is missing; --apply writes each missing JWS and private record and records the private record's hash on the row; --extend (with --apply) also raises retention as in §5; --limit N stops after N receipts. It is idempotent: an object already present is counted, not rewritten. It exits 1 when any receipt failed (each failure is listed by receipt_id) and 2 on a usage error, and it refuses --apply against a memory evidence store. The production run is the owner's dispatch.
Grants and Proofs are not stored by Delegus; the receipt pins their hashes and the relying party keeps the bytes (§8 lists what Delegus retains). The checked Action is kept on the receipt's row (action_* columns, migration 013) so an entitled party can read what was decided; a retention job nulls those columns seven years after the decision and leaves everything else. Receipts themselves are stored in Postgres (receipts) by receipt_id, with the full body and JWS; the JWS is the canonical receipt. v0.3 receipts (delegus-) are stored the same way; what opens the commitments they pin is kept privately beside them (§5).
3. Status-list credential#
One Bitstring Status List (131,072 bits) per Principal, published at https: with Cache-:
- compact JWS,
algEdDSA,typvc+jwt,kiddid:;web: <domain>#status- <n> - payload
@context["https:,/ / www. w3. org/ ns/ credentials/ v2"] type["VerifiableCredential",,"BitstringStatusListCredential"] id= the published URL,issuer= the service DID,validFrom(backdated 60 s for clock skew),credentialSubject{ id: "<url>#list", type: "BitstringStatusList", statusPurpose: "revocation", encodedList }; encodedListisu+ base64url(GZIP(bitstring)), bit index most significant bit first; a Grant'sstatusListIndexis the bit number.
Every re-signed list (each revocation) is a new artifact in the evidence store; receipts pin the one that was evaluated.
4. Keys#
- Receipt keys
did:, status keysweb: <domain>#receipt- <n> did:, Ed25519 in KMS. Rotation addsweb: <domain>#status- <n> <n+1>and keeps<n>published (DELEGUS_); a key is never removed from the DID document while any receipt it signed is within retention.RECEIPT_ KEYS_ RETIRED - The service DID document is served by the API at
/(and, for the DID domain itself, at the same path on that host); the production copy is a static file the website publishes from the same outputs.. well- known/ did. json
5. Private records (v0.3-rc1, delegus-base-v3 )#
A v2 receipt pins its matched authority, the ledger key of a budgeted Capability and its CURRENT_ dependency evidence under commitments, and a later action names it only by a fingerprint (spec v0.3 §R). What opens those commitments is kept by Delegus and is private: none of it is published by hash, GET / never serves it, and no route lists or looks up anything by fingerprint or transaction handle.
Openings are 32 bytes from the CSPRNG, fresh for each commitment and never reused; their wire form is unpadded base64url, exactly 43 characters.
Kept on the receipt's row in Postgres (receipts), written in the same insert as the receipt (for a budgeted ALLOW, in the same transaction as the spend):
| Column | Written for | Holds |
|---|---|---|
authority (jsonb), authority_opening (bytea) | a v2 receipt that pins authority_commitment (a Capability matched: ALLOW, and a DENY decided after the match) | the matched Capability as it appears in the Grant, and the opening of authority_commitment |
capability_key_opening (bytea) | a v2 receipt whose evidence.consumption pins capability_key_commitment | the opening; the ledger key itself is not kept here (an entitled party recomputes it from authority) |
dependency_fingerprint, dependency_opening (bytea) | a v2 ALLOW | the receipt's fingerprint (unique; looked up by exact value only, for D2) and the opening it was computed under |
txn_internal, txn_handle | a v2 ALLOW | the Delegus-internal transaction id (never in a receipt, a response or an API) and the pairwise handle the receipt carries |
dependency_grant_kid | a v2 ALLOW | the kid that signed the receipt's Grant, read for D7 when a later action declares it CURRENT_AUTHORITY |
dependency_disclosure (jsonb) | a v2 ALLOW that declared dependencies; nothing is kept for a DENY | the stored part of the §R.5 disclosure package, per entry in declaration order: fingerprint, mode, checks (D2–D7), transaction_equal (D5), receipt_hash, and for CURRENT_AUTHORITY status_list_index, status_credential (the compact JWS read before P6) and evidence_nonce (the opening of evidence_commitment) |
Consumption snapshots are kept in the evidence store (§2), private by content type.
Who receives what (a receipt's entitled parties are its relying party through its own key, its principal through its own key, a key of an organization that owns that relying party or principal, and Delegus staff through the admin token; relying-party and principal keys are matched by kind, and observer keys are not entitled):
| Route | Caller | Returned |
|---|---|---|
POST /verify | the relying party, in the response, beside the receipt (outside its signed payload) | authority and authority_opening when pinned, capability_key_opening when pinned, relies_on when the receipt pins relies_on_hash, and on ALLOW opening { fingerprint, nonce }. This is the only response that carries a receipt's own fingerprint opening for that receipt |
GET /decisions/{receipt_id} | entitled parties | the receipt, its outcomes, and authority, authority_opening, capability_key_opening when kept |
GET /decisions/{receipt_id}/disclosure | entitled parties; Cache-Control: no-store | receipt_id; the same three members; consumption_snapshot when the receipt pins one; and relies_on, per stored entry, with the dependency receipt's JWS (receipt) and its opening added only when the caller is also entitled to that dependency receipt |
Anyone else, an unknown receipt, and a receipt with nothing to disclose get the same 404 on both /decisions routes. GET / (list), / and POST / return no openings.
Retention: consumption snapshots, receipt JWS objects and private records follow the evidence bucket (§2: Object Lock COMPLIANCE, 7 years, in production). The Postgres columns above have no delete or clear path in the API; the action-data retention job nulls only a receipt's action_* columns after seven years and leaves these columns, the receipt and its evidence untouched.
The bucket locks an object for seven years from its first write, but a receipt issued years later may pin an object first stored long before (a DID document or status list that has not changed, a profile document, a trust configuration). So a daily retention-extension job, in the API's maintenance loop, takes the previous UTC day's receipts and raises the retain-until date of every object each one references (its body's did_, credential_, snapshot_, trust_ and profile_, its own JWS, and its private record) to that day's end + 7 years + 1 day. It only ever moves a date later, so a re-run is safe; the day is marked done (setting evidence_, and an evidence. audit event) only when every extension succeeded, and a day with a failure is retried by the next run. The Postgres evidence store has no retention lock and reports unsupported: its objects last as long as their rows. The backfill's --extend applies the same rule to every receipt it scans, by each receipt's own day.
A receipt stored after its day was marked done. The job runs on complete days, but a receipt can be written in the seconds after a day's run marked it done (a request that began before midnight). Its objects keep the date they had from first storage or from an earlier receipt, and its own JWS and private record hold the bucket's default retention from their write; the window is seconds per day, and the backfill's --extend covers any such receipt when it is next run. Accepted.
Orphaned objects. The store is written before the row: a receipt whose evidence landed but whose row (or spend transaction) then failed answers 503, and its JWS (and private record) stay in the locked store with no row naming them. They are content-addressed, private kinds that GET / never serves, and no route reaches them; they cost storage, nothing else. The backfill and the retention job start from rows, so they never touch them.
6. Operational records (not receipt evidence, but shape-frozen)#
- Request log line:
{ time, level, msg: "request", request_plus, on verify,id, method, path ( templated), host, status, duration_ ms } rp, principal, grant_. Never keys, Grants, Proofs or receipt bodies.id, receipt_ id, decision, reason, engine_ ms - Audit event:
{ event_; append-only.id: aud_ <ULID>, occurred_ at, actor { kind, did, key_ id }, action noun. verb, target, request_ id, details }
7. What changing any of this means#
A new receipt version (delegus-) with its own verifier in core; a new key prefix for any new public artifact kind (a private kind, such as the consumption snapshot, shares the sha256/ prefix and is kept out of GET / by its content type, which a test pins); a new profile or trust-config file with a new hash. Old receipts must keep verifying with the old shapes for their entire retention.