Eformogi
The Record: protocol draft 0.1 (CC BY 4.0; verifier Apache-2.0)

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.

prefixobjectid formpublic at /verify/<id>in the transparency logissued by
tr_family-issued transcripttr_ + 12 hexyesyesfamily account
cr_one course row on a transcriptcr_ + 12 hexyesyesfamily account (minted with the transcript)
ws_sealed work samplews_ + 12 hexyesyesfamily account or guest device
wn_sealed weekly notewn_ + 12 hexyesyeslearner via the family account
wl_curated wall snapshotwl_ + 12 hexyesyes (one leaf per snapshot)learner or family
att_witness attestation on a ws_ or cr_att_ + 12 hexyesyesthe witness (public endpoint)
kb_family signing-key bindingkb_ + 12 hexyesyesthe operator, on first signed publish
ecr_institutional consent receipt (Kantara v1.1 shape)ecr_ + 24 hexno: served only by the authenticated GET /api/consent-receipt to the family or the receiving institutionnothe operator, when a family approves an institutional intake
tl_private timeline entrytl_<base36 time>_<6 random>no: on-device onlynothe family's device

Normative rules:

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):

  1. Scalars (null, booleans, numbers, strings) serialize exactly as JSON.stringify does.
  2. Arrays serialize as [ + elements joined by , + ], in the given order. Order is meaningful and is never sorted.
  3. Objects serialize as { + "key":value pairs joined by , + }, with keys sorted by code-unit order at every depth. No whitespace anywhere.
  4. 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).
  5. Any value that will be signed (leaf payloads, kb_ payloads, tree heads, credential documents) MUST additionally pass assertJcsSafe: no non-integer numbers, no undefined. Floats MUST be coerced to strings before signing (jcsCoerceNumbers in 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)

fieldtypenote
studentobject {first, last, dob, gradYear, program, issuer, philosophy}all strings, trimmed where the builder trims
coursesarray of course rows (chapter 01, section 1.4)in the family's order, including tier
issued_atISO 8601inside the domain for tr_ (re-issuing produces a new tr_)
issuer_userstringaccount id, or unauth-preview for a guest
person_idstringor 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)

fieldtypenote
kind"course"literal
student_namestringclipped to 200
course_titlestringclipped to 300
termstringclipped to 100
gradestringclipped to 32
creditsstring or nullthe 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):

fieldtype
entry{kind, title, detail or null, evidence_url or null, at}
issuerstring, default Family Records Office
person_idstring or null
source_entry_idthe 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:

fieldnormalization
issued_atparsed and re-serialized as ISO 8601 with milliseconds and Z
prefixone of tr cr ws wn wl att kb
receipt_idlowercased
sha256lowercased, 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.

fieldtypemeaning
iduuidthe audit row id; verifies at /verify/<id> as kind: event_receipt
event_typestring from ELIGIBLE_EVENT_TYPESe.g. consent.approved, vault.exported, firewall.blocked, agent.action_logged
occurred_atISO 8601
icon, title, bodystringsthe plain-language card; body never echoes narrative content or PII
actionssubset of verify, revoke, portwhat the family can do from the card
verify_url/verify/<id>
raw_event_typestringthe 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).