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

03. Witness attestation

Draft 0.1. Reference implementation: api/request-attestation.js, api/resolve-witness-token.js, api/revoke-witness-request.js, api/witness-requests.js, api/publish-attestation.js, shared/witnessCountersig.js, shared/witnessIdentity.js, api/admin-witness-identity.js, api/_lib/witness-history.js, api/verify-receipt.js (attestation folds, rollup, identity levels), api/revoke-family-receipt.js, api/dispute-attestation.js, frontend/src/pages/VerifyReceiptPage.jsx (tier labels, identity chips, honesty blocks). Vectors v05, v12.

3.1 What a witness is

A witness is an adult outside the family who puts their name on one specific receipt: a work sample (ws_) or a single course row (cr_). The witness produces an attestation (att_), which is itself a public, content-addressed, logged receipt. A witness never edits the family's record; disagreement is recorded beside the family's claim and rendered openly (pin #6: disagreement is rendered, never hidden). The operator never validates the role or the relationship a witness claims (pin #2: roles and relationships are self-declared); the operator records and discloses facts about the ceremony, states how far the signer's identity was checked (section 3.9), and, when enabled, countersigns that the ceremony happened.

3.2 Request

A family asks a specific person to witness a specific receipt.

POST /api/request-attestation (authenticated as the family account)

fieldtyperequiredmeaning
ws_receipt_id or cr_receipt_idreceipt idexactly onethe target; it MUST already exist in the public ledger
witness_emailstringyeslowercased; stored in plaintext on the request row so the invitation can be sent; never copied to the public att_ row
notestring, at most 500nothe family's message to the witness

Rules (MUST):

3.3 Landing and lifecycle

The witness opens /witness/<token>. GET /api/resolve-witness-token?token= (public) resolves the token to the minimum context needed to sign: target_kind (work_sample or course) and the surface fields of the target (for a course: course_title, term, the family-stated grade, credits). No write happens on resolve.

statusconditionHTTP on resolve
pendingnot fulfilled, not revoked, younger than 60 days200
signedfulfilled_at set200 with fulfilled: true and fulfilled_at; the page shows the request as already signed. Single use is enforced at publish time, not at resolve.
withdrawnfamily revoked the link (revoked = true)410
expiredolder than 60 daysrefused

The family lists its own requests (GET /api/witness-requests) and may revoke a pending one by token or by target id (POST /api/revoke-witness-request); ownership is enforced against requester_user from the verified session.

The signing page never pressures the person it invited (pin #4). No scheduled job writes to an attestation request or contacts the person it invited, so an unsigned invitation is never chased; the only scheduled read is a count of pending invitations for the operator's own queue. A request that is ignored simply expires at 60 days. The operator MUST NOT add a reminder sequence, a countdown, a completion meter, or a score of the signer to this surface.

3.4 Signing

POST /api/publish-attestation (public; no account for the witness)

fieldtyperequiredin att_ hashmeaning
id, sha256stringsyes(derived)the client's derivation; the server re-derives and refuses a mismatch
ws_receipt_id or cr_receipt_idreceipt idexactly oneyestarget
signer_namestring, at most 120yesyesself-declared
signer_rolestring, at most 120yesyesself-declared
signer_organizationstring, at most 200noyesself-declared
signer_relationshipstring, at most 200cr_ only, requiredyesfree text such as tutor weekly 18mo; never fact-checked
signed_gradestring, at most 32cr_ only, requiredyesthe grade the witness attests to; MAY differ from the family-stated grade
statementstring, at most 1000noyesfree text
signer_emailstringnoas email_hash_input (lowercased)stored only as signer_email_hash = sha256(lowercase)
signed_atISO 8601no (server default now)nothe leaf timestamp
request_token64 hexrequired for cr_ targets; optional for ws_nobinds the signing to an invitation

Server rules (MUST):

  1. Firewall first. statement and signer_relationship are scanned by the deterministic firewall (shared/firewall.js, policies/firewall.md) before anything is hashed or stored; a statement carrying an SSN, a card number, or an injection payload is refused with a receipt of what was blocked. This validates the text's safety, never the claim's truth.
  2. Target must exist in the public ledger. Attestations on unknown ids are refused so nobody can inflate "witnessed by N" against a receipt that was never issued.
  3. Course rows require an invitation. A cr_-targeted attestation MUST present a live token minted by the issuing family for that exact cr_ (not revoked, not older than 60 days). A walk-up signature on a transcript-grade row is refused (ruling of 2026-09-04). Work samples accept walk-up signatures from /verify/ws_<id>.
  4. No self-attestation on course rows (pin #9). When both the signer's email hash and the transcript issuer's email hash are known and equal, the cr_ attestation is refused with self-attestation refused. On a work sample the same condition is allowed and labeled (signer_is_issuer: true): a homeschool parent witnessing their own child's project is legitimate life, co-signing their own grade assignment is not.
  5. Domain credibility is not granted from a claimed address. signer_verification is the field about the address the signer typed, and it is self_declared for every signer; domain_matched is reserved for a future mailbox-proof flow and MUST NOT be set from an unverified email (red-team finding, 2026-08-13). The identity level of section 3.9 is a different field asking a different question, and it reads the address the family invited, proven by the single-use emailed token, never the typed one.
  6. Idempotent. Re-posting the same attestation returns existed: true; the same id with a different hash is a 409.
  7. The identity level is recorded, not claimed. The server writes witness_identity_level and witness_identity_source from the invited address alone, under the conditions in section 3.9. A level supplied in the request body is ignored.

Ceremony observations recorded outside the hash, disclosed on every verify surface, never used to block:

fieldvaluesmeaning
witness_bindingemailed_link, directhow the witness reached the signing page
signed_from_invited_addresstrue, false, nullwhether the signer's email hash equals the invited address (null when unknowable)
signer_is_issuertrue, false, nullparent-as-witness label on ws_
signer_verification, signer_email_domainself_declared, nullsee rule 5
witness_identity_level0, 1, 2how far the signer's identity was checked; 3 is never stored, see section 3.9
witness_identity_sourcedomain, staff free text, nullhow the level was reached; the free text stays internal and only domain or manual is published
witness_identity_verified_attimestamp or nullserver clock at the moment of the check, never the client's signed_at
org_countersig, org_key_idmultibase signature, did:web:eformogi.com#key-1section 3.5
hidden_by_issuer_attimestamp or nullsection 3.7

After a successful insert the attestation is appended to the transparency log with issued_at = signed_at, and, if a token was used, the request is marked fulfilled and the family is notified.

3.5 Organization countersignature

When family signing is enabled (EFG_SIGNED_RECEIPTS), the operator countersigns the ceremony: Ed25519 with the published organization key over 'eformogi-witness-countersig-v1:' + <the att_ leaf payload> (chapter 02, section 2.5). This asserts "the operator's infrastructure processed this attestation at this time and its bytes have not changed." It asserts nothing about who the witness is. The context prefix is distinct from the family receipt context and the tree-head context so no signature can be replayed as another kind (vector v12; a context-stripped signature MUST fail).

3.6 Tiers as displayed

Each course row carries a tier chosen by the family in the builder. Verify surfaces render the tier as a chip with these labels, and MUST describe them as the issuer's description of the source, never as a rank the operator awards:

tier valuelabelmeaning as rendered
selfFamily-statedthe family wrote it down
familyFamily-gradedthe family graded against a rubric
mentorCo-signedan outside teacher, coach, mentor, or supervisor put their name on it
receiverSource-verifiedthe family states the claim came directly from the source (a registrar record, a score report)

Ledger corroboration rules (api/verify-receipt.js, lookupTranscript):

3.7 Revocation, hiding, and the withdrawn state

actoractmechanismpublic effect
familyrevoke a receipt it issued (tr_, cr_, ws_, wn_, wl_)POST /api/revoke-family-receipt {receipt_id}; sets revoked_at; tr_ cascades to its cr_ rowsverify returns 200 {verified: true, revoked: true, kind: "revoked", original_kind, id, hash, issued_at, revoked_at}; content withheld; never a 404 (a 404 would be a memory hole)
family (issuer of the target)hide an attestation on its receiptsame endpoint with an att_ id; sets hidden_by_issuer_atthe row stops rendering and counting; the verify payload discloses hidden_attestation_count
familywithdraw a witness linkPOST /api/revoke-witness-requestthe token resolves 410; a signing attempt with it is refused
witnessrevoke their own attestationplanned (gap: no witness-initiated revocation path exists; a witness who disputes their own signature uses the dispute rail)
anyonedispute an attestationsection 3.8nothing public changes
nobodyrevoke a kb_key establishment is a historical fact; rotation is the honest operation (chapter 04)

Guest-issued receipts (no bound account) cannot be revoked because no caller can prove issuance. An attestation whose target was revoked still verifies as the signer's own claim, with target_revoked: true and the target's content withheld.

3.8 Dispute

POST /api/dispute-attestation {receipt_id: "att_...", statement, email?} (public, 3 per minute per IP). A dispute is an allegation, not a verdict: it is written durably to the audit log and notifies a human; nothing on the public page changes until a person reviews it. The operator does not gatekeep evaluator legitimacy and does not auto-moderate.

3.9 Identity levels (0 to 3)

Shipped 2026-09-07. shared/witnessIdentity.js holds the single definition of the four levels and their names; api/publish-attestation.js earns the automatic step, api/admin-witness-identity.js is the human path, api/_lib/witness-history.js counts the history, and api/verify-receipt.js publishes the result on the att_, ws_, cr_, and tr_ verify surfaces.

A level says how far the signer's identity was checked, and nothing else. It is a fact about the check the operator performed. It is not a rank of one signer against another, not a score of the work, and not a claim that the role the signer typed is real: names, roles, and relationships stay self-declared at every level, including level 2 (pin #2). Every surface MUST describe a level in those terms.

levelnamehow it is reachedthe sentence a receiver reads
0emailthe default for every signature; nothing beyond an addressEmail address only. No further identity check on file.
1verified domain or licenseautomatically at signing time when the address the family invited belongs to a school, district, government, or military domain (levelForEmail, the one rule in shared/institutionalDomains.js); or by an Eformogi admin who looked up a professional license or registry entry and recorded where and whenInvited at a school or organization address and signed through the link sent there. When the source is a lookup: a professional license or registry lookup was recorded by Eformogi staff.
2verified identityonly by an Eformogi admin who checked an identity document, for counselors of record. The assertion is stored, never the document: the endpoint accepts an attestation id, a level, and a source string, and refuses a body carrying any other keyIdentity checked against an identity document by Eformogi staff. Only the record of the check is kept, never the document.
3established historyderived at read time, never stored. effectiveLevel(stored_level, history_count) returns 3 once the signer's signer_email_hash has co-signed HISTORY_LEVEL_THRESHOLD (5) or more other receipts from the family-invited addressEstablished history: a count of prior co-signatures on file over time.

Rules (MUST):

  1. Only levels 1 and 2 are settable (ADMIN_SETTABLE_LEVELS), by POST /api/admin-witness-identity under the admin secret, with a required free-text source naming where and when the check happened. Every successful call is logged to audit_log as witness_identity_set, and the response reports whether the row landed. Level 0 is the default and is never set. Asking for level 3 is a 400.
  2. Nothing about level 3 is stored. It is computed on every read from ledger rows the receiver could count themselves. The stored level is left alone, so an admin-set 1 or 2 survives a signer whose history has not reached the threshold, and a signer who passes the threshold reads as 3 without any row being rewritten.
  3. The level sits outside the att_ hash, like the V31 and Phase C observations, in witness_identity_level, witness_identity_source, and witness_identity_verified_at. A missing column reads as 0 and null rather than failing a verify.
  4. The automatic step reads the invited address only, and only when the token's target is this exact receipt, the invitation is not revoked, it is younger than 60 days, and the signer did not type a different address (signed_from_invited_address is not false). The signing endpoint is public, so an address the signer merely claims can never raise a level; the antibody is tests/test-attestation-no-unverified-domain.js.
  5. The staff source string is internal. The public payload carries exactly one of two fixed words, domain (earned from the invited address) or manual (recorded by staff). The free text stays in the row and in audit_log, and nothing else ever reaches /verify.
  6. The history count is a count, not a ranking. It counts distinct other receipts co-signed from the family-invited address, capped at 99. A walk-up signature never increments anyone's history and reads 0 for itself, even under a real teacher's address hash.

What api/verify-receipt.js publishes for each signature:

fieldvalues
identity_level0 to 3, after the level-3 derivation
identity_sourcedomain, manual, or null
identity_verified_attimestamp or null
witness_history_count0 to 99

The four fields sit inside the signer object on the att_, ws_, and cr_ payloads, beside the self-declared name and role. On a tr_ payload they are one entry of the course's co_signatures[] with no signer wrapper, because a transcript row publishes how far each signer was checked without republishing who the signer said they were: the cr_ page holds that, and the transcript links to it.

The stored column is witness_identity_level; the published field inside the signer object is identity_level. They are the same fact under two names. signer_verification (section 3.4, rule 5) is neither an alias nor a synonym: it reports the typed address, it stays self_declared, and it never moves because of a level.

The ladder adds no personal data to the public record. It publishes the level, the two-word source, the timestamp of the check, and the count. It never publishes the address, the address hash, the document, or the staff note.

Display (frontend/src/pages/VerifyReceiptPage.jsx): each co-signature renders a chip reading Identity · <the level's name>, with the source line ("Source: the address the family invited." or "Source: recorded by Eformogi staff.") and, at two or more, the sentence "This signer has co-signed N other records." A transcript prints the plain-sentence legend once for every level that appears on it rather than repeating a sentence on each row, and identical chips on one course collapse into a single chip carrying a count.

3.10 Worked example

The public demo attestation on /verify/ws_demo_robotics (a static fixture; no real student) has the shape a real one has:

{
  "id": "att_demo_coach",
  "kind": "attestation",
  "ws_receipt_id": "ws_demo_robotics",
  "signed_at": "2026-04-05T18:30:00Z",
  "signer": { "name": "Sarah Kim", "role": "Lead robotics coach", "organization": "Lincoln High FRC Team 4012" },
  "statement": "Maya was our drive team lead for the 2026 season. ..."
}

A real derivation (vector v05): signer Sarah Kim, role Lead robotics coach, organization Lincoln High FRC Team 4012, target ws_abc123def456, email S.Kim@LincolnHS.org (hashed as s.kim@lincolnhs.org) produces att_5d9b296a66cb.

3.11 What this does not prove

An attestation proves that someone who typed this name, role, and statement signed this exact receipt at this time, and, when countersigned, that the operator processed that signing then. It does not prove the signer is who they say they are, holds the role they claim, or evaluated the work competently. The verify page says exactly this: receivers should verify each signer's identity independently; the operator does not gatekeep evaluator legitimacy. An identity level (section 3.9) narrows that gap without closing it. It reports how far the address behind the signature, or the identity behind the address, was checked, and it says nothing about whether the claimed role is real or the judgment sound. A "Co-signed" chip still means a named person signed.