06. Verification
Draft 0.1. Reference implementation: api/verify-receipt.js, api/receipt-proof.js, scripts/verify-receipt-offline.js (copied as spec/verifier/verify-offline.js), frontend/src/utils/receiptSignature.js, frontend/src/pages/VerifyReceiptPage.jsx. Vectors v18, v19.
6.1 Two paths, one algorithm
A receiver holds an artifact (a printed transcript, a PDF, a work sample, a course row) and a receipt id. Verification answers, in order: is this id a real receipt; do the bytes I hold match what was sealed; who signed; was it publicly logged; what tier of claim is this; has it been withdrawn.
| path | needs | proves |
|---|---|---|
| online | the id, a browser, no account | steps 1 to 6 with the operator's server answering steps 2 and 5 |
| offline | a proof file downloaded earlier (or handed over with the artifact), the published organization key, the reference verifier | steps 1, 2 (artifact hash), 4 (signatures, with the leaf bytes from the proof), 5 (inclusion), without any network and without trusting the operator's server |
The proof file is what the Download proof (JSON) link under the verdict on /verify/<id> saves, and what GET /api/receipt-proof?id=<id> returns. The reference verifier and the conformance vectors are published at /spec/verifier under Apache-2.0, so obtaining the offline path takes no account, no clone, and no permission.
A receiver who does not trust the online answer MUST be able to reproduce it offline from public inputs. That is the design test for every field on the verify payload.
6.2 The algorithm, step by step
Step 0. Classify the id. Match ^(tr|cr|ws|wn|wl|att|kb)_[a-f0-9]{12}$ (case-insensitive). An id with no known prefix falls through to the legacy lookups (event receipts by uuid, consent receipts, plan receipts) and otherwise 404s. ecr_ and tl_ never resolve here (chapter 02).
Step 1. Hash integrity (the receiver's own step). Compute SHA-256 over the artifact you hold, exactly as the issuing surface computed it (the whole transcript issuance payload for tr_; the canonical hash-domain object per chapter 02 for the others). Compare with the receipt's hash. A mismatch means the bytes you hold are not the bytes that were sealed. No server can do this step for you; the verifier's --sha256 flag performs the comparison against the logged leaf.
Step 2. Existence and state. Online: GET /api/verify-receipt?id=. verified: true means the id resolves in the operator's ledger; it does not mean the claims are true. If the payload carries revoked: true, the family withdrew it: stop, and treat the artifact as withdrawn (the id, kind, dates, and hash remain so a receiver holding the link learns the truth rather than hitting a memory hole).
Step 3. Identity of content. The receipt id MUST equal the prefix plus the first 12 hex of the receipt's sha256. A receiver holding the artifact MAY re-derive both from the canonical hash domain (vectors v04 to v08) and MUST treat a mismatch as a forged or corrupted receipt.
Step 4. Signatures.
- Family signature (if
issuer_signatureis present): verify'eformogi-receipt-sig-v1:' + leaf_payloadagainstissuer_signature.public_jwk, in the receiver's own runtime; render the verdict honestly (chapter 04, section 4.5). A receiver SHOULD additionally check thatpublic_jwkmatches thekb_binding atissuer_signature.kb_receipt_id(resolvable at/verify/kb_<id>and provable in the log), and thatkey_established_atprecedesissued_at. The reference verifier does the signature check and derivesdid:keyfrompublic_jwk, requiring it to equalkey_id; resolving thekb_receipt itself needs a second proof file and is still planned.
A verifier MUST NOT fail a receipt because key_established_at is later than issued_at. issued_at is the moment the family sealed the record, and it legitimately precedes the key in at least two ordinary cases: a guest issues a record before having an account and republishes it later with the original issued_at, and a family's first signed receipt is stamped before its signing key is minted. The ordering is worth showing a receiver and worth nothing as a verdict, so an implementation SHOULD report it as an advisory note. A tolerance window is the wrong fix; the gap is weeks. The reference client (frontend/src/utils/receiptSignature.js) verifies against the served public_jwk only; the kb_ join is done server-side when the payload is built.
- Organization countersignature on attestations (if
org_countersigis present): verify'eformogi-witness-countersig-v1:' + <att_ leaf>against the DID document key. - Absence of a signature is not a failure; it means "verifies by integrity alone".
Step 5. Log inclusion. Fetch GET /api/receipt-proof?id= (or use the proof file you hold), then run the four checks the reference verifier runs, in order, all of which MUST pass:
- (optional, with
--sha256) the artifact hash you computed equalssha256insideleaf_payload; leaf_hash == SHA256(0x00 || leaf_payload);- the audit path folds from
leaf_hashatleaf_indexin a tree ofsth.tree_sizeleaves tosth.root_hash(chapter 05, section 5.4); sth.signatureverifies under'eformogi-sth-v1:'against the organization key.
Two further checks run when the proof carries issuer_signature: the family signature of step 4 over the same leaf bytes, and the did:key derivation of key_id from public_jwk. Both are skipped, not failed, when the receipt was not signed with a family key. The key-establishment ordering prints as an advisory note and never changes the verdict. Vectors v21 (a real proof with a valid family signature: all five checks pass) and v22 (the same proof with one character of that signature flipped: refused) pin this.
status: "pending" means no head covers the leaf yet: the verifier exits 1 and says so; try again after the next daily head. Vector v18 passes all four; v19 (one flipped path element) fails at check 3.
Step 6. Tier display. Render the family's tier_counts chips with the labels of chapter 03, section 3.6, muting any mentor or receiver chip the ledger does not corroborate and suffixing it "issuer-declared". Render every attestation with its self-declared fields, its ceremony observations, and its dispute door. Where a witness's signed_grade differs from family_stated_grade, render both.
Step 7. Say what was proved. The tr_, cr_, and ws_ verify surfaces MUST carry a "what this verification means" paragraph and a "what this verification does NOT mean" paragraph (section 6.6). Gaps recorded against the reference implementation: the kb_, wn_, and wl_ pages do not carry the paired paragraphs, and a witnessed ws_ page replaces the "does NOT mean" heading with "Trust ladder: rung 2 reached." followed by the caveat text; extending the pair to every surface and keeping the heading stable when witnessed is planned.
6.3 Verify payloads by kind
Common to every found receipt: verified: true, kind, id, issued_by: "Eformogi". Never returned: student or family identity beyond what the family printed, org or counselor names or emails, narrative text, file paths, or raw metadata blobs.
kind (id prefix) | fields |
|---|---|
family_transcript (tr_) | issued_at, hash, student_name, issuer, program, grad_year, course_count, total_credits, tier_counts or null, courses[] or null (cr_receipt_id, course_title, term, family_stated_grade, credits, signed_count, dissenting_count, source_signed_count, co_signatures[] (always present; [] for a course with no outside co-signer) of {identity_level, identity_source, identity_verified_at, witness_history_count}, newest first, carrying no signer name, email, or hash), course_signature_rollup or null (total, co_signed, with_dissent, source_backed), issuer_signature or null |
course_receipt (cr_) | issued_at, hash, student_name, course_title, term, family_stated_grade, credits, tr_receipt_id, transcript_eligible: true, attestations[], hidden_attestation_count, issuer_signature |
work_sample (ws_) | issued_at, hash, title, entry_kind, issuer, occurred_at, has_evidence, attestations[], hidden_attestation_count, issuer_signature |
weekly_note (wn_) | hash, author: "kid", week_of, issued_at, excerpt, transcript_eligible: false, issuer_signature; no attestations field at all |
wall (wl_) | curator, pinned_ids[], pinned_count, attestation_counts {ws_id: distinct signers}, snapshot_sha256, created_at, updated_at, transcript_eligible: false, issuer_signature |
attestation (att_) | hash, ws_receipt_id or cr_receipt_id, ws_title or course_context {course_title, term, family_stated_grade, student_name}, target_revoked, signed_at, signer {name, role, organization, relationship, verification, verified_domain}, signed_grade, statement, witness_binding, signed_from_invited_address, signer_is_issuer, org_countersig, org_key_id |
key_binding (kb_) | hash, key_id, public_jwk, established_at, custody: "escrowed_by_eformogi" |
revoked (any family prefix) | revoked: true, original_kind, hash, issued_at, revoked_at |
event_receipt (uuid) | event_type, event_category, occurred_at, surfaced_to_family |
consent_receipt (uuid) | type, categories[], status, occurred_at, plus allowlisted non-identifying facts per type |
plan_receipt (legacy) | grad_rules_version, generated_at, depth_label, status, ai_used |
Attestation entries inside attestations[] carry the same signer and ceremony fields as the attestation kind, minus the target context.
6.4 Exact error semantics
GET /api/verify-receipt:
| status | body | when |
|---|---|---|
| 200 | {verified: true, kind, ...} | found (including kind: "revoked") |
| 400 | {error: "id required"} or {error: "id too long"} | missing id or longer than 200 characters |
| 404 | {verified: false, id, message} with a per-prefix message (No Eformogi transcript receipt with this id., ... work-sample receipt ..., ... Sunday Note ..., ... wall ..., ... key-binding receipt ..., ... course receipt ..., ... attestation ..., or the generic No Eformogi receipt with this id.) | unknown id; also when a prefix's table is missing in a fresh environment |
| 402 | {error: "Monthly verification quota exceeded", plan, included, used, hint, upgrade_url} | keyed organization over quota, only when enforcement is on (shadow mode today) |
| 405 | {error: "Method not allowed"} | non-GET |
| 429 | rate-limit response | anonymous: 10 per minute per IP (anti-enumeration); keyed organization: 60 per minute per key plus a 120 per minute per IP backstop |
| 500 | {error: "Lookup failed"} | every lookup path errored |
Headers on keyed calls: X-Verify-Quota-Plan, X-Verify-Quota-Limit, X-Verify-Quota-Used. A present-but-invalid organization key is never an error: it falls back to public attribution so a bad key can never break a free verify. self=1 marks the issuing device viewing its own receipt and suppresses view counting and notification; it can only undercount.
GET /api/receipt-proof:
| status | body | when |
|---|---|---|
| 200 | the proof object (section 6.4.1) | covered leaf |
| 200 | {status: "pending", leaf_index, next_sth, leaf_payload, leaf_hash, issuer_signature} | leaf newer than the latest head |
| 400 | {error: "id required"} or {error: "id too long"} | |
| 404 | {error: "no log entry for this id"} | no leaf, or the log table absent |
| 500 | {error: "log inconsistent with published tree head"} | leaf count under the head does not equal tree_size (refuse rather than fabricate) |
| 500 | {error: "proof lookup failed"} | other |
Reference verifier exit codes: 0 verified or verdict matched; 1 a step failed, the proof is pending, or a vector's verdict mismatched; 2 usage error or unreadable input.
6.4.1 The proof file
This is the whole offline input. It contains no personal data by construction: the leaf grammar is four fields (chapter 02), and the signature block is the public half of a key whose establishment is already a public kb_ receipt.
| field | type | required | meaning |
|---|---|---|---|
receipt_id | string | yes | the receipt this proof is for, lowercased |
leaf_index | integer | yes | position of the leaf in the log, zero-based |
tree_size | integer | yes | leaf count of the tree the head covers; equals sth.tree_size |
leaf_payload | string | yes | the exact canonical bytes that were hashed into the log, verbatim, not re-serialized |
leaf_hash | string | yes | 64 hex; MUST equal SHA-256 of 0x00 followed by leaf_payload |
audit_path | string[] | yes | RFC 6962 audit path from the leaf to sth.root_hash, each 64 hex |
sth | object | yes | the signed tree head, verbatim as signed, plus signature: version, log_id, tree_size, root_hash, chain_hash, timestamp, key_id, signature |
issuer_signature | object | no | present only when the receipt was signed with a family key: sig (multibase base58btc), key_id (did:key), alg (Ed25519), context (eformogi-receipt-sig-v1:), public_jwk, kb_receipt_id, key_established_at, custody |
did_document | string | yes | where to fetch the organization key for step 4 of the inclusion checks |
A pending response carries status: "pending", leaf_index, next_sth, leaf_payload, leaf_hash, and issuer_signature when present. It is enough to check a family signature immediately; it is not enough to prove inclusion, and the verifier refuses rather than implying otherwise.
The sth object MUST be the bytes that were signed, with the signature appended. An implementation that rebuilds it from typed database columns will re-serialize timestamps and break every offline check, which is why the reference implementation stores and returns the signed body verbatim.
6.5 Online versus offline capabilities
| check | online | offline |
|---|---|---|
| artifact hash equals sealed hash | yes | yes (--sha256) |
| id is a real receipt | yes | inferred from inclusion |
| withdrawn state | yes | no (planned: a revocation leaf or a signed revocation list; today revocation is only visible online) |
| family signature | yes, in the browser, against the served public_jwk | yes: the proof carries issuer_signature, and the reference verifier checks the signature and the did:key derivation of key_id (resolving the kb_ receipt offline needs its own proof file and is planned) |
| organization countersignature | yes | yes |
| log inclusion | yes | yes |
| head is anchored externally | anchor_github on the head | check the anchor repository's history yourself |
| tier corroboration, dissent, hidden counts | yes | no (these are ledger folds served online) |
6.6 What this does not prove (the verify page's own words)
The transcript page: "The family issued this transcript through Eformogi. The SHA-256 above was computed at issuance over the entire transcript content. If the printed document you are reading matches the hash, no field has been altered since the family sealed it. The family, not the school, is the records office of record." And: "This is a self-issued, family-attested transcript. The hash proves the bytes haven't changed since issuance; it does not, by itself, prove the underlying claims were evaluated by a third party. Receivers who need third-party-evaluated or issuer-of-record records should ask the family for the linked evidence. The per-course signature chips show who put their name on each row rather than promoting any row to a rank Eformogi cannot award."
The work-sample page, unwitnessed: "This is a self-attested work sample. The hash proves the entry hasn't changed since sealing; it does not, by itself, prove the work was evaluated by a third party." Witnessed: "Each signature is hashed against this same receipt id; signatures cannot be silently altered. Receivers should verify each signer's identity independently; Eformogi does not gatekeep evaluator legitimacy."
The course page: "The grade above is what the family wrote down. Each co-signature below is a separate signed claim from a third party who reviewed the course; where their attested grade differs, both are rendered openly."
An implementation of this specification MUST carry equivalent text on every verify surface and MUST NOT describe a Record as "verified" without the qualifier of what was verified.