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)
| field | type | required | meaning |
|---|---|---|---|
ws_receipt_id or cr_receipt_id | receipt id | exactly one | the target; it MUST already exist in the public ledger |
witness_email | string | yes | lowercased; stored in plaintext on the request row so the invitation can be sent; never copied to the public att_ row |
note | string, at most 500 | no | the family's message to the witness |
Rules (MUST):
- The request is bound to the requesting account (
requester_user). Anonymous requests are refused so witness inboxes cannot be enumerated. - The token is 32 bytes from a cryptographic random source, rendered as 64 lowercase hex, opaque, and single-use: once
fulfilled_atis set, a second signing with the same token is refused at publish time. - Re-requesting the same
(target, witness_email)pair returns the existing token instead of minting a second one. - Rate: 20 requests per minute per IP (
api/request-attestation.js), on top of the sign-in requirement.
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.
| status | condition | HTTP on resolve |
|---|---|---|
pending | not fulfilled, not revoked, younger than 60 days | 200 |
signed | fulfilled_at set | 200 with fulfilled: true and fulfilled_at; the page shows the request as already signed. Single use is enforced at publish time, not at resolve. |
withdrawn | family revoked the link (revoked = true) | 410 |
expired | older than 60 days | refused |
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)
| field | type | required | in att_ hash | meaning |
|---|---|---|---|---|
id, sha256 | strings | yes | (derived) | the client's derivation; the server re-derives and refuses a mismatch |
ws_receipt_id or cr_receipt_id | receipt id | exactly one | yes | target |
signer_name | string, at most 120 | yes | yes | self-declared |
signer_role | string, at most 120 | yes | yes | self-declared |
signer_organization | string, at most 200 | no | yes | self-declared |
signer_relationship | string, at most 200 | cr_ only, required | yes | free text such as tutor weekly 18mo; never fact-checked |
signed_grade | string, at most 32 | cr_ only, required | yes | the grade the witness attests to; MAY differ from the family-stated grade |
statement | string, at most 1000 | no | yes | free text |
signer_email | string | no | as email_hash_input (lowercased) | stored only as signer_email_hash = sha256(lowercase) |
signed_at | ISO 8601 | no (server default now) | no | the leaf timestamp |
request_token | 64 hex | required for cr_ targets; optional for ws_ | no | binds the signing to an invitation |
Server rules (MUST):
- Firewall first.
statementandsigner_relationshipare 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. - 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.
- Course rows require an invitation. A
cr_-targeted attestation MUST present a live token minted by the issuing family for that exactcr_(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>. - 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 withself-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. - Domain credibility is not granted from a claimed address.
signer_verificationis the field about the address the signer typed, and it isself_declaredfor every signer;domain_matchedis 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. - Idempotent. Re-posting the same attestation returns
existed: true; the same id with a different hash is a 409. - The identity level is recorded, not claimed. The server writes
witness_identity_levelandwitness_identity_sourcefrom 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:
| field | values | meaning |
|---|---|---|
witness_binding | emailed_link, direct | how the witness reached the signing page |
signed_from_invited_address | true, false, null | whether the signer's email hash equals the invited address (null when unknowable) |
signer_is_issuer | true, false, null | parent-as-witness label on ws_ |
signer_verification, signer_email_domain | self_declared, null | see rule 5 |
witness_identity_level | 0, 1, 2 | how far the signer's identity was checked; 3 is never stored, see section 3.9 |
witness_identity_source | domain, staff free text, null | how the level was reached; the free text stays internal and only domain or manual is published |
witness_identity_verified_at | timestamp or null | server clock at the moment of the check, never the client's signed_at |
org_countersig, org_key_id | multibase signature, did:web:eformogi.com#key-1 | section 3.5 |
hidden_by_issuer_at | timestamp or null | section 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 value | label | meaning as rendered |
|---|---|---|
self | Family-stated | the family wrote it down |
family | Family-graded | the family graded against a rubric |
mentor | Co-signed | an outside teacher, coach, mentor, or supervisor put their name on it |
receiver | Source-verified | the family states the claim came directly from the source (a registrar record, a score report) |
Ledger corroboration rules (api/verify-receipt.js, lookupTranscript):
- A
mentorchip renders normally only when the transcript has at least one course with an attestation on file (course_signature_rollup.co_signed > 0); otherwise it renders muted with the suffix "issuer-declared". - A
receiverchip renders normally only when at least one on-ledger signature came from a matched institutional domain (source_backed > 0); sincedomain_matchedis not currently granted, everyreceiverchip today renders as issuer-declared. - A signer is bound when
signed_from_invited_address === true, orsigner_verification === 'domain_matched', or anorg_countersigis present. Only bound signers can move the transcript's dissent gauge (dissenting_count). Unbound attestations still render in full on thecr_page with their self-declared disclosure; they cannot impersonate verified disagreement (red-team finding, 2026-09-02). - Wall badges count distinct signers by
signer_email_hash; all anonymous signers collapse into one bucket.
3.7 Revocation, hiding, and the withdrawn state
| actor | act | mechanism | public effect |
|---|---|---|---|
| family | revoke a receipt it issued (tr_, cr_, ws_, wn_, wl_) | POST /api/revoke-family-receipt {receipt_id}; sets revoked_at; tr_ cascades to its cr_ rows | verify 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 receipt | same endpoint with an att_ id; sets hidden_by_issuer_at | the row stops rendering and counting; the verify payload discloses hidden_attestation_count |
| family | withdraw a witness link | POST /api/revoke-witness-request | the token resolves 410; a signing attempt with it is refused |
| witness | revoke their own attestation | planned (gap: no witness-initiated revocation path exists; a witness who disputes their own signature uses the dispute rail) | |
| anyone | dispute an attestation | section 3.8 | nothing public changes |
| nobody | revoke 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.
| level | name | how it is reached | the sentence a receiver reads |
|---|---|---|---|
| 0 | email | the default for every signature; nothing beyond an address | Email address only. No further identity check on file. |
| 1 | verified domain or license | automatically 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 when | Invited 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. |
| 2 | verified identity | only 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 key | Identity checked against an identity document by Eformogi staff. Only the record of the check is kept, never the document. |
| 3 | established history | derived 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 address | Established history: a count of prior co-signatures on file over time. |
Rules (MUST):
- Only levels 1 and 2 are settable (
ADMIN_SETTABLE_LEVELS), byPOST /api/admin-witness-identityunder the admin secret, with a required free-textsourcenaming where and when the check happened. Every successful call is logged toaudit_logaswitness_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. - 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.
- The level sits outside the
att_hash, like the V31 and Phase C observations, inwitness_identity_level,witness_identity_source, andwitness_identity_verified_at. A missing column reads as 0 and null rather than failing a verify. - 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_addressis not false). The signing endpoint is public, so an address the signer merely claims can never raise a level; the antibody istests/test-attestation-no-unverified-domain.js. - The staff
sourcestring is internal. The public payload carries exactly one of two fixed words,domain(earned from the invited address) ormanual(recorded by staff). The free text stays in the row and inaudit_log, and nothing else ever reaches/verify. - 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:
| field | values |
|---|---|
identity_level | 0 to 3, after the level-3 derivation |
identity_source | domain, manual, or null |
identity_verified_at | timestamp or null |
witness_history_count | 0 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.