02. Receipts and canonicalization
Draft 0.1. Reference implementation: shared/merkleLog.js (canonicalize, assertJcsSafe, buildLeafPayload, RECEIPT_PREFIXES), api/publish-course-receipts.js (deriveCourseReceipt), api/publish-attestation.js, frontend/src/utils/timeline.js (sealEntry, sealWeeklyNote, deriveWallId), frontend/src/pages/TranscriptBuilderPage.jsx (handleIssue), shared/familySigning.js (buildKbPayload), api/institutional/consent.js and api/consent-receipt.js (ecr_), shared/receipts.js (family-facing receipt shape). Vectors v01 to v09.
2.1 Definitions
A receipt is a small public record that a specific artifact existed with a specific content hash at a specific time, identified by a prefix and a content-derived id. A receipt carries only the surface metadata needed to check an artifact the receiver already holds; the artifact's content stays with the family.
A canonical form is the single byte string a given JSON value serializes to, so that two implementations hashing the same value produce the same hash.
2.2 The receipt grammar
The grammar is locked. Nine prefixes exist. Seven are public and log-eligible; two are not.
| prefix | object | id form | public at /verify/<id> | in the transparency log | issued by |
|---|---|---|---|---|---|
tr_ | family-issued transcript | tr_ + 12 hex | yes | yes | family account |
cr_ | one course row on a transcript | cr_ + 12 hex | yes | yes | family account (minted with the transcript) |
ws_ | sealed work sample | ws_ + 12 hex | yes | yes | family account or guest device |
wn_ | sealed weekly note | wn_ + 12 hex | yes | yes | learner via the family account |
wl_ | curated wall snapshot | wl_ + 12 hex | yes | yes (one leaf per snapshot) | learner or family |
att_ | witness attestation on a ws_ or cr_ | att_ + 12 hex | yes | yes | the witness (public endpoint) |
kb_ | family signing-key binding | kb_ + 12 hex | yes | yes | the operator, on first signed publish |
ecr_ | institutional consent receipt (Kantara v1.1 shape) | ecr_ + 24 hex | no: served only by the authenticated GET /api/consent-receipt to the family or the receiving institution | no | the operator, when a family approves an institutional intake |
tl_ | private timeline entry | tl_<base36 time>_<6 random> | no: on-device only | no | the family's device |
Normative rules:
- A public receipt id MUST be
<prefix>_followed by the first 12 lowercase hex characters of the SHA-256 of the artifact's canonical hash-domain bytes (section 2.4). 48 bits of id space is deliberate: the id is a handle, and the full 64-hexsha256travels with every receipt for integrity. ecr_is NOT content-addressed. Its 24 hex characters aresha256("<consent.id>:<time>")truncated, minted once and stored on the consent row. It MUST NOT resolve on the public verify page and MUST NOT be appended to the transparency log (RECEIPT_PREFIXESexcludes it;buildLeafPayloadthrows on it; vector v03).tl_is a local identifier. It MUST NOT be presented as a receipt id and MUST NOT be published.- Implementations MUST validate ids against
^<prefix>_[a-f0-9]{12}$(case-insensitive on input, lowercased on storage) before any lookup.
2.3 Canonical JSON
The reference implementation carries one canonicalize function in five places (shared/merkleLog.js, api/publish-attestation.js, api/publish-course-receipts.js, frontend/src/utils/timeline.js, TranscriptBuilderPage.jsx), gate-tested for source-level parity (tests/test-canonicalize-parity.js, tests/test-merkle-log.js).
Rules (MUST):
- Scalars (
null, booleans, numbers, strings) serialize exactly asJSON.stringifydoes. - Arrays serialize as
[+ elements joined by,+], in the given order. Order is meaningful and is never sorted. - Objects serialize as
{+"key":valuepairs joined by,+}, with keys sorted by code-unit order at every depth. No whitespace anywhere. - Strings are hashed as their UTF-8 bytes. No Unicode normalization is applied (gap, recorded: two strings that render identically but differ in code points hash differently; issuers SHOULD capture text once and never re-normalize it).
- Any value that will be signed (leaf payloads,
kb_payloads, tree heads, credential documents) MUST additionally passassertJcsSafe: no non-integer numbers, noundefined. Floats MUST be coerced to strings before signing (jcsCoerceNumbersin chapter 07).
This dialect coincides with RFC 8785 (JCS) on the subset of values the Record uses (strings, booleans, null, integers, arrays, objects). It is not a full JCS implementation: number formatting for non-integers is undefined here and forbidden by rule 5.
Worked example (vector v01):
input {"z":1,"a":[3,1,"x",null,true],"m":{"z":3,"a":4},"name":"Zoë Ærø","emoji":"✦"}
output {"a":[3,1,"x",null,true],"emoji":"✦","m":{"a":4,"z":3},"name":"Zoë Ærø","z":1}
2.4 Hash domains per prefix
The hash domain is the exact object whose canonical bytes are hashed to produce the id. Everything outside the domain (timestamps of publication, signatures, tiers, ceremony observations, provenance links) MAY change without changing the id. Implementations MUST hash exactly these objects and MUST NOT add fields.
tr_ (transcript)
| field | type | note |
|---|---|---|
student | object {first, last, dob, gradYear, program, issuer, philosophy} | all strings, trimmed where the builder trims |
courses | array of course rows (chapter 01, section 1.4) | in the family's order, including tier |
issued_at | ISO 8601 | inside the domain for tr_ (re-issuing produces a new tr_) |
issuer_user | string | account id, or unauth-preview for a guest |
person_id | string | or unauth-preview |
Worked example (vector v08, gold-demo shape): student Maya Chen, program Traditional homeschool · NY, issuer Chen Family Records Office, two courses, issued 2026-05-12T14:08:00.000Z, produces tr_d83e84e54c94 with sha256 d83e84e54c94637b51e7b56e8e92da9b8c6e05e94ed9f6dcf107dcce4f97dfbf. (The verify page's placeholder id tr_8e59bb9641e8 is illustrative copy, not a fixture.)
The public surface published with a tr_ is only what the family already printed: student_name, issuer, program, grad_year, course_count, total_credits, sha256, issued_at, and the tier distribution tier_counts (server-projected to the four tiers and stored only when it sums to course_count).
cr_ (course row)
| field | type | note |
|---|---|---|
kind | "course" | literal |
student_name | string | clipped to 200 |
course_title | string | clipped to 300 |
term | string | clipped to 100 |
grade | string | clipped to 32 |
credits | string or null | the credits as posted, stringified before any numeric coercion; null when absent |
tr_receipt_id is provenance, stored on the row, outside the domain, and refreshed on re-issuance. The server re-derives the id and refuses a client-claimed id that does not match. Same tuple, same cr_, same attestations; changing any field mints a new cr_ and the old attestations stay attached to the old grade.
Worked example (vector v04): {Maya Chen, Algebra II, 2025-26 · Year, A, "1.0"} produces cr_0ad5487e3ca4, sha256 0ad5487e3ca41a0994f917602b49e7ecf08e925077395605f3d7f33b1594eb10.
ws_ (work sample), two versions
Version 1 (inline or no evidence):
| field | type |
|---|---|
entry | {kind, title, detail or null, evidence_url or null, at} |
issuer | string, default Family Records Office |
person_id | string or null |
source_entry_id | the tl_ id |
Version 2 (evidence in the encrypted evidence store) replaces evidence_url with evidence_sha256 and adds v: 2 as the first member, so the two domains are disjoint by construction. issued_at is envelope metadata, outside the domain, so re-sealing the same entry on another device yields the same ws_.
Worked example (vector v06): a project titled Hand at 2026-04-12T00:00:00.000Z, issuer Family Records Office, person p_demo, source tl_abc, produces ws_53071e72cd4a.
wn_ (weekly note)
{kind: "weekly_note", author: "kid", week_of: "YYYY-MM-DD" (the ISO Monday), body}. author is a literal inside the domain; the publish endpoint and the SQL CHECK refuse any other value. Vector v07: wn_e45fc1d88617.
wl_ (wall)
Identity: {kind: "wall", curator: "kid" | "family", person_id or null, created_at}. Mutability lives in snapshot_sha256 = sha256(JSON.stringify(pinned_ids)) over the ordered array of pinned ids (not canonicalized, not sorted; the curator's order is the content). Each re-curation appends a new log leaf for the same wl_ id with the new snapshot hash.
att_ (attestation)
ws_ target: {ws_receipt_id, signer: {name, role, organization or null}, statement or null, email_hash_input}. cr_ target: {cr_receipt_id, signer: {name, role, organization or null, relationship}, signed_grade, statement or null, email_hash_input}.
email_hash_input is the signer's email lowercased, or the empty string. The stored row keeps only signer_email_hash = sha256(lowercased email); the address itself is never stored. signed_at and every ceremony observation (chapter 03) are outside the domain. Vector v05: att_5d9b296a66cb.
kb_ (key binding)
{established_at (ISO ms), key_id (did:key), public_jwk: {crv: "Ed25519", kty: "OKP", x}}. Frozen; vector v09 pins kb_b5ed898815ac. See chapter 04.
2.5 The log leaf
Every public receipt is committed to the transparency log (chapter 05) as one leaf whose payload is exactly four fields, canonicalized:
| field | normalization |
|---|---|
issued_at | parsed and re-serialized as ISO 8601 with milliseconds and Z |
prefix | one of tr cr ws wn wl att kb |
receipt_id | lowercased |
sha256 | lowercased, 64 hex |
Worked example (vector v02): input {TR_AB12CD34EF56, tr, "A"×64, 2026-07-04T00:00:00Z} produces
{"issued_at":"2026-07-04T00:00:00.000Z","prefix":"tr","receipt_id":"tr_ab12cd34ef56","sha256":"aaaa...aaaa"}
Which sha256 and which timestamp enter the leaf per prefix: tr_ uses sha256 and issued_at; cr_ uses sha256 and the row's created_at (the mint time); ws_/wn_ use sha256 and issued_at; wl_ uses snapshot_sha256 and the snapshot time; att_ uses sha256 and signed_at; kb_ uses its sha256 and established_at. A leaf MUST NOT carry PII, signatures, or any field beyond these four; the daily signer refuses to sign over a leaf that fails this grammar (chapter 05, section 5.5).
2.6 The family-facing receipt shape (Kantara and FDEP alignment)
Beyond the content-addressed receipts above, the reference implementation renders event receipts to the family from its audit log (shared/receipts.js). This is the shape the family sees for every consent, share, export, credential, firewall refusal, or agent action; it exists so that every act on the family's data is visible as a card the family can verify.
| field | type | meaning |
|---|---|---|
id | uuid | the audit row id; verifies at /verify/<id> as kind: event_receipt |
event_type | string from ELIGIBLE_EVENT_TYPES | e.g. consent.approved, vault.exported, firewall.blocked, agent.action_logged |
occurred_at | ISO 8601 | |
icon, title, body | strings | the plain-language card; body never echoes narrative content or PII |
actions | subset of verify, revoke, port | what the family can do from the card |
verify_url | /verify/<id> | |
raw_event_type | string | the stored type, for filtering |
Every eligible event type is classified by shared/ratio-test.js as protective, extractive, or neutral, and the classification MUST cover every event type the code writes (gate-tested). A receipt that cannot say in one sentence what the family gained or what the vault refused does not belong in the eligible list.
The consent receipt served at GET /api/consent-receipt follows the Kantara Initiative Consent Receipt Specification v1.1 with FDEP extensions: @context: https://schema.eformogi.com/v2/consent-receipt, type: ConsentReceipt, version: "1.1", receiptId (ecr_), consentId, issuedAt, consentedAt, expiresAt, revokedAt, status, dataController {name, type, identifier}, dataSubject {identifier} (an opaque user id, no PII), categories[{category, purpose, lawfulBasis: "consent"}], provenance, and consent {type: explicit, mechanism, granularity: field-level, purpose, duration, revocable, downstreamSharing, proofMode}. It is an institutional-product artifact and is documented here only because its prefix is part of the grammar.
2.7 What this does not prove
A receipt proves that bytes with a given hash were sealed under a given id at a given time, and (once logged) that they were in the public log by the next signed tree head. A receipt does not prove that the claim inside the artifact is true, that the family is who it says it is, or that any third party looked at the work. The id proves identity of content, not authorship: authorship is a signature question (chapter 04) and a witness question (chapter 03).