Handoff v0: completion-record protocol

Version
r8.1a
Licence
CC-BY-4.0
SHA-256
bf232aef9b831f848135c072d1f44625d81ba6de828b85abbb0d7693c558021a
Formats
HTML · text · Conformance fixtures

This is the complete text of the Handoff v0 specification, revision r8.1a. Sections 0 to 15A and Appendix A are normative, except where a section is marked non-normative. Section 16 is not used in this edition. Tags such as (r7, gap 1) name the revision in which a rule was added or changed; the files the text calls INDEX.md and SHA256SUMS are served as INDEX.txt and SHA256SUMS.txt.

Contents
  1. 0. Conventions
    1. 0.1 Requirement words
    2. 0.2 Notation
  2. 1. Model and results (non-normative overview)
  3. 2. Primitives
    1. 2.1 Hash and tags
    2. 2.2 Ed25519
    3. 2.3 key_id
  4. 3. Canonical JSON profile
    1. 3.1 Byte-level checks (before parsing)
    2. 3.2 Parse rules (profile)
    3. 3.3 Serialisation (RFC 8785 under the profile)
    4. 3.4 Absent is not null
    5. 3.5 Contexts and limits (protocol limits)
    6. 3.6 Strings and times
  5. 4. Events and the signed bytes
    1. 4.1 The event object
    2. 4.2 auth
    3. 4.3 Bodies by type
    4. 4.4 record_id and binding
    5. 4.5 Event hash
    6. 4.6 prev_hash and the genesis
    7. 4.7 record_tag
    8. 4.8 Signatures on an event
    9. 4.9 What is inside the signed bytes
  6. 5. Record rules: who may write what
    1. 5.1 Record state
    2. 5.2 apply(state, event, S_a): the universal rules, in order
    3. 5.3 The G1 transition procedure
    4. 5.4 Bilateral events and CONFIRM
    5. 5.5 One open delivery
    6. 5.6 REVERSE
    7. 5.7 Erasure
    8. 5.8 Party digests
  7. 6. The log, tips and heads
    1. 6.1 Tree hashing (RFC 9162 §2.1)
    2. 6.2 The event log
    3. 6.3 Inclusion proof (RFC 9162 §2.1.3.2)
    4. 6.4 Consistency proof (RFC 9162 §2.1.4.2)
    5. 6.5 The tip tree
    6. 6.6 The head object
    7. 6.7 Head chain
    8. 6.8 Head signatures
  8. 7. The server key history
    1. 7.1 keys.json
    2. 7.2 Endorsement
    3. 7.3 Validation (all failures make the key history unusable: verifier ERROR, exit 3, with the code below)
    4. 7.4 Rotation (server procedure)
    5. 7.5 Compromise
    6. 7.6 Where the verifier gets the key history
  9. 8. The export envelope
    1. 8.1 Top level
    2. 8.2 Event entries
    3. 8.3 tip
    4. 8.4 plaintext
    5. 8.5 The head chain an export carries
  10. 9. Witness inputs
    1. 9.1 --known-heads FILE
    2. 9.2 --mirror DIR
    3. 9.3 Acceptance of witness heads
    4. 9.4 What the verifier can detect, and what is only missing evidence
  11. 10. The verification algorithm (normative)
  12. 11. Results, time and wording
    1. 11.1 Results
    2. 11.2 Time
    3. 11.3 Wording
  13. 12. Server rules (REFUSE)
  14. 13. The CLI
  15. 14. Protocol limits versus local caps
  16. 15. Fixtures (normative test data)
  17. 15A. Profile extension weio-ed25519-only/1 (not part of unqualified r8, r8.1 or r8.1a conformance)
    1. 15A.0 Status and scope
    2. 15A.1 The identifier
    3. 15A.2 Digest, salts and the equal-identifier check
    4. 15A.3 Key possession
    5. 15A.4 Authorisation: ed25519 only, email_token refused
    6. 15A.5 The agent key is fixed per record; no fallback
    7. 15A.6 The plaintext rule (one rule, bound across §5.7, §5.8, §8.4, §10 step 13 and §12)
    8. 15A.7 Server REFUSE codes
    9. 15A.8 What the profile does not claim
    10. 15A.9 Conditions: all seven are required
    11. 15A.10 Profile fixtures
  18. 17. Limits of what a valid record shows (non-normative)
  19. Appendix A. Codes
  20. Appendix B. References

Handoff v0 specification, revision r8.1a.

SPDX-License-Identifier: CC-BY-4.0

Scope: this document specifies the Handoff record format, its log, its verifier and their test data. It defines no product, price, purchase flow or workflow content.

#0. Conventions

#0.1 Requirement words

The key words MUST, MUST NOT, REQUIRED, SHALL, SHALL NOT, SHOULD, SHOULD NOT, RECOMMENDED, MAY and OPTIONAL are to be interpreted as described in RFC 2119 and RFC 8174 when, and only when, they appear in all capitals.

#0.2 Notation

  • ‖ is byte concatenation.
  • H(x) is SHA-256 of the byte string x (32 bytes).
  • DT(name) is the ASCII bytes of "weio-handoff/v0/" + name followed by one 0x00 byte. Example: DT("event") is the 22 bytes 77 65 69 6f 2d 68 61 6e 64 6f 66 66 2f 76 30 2f 65 76 65 6e 74 00.
  • u64be(n) is n as an 8-byte unsigned big-endian integer.
  • C(x) is the canonical bytes of the JSON value x under §3 with the EVENT limits (§3.5).
  • hex32 is a JSON string of exactly 32 lowercase hex characters ([0-9a-f]{32}, 16 bytes); hex64 is 64 lowercase hex characters (32 bytes); hex128 is 128 lowercase hex characters (64 bytes). Uppercase hex is invalid.
  • time is a JSON string matching YYYY-MM-DDTHH:MM:SSZ exactly (§3.6).
  • "int" is a JSON number token that the §3 parser accepts (an integer with |n| ≤ 2^53 − 1).
  • FORMAT is the ASCII string handoff/v0.
  • A code (for example CHAIN_PREV_HASH) is the reason code a verifier reports for a failed check. Codes are normative: a conforming verifier reports the code this document names for a check. Appendix A lists them all.

#1. Model and results (non-normative overview)

  • A record is one handoff between two parties: P (provider) and C (counterparty). It is a list of events e0..en, each signed by the log server's key K_w (S_w) and optionally by the writing party's own Ed25519 key (S_a).
  • Each event names its record (record_id), its position (seq) and the hash of its predecessor (prev_hash).
  • The server appends every event to one public log (an RFC 9162 Merkle tree) and publishes at most one signed head per UTC date. Each head also commits every record's latest event (its tip) in a sorted tip tree.
  • An export is one record plus the heads, proofs and tip proof needed to check it offline.
  • The verifier is given an export, the server key history, and optionally heads it saw before (known heads) and a copy of the public mirror of heads. It answers:

    • ACCEPT: the export is internally valid, and no authenticated conflict, freshness failure, completeness failure or anchoring failure was found against the supplied signed heads. Consistency is claimed only with heads that are linked to the export's own head chain (§9.3); other supplied heads are reported as unlinked.
    • REJECT: the export is internally invalid or incomplete: structure, canonical form, chain, signature, a missing or failing proof, or a transition.
    • DETECT: the export is internally valid, but (class conflict) two validly signed objects contradict each other, each verified, both carried as evidence; or (class freshness) a supplied signed head is newer than the export; or (class completeness) a validly signed head commits events of this record that the unsigned export does not carry; or (class anchoring) an anchoring or key-witness policy check fails. A missing or invalid proof is never DETECT evidence (§11.1).
    • ERROR: the verifier's own inputs are unusable (no key history, unreadable file, bad flags, an input above a declared local cap, or a mirror whose own chain cannot be proven). Not a verdict on the export.

#2. Primitives

#2.1 Hash and tags

  • The hash is SHA-256. All hashes in JSON are hex64.
  • Domain tags (exact names; any other name is not used by v0):
Tag Used for Section
DT("event")event hash§4.5
DT("genesis")prev_hash of seq 0§4.6
DT("tag")record_tag§4.7
DT("sig-event")S_w input§4.8
DT("sig-agent")S_a input§4.8
DT("leaf")log leaf input§6.2
DT("tip")tip leaf input§6.5
DT("head")head hash (prev_head link)§6.7
DT("genesis-head")prev_head of head 0§6.7
DT("sig-head")head signature input§6.8
DT("key")key_id§2.3
DT("sig-key")key-history endorsement input§7.2

#2.2 Ed25519

Signatures are Ed25519 (RFC 8032 §5.1), 64 bytes R ‖ S, over the exact message bytes this document defines (never a pre-hash variant: the message is passed whole to Ed25519). Public keys are 32 bytes. B is the RFC 8032 base point, of prime order L = 2^252 + 27742317777372353535851937790883648493; the identity is the point (0, 1).

Strict verification. Every v0 verifier uses exactly this procedure. It MUST perform these checks, in this order, and fail with the first code given:

  1. Public key A: length 32 (else ED_BAD_LENGTH). Decode A per RFC 8032 §5.1.3 with these refusals:

    • the 255-bit y (the encoding with bit 255 cleared) is ≥ p = 2^255 − 19 → ED_NONCANONICAL_A;
    • no x satisfies the curve equation for y → ED_NOT_ON_CURVE_A;
    • x = 0 and the sign bit (bit 255) is 1 → ED_NONCANONICAL_A.
  2. A is of small order, that is [8]A is the identity → ED_SMALL_ORDER_A. (This includes the identity.)
  3. A is not in the prime-order subgroup, that is [L]A is not the identity (A has a torsion component: a mixed-order point aB + T with T a nonzero point of order 2, 4 or 8) → ED_TORSION_A.
  4. Signature length 64 (else ED_BAD_LENGTH). Decode R = the first 32 bytes with the same refusals as step 1, codes ED_NONCANONICAL_R and ED_NOT_ON_CURVE_R.
  5. R is of small order → ED_SMALL_ORDER_R.
  6. R is not in the prime-order subgroup ([L]R is not the identity) → ED_TORSION_R.
  7. S = the last 32 bytes as a little-endian integer; S ≥ L → ED_S_NOT_REDUCED.
  8. The cofactorless equation: let k = SHA-512(R_bytes ‖ A_bytes ‖ M) as a little-endian integer, mod L, where R_bytes and A_bytes are the received 32-byte encodings and M the message. The signature is valid iff [S]B = R + [k]A as curve points. Otherwise → ED_BAD_SIGNATURE.

Why one equation suffices: after steps 3 and 6, A and R lie in the prime-order subgroup, so the difference X = [S]B − R − [k]A does too, and [8]X = identity implies X = identity (8 and L are coprime). The cofactored equation [8][S]B = [8]R + [8][k]A therefore holds exactly when the cofactorless one does, on every input that passes steps 1-7. Without steps 3 and 6 the two can differ (for example A = aB + T with an odd k). An implementation MAY evaluate step 8 with any RFC 8032 library call, cofactored or cofactorless, but only after its own steps 1-7 pass; it MUST NOT rely on a library for steps 1-7 (at least one common library accepts the small-order forgery A = identity, R = identity, S = 0 for every message, and common libraries accept mixed-order keys).

The same procedure applies to every key and signature in v0: S_w, S_a, head signatures, key-history endorsements, and party agent keys (for a key without a signature, steps 1-3 only).

In the result report, a strict-check failure appears as the code of the enclosing check (for example SIG_W_INVALID), with the Ed25519 code in the message. The ed25519 fixtures (§15) test this procedure directly, including mixed-order keys and R values for which both equations would hold or only the cofactored one would.

Informative: the reference build uses the distro package python3-cryptography 41.0.7 for step 8 only, after its own steps 1-7.

#2.3 key_id

key_id(pubkey) = the first 16 bytes of H(DT("key") ‖ pubkey), as hex32. It identifies K_w keys (§7) and party agent keys (auth.agent_key_id, sigs[].key_id for S_a).

#3. Canonical JSON profile

Every Handoff object (events, heads, key entries, exports, known-heads lines, mirror files) is JSON (RFC 8259) parsed by a parser that enforces this profile. Hashes and signatures are always over the canonical bytes (§3.3), never over the bytes as received.

#3.1 Byte-level checks (before parsing)

In this order:

  1. Input length > the size limit of the context (§3.5) → LIMIT_SIZE (checked before any parse).
  2. Input starts with the UTF-8 BOM EF BB BF → JSON_BOM.
  3. Input is not valid UTF-8 → JSON_BAD_UTF8.

#3.2 Parse rules (profile)

A depth-limited, single-pass parser. Whitespace is exactly space, tab, LF, CR. Exactly one value, then optional whitespace, then end of input (else JSON_SYNTAX). Errors and their codes:

Input Code
Any syntax error, bad escape, trailing data, unterminated stringJSON_SYNTAX
The literals NaN, Infinity, -InfinityJSON_NAN_INF
The literal null, anywhereJSON_NULL (§3.4)
An unescaped character U+0000-U+001F inside a stringJSON_CONTROL_CHAR
A duplicate member name in one object (compared after unescaping)JSON_DUP_KEY
A number with a fraction (.) or exponent (e/E)NUM_NOT_INTEGER
The number -0NUM_NEG_ZERO
An integer withn> 2^53 − 1 (9007199254740991)NUM_RANGE
A \uD800-\uDBFF escape not followed by a \uDC00-\uDFFF escape, or a lone \uDC00-\uDFFF escapeSTR_LONE_SURROGATE
A member name not matching ^[a-z][a-z0-9_]{0,31}$KEY_FORM
A string value longer than 4,096 UTF-8 bytes (after unescaping)LIMIT_STRING
A string value containing a banned code point (§3.6)STR_BANNED_CHAR
A string value not in Unicode NFCSTR_NOT_NFC
An object or array that would be at depth > the context's max depthLIMIT_DEPTH
An object with more members than the context allowsLIMIT_MEMBERS
An array with more items than the context allowsLIMIT_ITEMS

Precise rules:

  • Depth. The outermost object or array is at depth 1; a container directly inside a container at depth d is at depth d + 1. Scalars do not add depth. The parser MUST refuse on entering a container deeper than the limit, before parsing its contents (V52: a 10,000-deep input is refused in bounded time and memory).
  • Member and item counts. For each member, after its name is read (its closing quote) and before the : and its value are parsed, check in this order: KEY_FORM (the name), JSON_DUP_KEY, then LIMIT_MEMBERS (this member would be the (max+1)-th). For an array, LIMIT_ITEMS is raised before parsing the (max+1)-th item. Lexical errors inside a name (JSON_SYNTAX, JSON_CONTROL_CHAR, STR_LONE_SURROGATE) come before KEY_FORM.
  • Strings. Escapes are those of RFC 8259 (\" \\ \/ \b \f \n \r \t \uXXXX, hex digits in either case). A valid surrogate pair escape decodes to one code point. Member names are checked against KEY_FORM only (they are ASCII by that rule). String values are checked in this order: LIMIT_STRING, STR_BANNED_CHAR, STR_NOT_NFC.
  • Numbers. Match the longest prefix, at the current position, of the RFC 8259 number grammar (-?(0|[1-9][0-9]*)(\.[0-9]+)?([eE][+-]?[0-9]+)?); no match → JSON_SYNTAX (or JSON_NAN_INF for -Infinity). Then, on the matched token: a fraction or exponent part → NUM_NOT_INTEGER; the token -0 → NUM_NEG_ZERO; more than 16 digits or |n| > 2^53 − 1 → NUM_RANGE. Parsing then continues after the token, so leftover characters give JSON_SYNTAX (examples: 01 → JSON_SYNTAX; -01 → NUM_NEG_ZERO; 1.5e → NUM_NOT_INTEGER).
  • Surrogate escapes. A \uD800-\uDBFF escape must be followed immediately by the two characters \u (else STR_LONE_SURROGATE), then four hex digits (else JSON_SYNTAX) in DC00-DFFF (else STR_LONE_SURROGATE).
  • Literals. null, NaN, Infinity are recognised by their exact prefix at the current position (so nullx → JSON_NULL, NaNx → JSON_NAN_INF); a partial literal such as nul, or truex, → JSON_SYNTAX. A trailing comma ([1,]) → JSON_SYNTAX, unless the array already holds the maximum number of items (then LIMIT_ITEMS, raised before the next item is parsed). Empty or whitespace-only input → JSON_SYNTAX.
  • Booleans true and false are allowed by the parser; no v0 schema uses them, so a schema check refuses them.

#3.3 Serialisation (RFC 8785 under the profile)

The canonical bytes of a parsed value are its RFC 8785 (JCS) serialisation. Within the profile this is:

  • No whitespace anywhere.
  • Objects: members sorted by member name as sequences of UTF-16 code units (RFC 8785 §3.2.3). Member names are ASCII under the profile, so this equals byte order; implementations SHOULD still sort by UTF-16 code units so that the general case (V66) is right.
  • Strings: " + the characters, where " → \", \ → \\, U+0008 → \b, U+000C → \f, U+000A → \n, U+000D → \r, U+0009 → \t, any other U+0000-U+001F → \u00XX with lowercase hex, and every other code point (including /, non-ASCII and non-BMP code points) as its literal UTF-8 bytes, unescaped. (Under the profile the control characters are banned in values, so only \" and \\ occur in practice.)
  • Integers: shortest decimal form, - for negatives, no +, no leading zeros; 0 for zero.
  • true / false as such.
  • Output encoding UTF-8.

A serialiser MUST also refuse, with the same codes, any value that violates §3.2 (for example an in-memory string that is not NFC), and MUST apply the context's size limit to the canonical bytes (LIMIT_SIZE). A server MUST REFUSE to sign or append any object whose canonicalisation fails.

#3.4 Absent is not null

  • null never appears in any v0 object: not in signed objects, not in exports, not in key histories, not in known-heads lines or mirror files. A parser refuses it with JSON_NULL.
  • An OPTIONAL member that has no value is absent (the member is not written). The schemas below say "optional" for such members. A verifier MUST NOT treat a present member as absent, or the reverse.
  • Consequence for signatures: an optional member that is absent contributes no bytes to C(x).

#3.5 Contexts and limits (protocol limits)

These limits are part of the format handoff/v0. Changing any of them requires a new format string.

Context Max bytes Max depth Max members per object Max items per array Max string bytes
EVENT: C(event) and C(head) and C(key entry)16,384 (canonical)864644,096
EXPORT: a whole export file16,777,216 (as received)11641,0004,096
SIDE: keys.json, one known-heads line, one mirror head file1,048,576 (as received)8641,0004,096

Further protocol limits:

Item Limit Code
Events per record (per export)≤ 1,000LIMIT_ITEMS at parse (the events array), else LIMIT_EVENTS
Signatures per eventexactly one S_w plus at most one S_aLIMIT_SIGS / SIG_W_MISSING
Signatures per head1 to 4, distinct key_idHEAD_FORM
Proof path length (inclusion, consistency, tip)≤ 64 nodes, checked before any hashingPROOF_TOO_LONG
Party name1 to 80 Unicode code pointsFIELD_FORM
DELIVER evidence items1 to 16FIELD_FORM
Integersn≤ 2^53 − 1; money, hashes and ids are stringsNUM_RANGE

Where the byte limit applies. §3.1 applies a context's max bytes to the input bytes before parsing (an export file, a keys.json, one known-heads line, one mirror file, or a canon fixture's input under EVENT). §3.3 applies it to the canonical bytes whenever an object is canonicalised: C(event), C(head) and C(key entry) under EVENT. Depth, member, item and string limits apply in both places; for C(x), depth is counted with x itself at depth 1.

Local caps are different (one rule, with §14). The limits above are protocol limits. A verifier MAY in addition declare stricter local caps (for example a smaller maximum export file for a memory-constrained build). Then:

  1. the input's byte length is known before it is read, so it is checked first: a length above the context's byte limit (§3.5 table) is REJECTed with LIMIT_SIZE, whether or not a local cap is also exceeded;
  2. an input whose byte length is within that byte limit but above a declared local cap is ERROR (exit 3) with code LOCAL_CAP, never REJECT: the verifier gives no verdict on it. A capped verifier does not read the content, so LOCAL_CAP does NOT claim that the content is within the other protocol limits (depth, members, items, string length, form). An input that also violates such a limit is still LOCAL_CAP: the verifier MUST NOT read past its cap to find the violation, and MUST NOT report a REJECT for it (r8, F3; XF3-1, XF3-2);
  3. a verifier with no local cap below a protocol limit is a full verifier; one with such a cap is a capped verifier and MUST state its caps in its documentation and in the LOCAL_CAP message. Both conform; only a full verifier gives a verdict on every protocol-valid input.

#3.6 Strings and times

  • Banned code points in any string value: U+0000-U+001F, U+007F-U+009F (all Cc), U+200B, U+200C, U+200D, U+2060, U+202A-U+202E, U+2066-U+2069, U+FEFF.
  • NFC is required: a string value must equal its Unicode NFC normalisation. Clients normalise before submission; the server REFUSEs non-NFC input; the verifier REJECTs it (V56).
  • Times (time): exactly YYYY-MM-DDTHH:MM:SSZ (regex ^[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}Z$), a valid proleptic Gregorian date and time in UTC, seconds 00-59 (no leap second). Anything else → TIME_FORM. Years 0001-9999 (year 0000 → TIME_FORM). Times compare as instants (equivalently, as strings, given the fixed form).
  • Dates (head date): YYYY-MM-DD, a valid date; checked as the time date + "T00:00:00Z" (→ TIME_FORM).

#4. Events and the signed bytes

#4.1 The event object

An event is a JSON object with exactly these members (all REQUIRED):

Member JSON type Rule
formatstringexactly "handoff/v0" (else FORMAT)
record_idhex32§4.4
seqint ≥ 00-based position in the record
prev_hashhex64§4.6
typestringone of COMMIT CONFIRM DELIVER ACCEPT REJECT AMEND REVERSE DISPUTE WITHDRAW ERASE
actorstringP, C or W (W = the log server; ERASE only)
attimeself-declared time of the event (E2: proves nothing by itself)
bodyobject§4.3, by type
authobject§4.2
server_key_idhex32key_id of the K_w that signs this event (§7)

Structure check order and codes (check_event):

  1. Not an object → FIELD_FORM.
  2. C(event) under EVENT limits fails → that §3 code (LIMIT_SIZE, LIMIT_DEPTH, LIMIT_MEMBERS, LIMIT_ITEMS, ...).
  3. A required member missing → FIELD_MISSING; then any other member → FIELD_UNKNOWN.
  4. format → FORMAT; record_id, seq, prev_hash forms → FIELD_FORM; type not in the list → FIELD_FORM; actor not P/C/W → FIELD_FORM; at → TIME_FORM; server_key_id → FIELD_FORM.
  5. auth (§4.2), then body (§4.3).

The general object rule used throughout ("_keys"): a value that must be an object and is not → FIELD_FORM; a missing required member → FIELD_MISSING (checked first); a member that is neither required nor optional → FIELD_UNKNOWN. Unknown members are never ignored, at any level of any v0 object.

#4.2 auth

auth is an object: method REQUIRED; token_nonce, token_expires, agent_key_id OPTIONAL. The _keys rule applies. Then:

  • method not one of email_token, ed25519, server → AUTH_METHOD. (A share token is never an authority: V26.)
  • email_token: token_nonce MUST be present and hex32 (absent or malformed → FIELD_FORM); token_expires MUST be a time (absent or malformed → TIME_FORM); agent_key_id MUST be absent (→ AUTH_FORM).
  • ed25519: token_nonce and token_expires MUST be absent (→ AUTH_FORM); agent_key_id MUST be present and hex32 (→ FIELD_FORM).
  • server: all three MUST be absent (→ AUTH_FORM).

auth.method is the r5 confirm_method (O2). It sits inside the signed bytes, so flipping it breaks S_w (V18).

#4.3 Bodies by type

"pd" means: when auth.method = email_token, the body additionally REQUIRES party_digest (hex64): the writing role's digest committed in COMMIT (§5.8, R4). When the method is not email_token, party_digest is not allowed (→ FIELD_UNKNOWN). ERASE bodies never carry pd.

Type Required Optional Value rules
COMMITterms_digest, parties (+pd)confirm_by, oasf_refterms_digest hex64; parties = object with exactly p and c, each a party; confirm_by time; oasf_ref string of 1-256 code points (a reference to an AGNTCY/OASF record; informative)
CONFIRMref, terms_digest (+pd)—ref hex64, terms_digest hex64
DELIVERterms_digest, evidence (+pd)note_digestevidence = array of 1-16 items, each exactly {kind, value} (§4.3.1)
ACCEPT, REJECT, DISPUTEref (+pd)note_digestref hex64
AMENDterms_digest (+pd)note_digestterms_digest hex64 (every note_digest in this table is hex64)
REVERSEref, terms_digest (+pd)note_digestterms_digest = the current confirmed terms (fill; §5.6)
WITHDRAW(+pd)note_digest
ERASErole, fields, reason—role P or C; fields = array of 1-2 distinct strings from {email, note}; reason request or retention

parties, each party object and each evidence item follow the _keys rule (FIELD_FORM if not an object, FIELD_MISSING, FIELD_UNKNOWN) before their values are checked. Every time-typed member (confirm_by here, at, token_expires) that is required but absent (and not already caught as FIELD_MISSING by a _keys check, as at is), not a string, or malformed → TIME_FORM.

Party object: digest REQUIRED (hex64); agent_key OPTIONAL (hex64, a 32-byte Ed25519 public key K_a); name OPTIONAL (string, 1-80 code points, self-described, never verified). All value-form failures in this table → FIELD_FORM. Check order within a body: the _keys rule, then the members in the order listed in the table (for COMMIT: terms_digest, parties keys, party p, party c, confirm_by, oasf_ref), then party_digest last.

note_digest is HMAC-SHA256(k_rec_role, note) hex (§5.8). A plaintext note is never in a body: a member named note is FIELD_UNKNOWN (V57).

#4.3.1 Evidence items

kind value
file_sha256hex64
note_digesthex64
linkstring starting with https://, at most 2,048 code points
payment_refstring of 1-256 code points (an opaque reference; Handoff moves no money)

Any other kind or a malformed value → FIELD_FORM.

#4.4 record_id and binding

  • record_id is 16 bytes from a CSPRNG on the server at COMMIT time, as hex32. It is NOT a capability: knowing it grants nothing, and it appears in proofs and logs.
  • Every event carries record_id and seq inside the signed bytes. All events of a record carry the same record_id as its seq-0 event (else RECORD_ID_MISMATCH). Hence every signed action names its record, and an event signed for record R2 cannot verify inside R1 (V9, V22).
  • The share token (D1) is a separate 32-byte random value. It never appears in any event (V26); the server stores only H(DT("share") ‖ token) (server rule, §12).

#4.5 Event hash

event_hash(e) = H(DT("event") ‖ C(e)) (32 bytes). It is used by prev_hash, by ref members, by log leaves (§6.2) and by tips (§6.5). Signatures are not part of e.

#4.6 prev_hash and the genesis

  • seq 0: prev_hash = hex(H(DT("genesis") ‖ "handoff/v0" ‖ 0x00 ‖ record_id_bytes)), where record_id_bytes are the 16 bytes the record_id hex encodes (else CHAIN_GENESIS).
  • seq k > 0: prev_hash = hex(event_hash(e_{k-1})) (else CHAIN_PREV_HASH).

#4.7 record_tag

record_tag = H(DT("tag") ‖ record_id_bytes) (32 bytes). Heads commit to records by tag (§6), so the public log does not list record ids. Tags still reveal how many records exist and how often each changes.

#4.8 Signatures on an event

  • S_w = Ed25519(K_w, DT("sig-event") ‖ H(C(event))), where K_w is the key named by server_key_id.
  • S_a = Ed25519(K_a, DT("sig-agent") ‖ H(C(event))), where K_a is the writing role's agent_key from COMMIT.
  • Each message is the 26-byte tag (DT("sig-event") or DT("sig-agent")) followed by the 32-byte hash: 58 bytes.

Signatures travel next to the event in the export entry (§8.2), as sigs: an array of 1-2 objects, each exactly {role, key_id, sig}:

  • role "w" (S_w) or "a" (S_a); key_id hex32; sig hex128 (R ‖ S).
  • Check order (check_sigs): not an array → SIGS_FORM; length not 1-2 → LIMIT_SIGS; per item: _keys rule (FIELD_MISSING/FIELD_UNKNOWN/FIELD_FORM), key_id → FIELD_FORM, sig → FIELD_FORM, role not w/a → SIGS_FORM, a second w or a second a → LIMIT_SIGS; finally no w → SIG_W_MISSING.

#4.9 What is inside the signed bytes

Item Where Effect
confirm_methodauth.methodflip breaks S_w (V18)
email token nonceauth.token_noncereuse within a record → REJECT (V20)
token expiryauth.token_expiresat > expiry → REJECT (V23)
agent key bindingauth.agent_key_id = key_id(committed K_a)wrong key → REJECT (V16)
party bindingbody.party_digest = the role's COMMIT digestother address → REJECT (V21)
record bindingrecord_id (and the genesis, §4.6)other record → REJECT (V9, V22)
positionseq, prev_hashreorder, drop, duplicate → REJECT (V5-V7)
server keyserver_key_idmust equal S_w's key_id (SIG_W_KEY_ID)
actor and roleactorG1 table (§5)

Not signed: the signatures; inclusion, consistency and tip proofs (checked against signed heads instead); the plaintext table (§5.8); the share token; any display rendering.

#5. Record rules: who may write what

The verifier MUST enforce every rule in this section. The server MUST apply the same procedure before appending and REFUSE (not append) on any failure. This section is the single rule set for both.

#5.1 Record state

A record's state is: state ∈ {none, PROPOSED, AGREED, DELIVERED, ACCEPTED, REJECTED, REVERSED, WITHDRAWN}; pending (none, or a proposal {kind ∈ COMMIT/AMEND/REVERSE, hash, actor, terms_digest, seq}); terms (the current confirmed terms_digest); parties (from COMMIT); confirm_by; nonces (email-token nonces used); hashes (event hashes so far); latest_deliver (hash); accept_hash; delivered (count of DELIVER); accepted (whether any ACCEPT happened); disputed (count); erased (role → seq of its first ERASE); n (events applied); last_hash; last_at. TERMINAL = {REVERSED, WITHDRAWN}.

#5.2 apply(state, event, S_a): the universal rules, in order

Given an event that passed §4.1-§4.3 and the S_a entry from its sigs (or none):

  1. R1 seq ≠ n → R1_SEQ. n = 0 and type ≠ COMMIT → R1_FIRST_NOT_COMMIT.
  2. Link (G2) n = 0: prev_hash ≠ genesis(record_id) → CHAIN_GENESIS. n > 0: record_id ≠ the seq-0 record_id → RECORD_ID_MISMATCH; else prev_hash ≠ hex(last_hash) → CHAIN_PREV_HASH.
  3. R2 (type = ERASE) ≠ (actor = W) → R2_ACTOR.
  4. R3 actor ∈ erased → R3_ERASED_ACTOR (V24).
  5. R5 n > 0 and at < last_at → R5_TIME_ORDER (V42).
  6. COMMIT keys (seq 0 only): parties = {P: body.parties.p, C: body.parties.c}; for P then C, an agent_key present must pass §2.2 steps 1-3 (else COMMIT_BAD_AGENT_KEY, V15; a mixed-order key fails step 3, XN1-1). For n > 0, parties = the stored parties.
  7. R4 (auth):

    • actor W: method ≠ server → R2_ACTOR; an S_a present → R4_SIG_A_UNEXPECTED. Then go to step 8.
    • actor P or C with method server → R2_ACTOR.
    • method ed25519: the role committed no agent_key → R4_NO_AGENT_KEY; auth.agent_key_id ≠ key_id(agent_key) → R4_AGENT_KEY_MISMATCH; no S_a → R4_SIG_A_MISSING.
    • an S_a present (any method): the role committed no agent_key → R4_SIG_A_INVALID; S_a.key_id ≠ key_id(agent_key) → R4_SIG_A_INVALID; S_a fails §2.2 over DT("sig-agent") ‖ H(C(event)) → R4_SIG_A_INVALID. An optional S_a is never ignored (V17).
    • method email_token: token_nonce ∈ nonces → R4_NONCE_REUSED; body.party_digest ≠ parties[actor].digest → R4_PARTY_DIGEST; at > token_expires → R4_TOKEN_EXPIRED.
  8. G1 transition (§5.3); R6: any (state, event) pair not allowed there fails.
  9. On success: if n = 0 store record_id; if method email_token add the nonce; n += 1; last_hash = event_hash; last_at = at; append the hash to hashes.

#5.3 The G1 transition procedure

Let s = the state before, t = type, a = actor, b = body, h = event_hash(event). Evaluate in this order:

  1. COMMIT: seq ≠ 0 → R6_ILLEGAL. parties.P.digest = parties.C.digest → COMMIT_SELF_DEALING (V50). Else: store parties and confirm_by; s := PROPOSED; pending := {COMMIT, h, a, b.terms_digest, seq}.
  2. ERASE: erased[b.role] := seq unless already set (a repeat ERASE of a role is legal and keeps the first seq). State unchanged. (ERASE is legal in every state, including terminal ones.)
  3. s ∈ TERMINAL and t ≠ DISPUTE → G1_TERMINAL (V46).
  4. CONFIRM:

    • pending none → CONFIRM_NOTHING_PENDING;
    • pending.kind = AMEND and s = DELIVERED → CONFIRM_WHILE_DELIVERED (V71);
    • a = pending.actor → CONFIRM_SELF (V72);
    • b.ref ≠ hex(pending.hash) → CONFIRM_REF (V75: a superseded AMEND is no longer pending);
    • b.terms_digest ≠ pending.terms_digest → CONFIRM_TERMS (V73);
    • pending.kind COMMIT: confirm_by set and at > confirm_by → CONFIRM_LATE; else s := AGREED, terms := pending.terms_digest, pending := none;
    • pending.kind AMEND: s ∉ {AGREED, REJECTED} → R6_ILLEGAL; else s := AGREED, terms := pending.terms_digest, pending := none;
    • pending.kind REVERSE: s ≠ ACCEPTED → R6_ILLEGAL; else s := REVERSED, pending := none.
  5. WITHDRAW (P or C): s = ACCEPTED or accepted → G1_ACCEPTED_CLOSED; s ∉ {PROPOSED, AGREED, DELIVERED, REJECTED} → R6_ILLEGAL; else s := WITHDRAWN, pending := none. (Declining a COMMIT is WITHDRAW by the other role.)
  6. DELIVER: a ≠ P → G1_WRONG_ROLE (V47); s = ACCEPTED → G1_ACCEPTED_CLOSED; s = PROPOSED → G1_NOT_AGREED (V49); s ∉ {AGREED, REJECTED} → R6_ILLEGAL (one open delivery at a time); b.terms_digest ≠ terms → DELIVER_TERMS; pending.kind = AMEND → DELIVER_PENDING_AMEND; else s := DELIVERED, latest_deliver := h, delivered += 1.
  7. ACCEPT / REJECT: a ≠ C → G1_WRONG_ROLE (V47); s ≠ DELIVERED → R6_ILLEGAL (V8: a delivery is decided once); b.ref ≠ hex(latest_deliver) → REF_NOT_LATEST_DELIVER; ACCEPT: s := ACCEPTED, accepted := true, accept_hash := h; REJECT: s := REJECTED.
  8. AMEND (P or C): s = ACCEPTED → G1_ACCEPTED_CLOSED; s ∉ {AGREED, DELIVERED, REJECTED} → R6_ILLEGAL; b.terms_digest = terms → AMEND_SAME_TERMS; else pending := {AMEND, h, a, b.terms_digest, seq} (replacing any unconfirmed proposal). State unchanged.
  9. REVERSE (P or C): not accepted → REVERSE_NO_ACCEPT (V45); s ≠ ACCEPTED → R6_ILLEGAL; b.ref ≠ hex(accept_hash) → REVERSE_REF; b.terms_digest ≠ terms → REVERSE_TERMS; else pending := {REVERSE, h, a, b.terms_digest, seq}. State unchanged.
  10. DISPUTE (P or C): s = WITHDRAWN and delivered = 0 → DISPUTE_NO_DELIVER (V74); s ∉ {DELIVERED, REJECTED, ACCEPTED, REVERSED, WITHDRAWN} → R6_ILLEGAL; b.ref not the hash of an earlier event of this record → DISPUTE_REF; else disputed += 1. State unchanged. DISPUTE records and resolves nothing.
  11. Anything else → R6_ILLEGAL.

Summary table (non-normative restatement of 1-11):

State before Event (actor) Result
noneCOMMIT (P or C)PROPOSED, pending COMMIT
PROPOSEDCONFIRM (other role)AGREED
PROPOSED, AGREED, DELIVERED, REJECTED (never after an ACCEPT)WITHDRAW (P or C)WITHDRAWN
AGREED, REJECTEDDELIVER (P), current terms, no pending AMENDDELIVERED
DELIVEREDACCEPT / REJECT (C), ref = latest DELIVERACCEPTED / REJECTED
AGREED, DELIVERED, REJECTEDAMEND (P or C), new termssame, pending AMEND
AGREED, REJECTEDCONFIRM of pending AMEND (other role)AGREED, new terms
DELIVEREDCONFIRM of pending AMENDREJECT (decide the delivery first)
ACCEPTEDREVERSE (P or C), ref = the ACCEPTACCEPTED, pending REVERSE
ACCEPTEDCONFIRM of pending REVERSE (other role)REVERSED
DELIVERED, REJECTED, ACCEPTED, REVERSED; WITHDRAWN only if ≥ 1 DELIVERDISPUTE (P or C)same, disputed + 1
anyERASE (W)same, erased ∪ {role}
AGREED (even after an earlier delivery)DISPUTEREJECT (the adopted table omits AGREED)
REVERSED, WITHDRAWNanything but DISPUTE or ERASEREJECT (terminal)

#5.4 Bilateral events and CONFIRM

  • COMMIT, AMEND and REVERSE are proposals. They take effect only when the role that did not write them writes a CONFIRM whose ref = hex(event_hash(proposal)) and whose terms_digest equals the proposal's.
  • The proposer's own agreement is implicit in its proposal.
  • COMMIT.body.confirm_by (optional) bounds the confirmation: the server REFUSEs a CONFIRM of the COMMIT after it; the verifier REJECTs one whose at > confirm_by (CONFIRM_LATE), and DETECTs (CONFIRM_ANCHOR_LATE, class anchoring) if the first head including that CONFIRM declares a published_at more than 86,400 seconds after confirm_by (§10 step 9). Both values are declared, not observed (§11.2): the check compares declarations.
  • There is no DECLINE type: the other role writes WITHDRAW.

#5.5 One open delivery

A second DELIVER while one is undecided is REJECTed (R6_ILLEGAL). ACCEPT and REJECT name the latest DELIVER.

#5.6 REVERSE

REVERSE is bilateral. Its body carries ref = the ACCEPT's hash and terms_digest = the current terms, so the CONFIRM terms check is uniform (fill).

#5.7 Erasure

  • ERASE is written by the server: actor W, auth.method server, S_w only (no S_a, no pd).
  • Effect on the record: erased ∪ {role}; that role may not write again (R3). State and pending are otherwise unchanged.
  • What the protocol requires of the server (§12): delete that role's address, notes and k_rec_role from the deletable table (§5.8) and from its backups as its published retention text says. The duty is the same whatever fields lists: fields describes what the erasure request named (email, note); it does not select what is kept.
  • What does not change: the chain is unchanged and still verifies (V39). The signed record keeps the role's COMMIT digest, any self-described party name and agent_key, every party_digest and note_digest, and the ERASE event itself. Exports handed out before the ERASE are not recalled (§8.4): whoever holds one holds that role's address and k_rec_role and can still recompute and link the digest (V37).
  • What a verifier can say: an ERASE is a signed server statement that an erasure was recorded. A verifier cannot observe the server's storage or backups, so it never reports that deletion happened or that a digest can no longer be linked. It reports a status (never an error) with code PARTY_ERASED and this text (X the role, k the seq of the role's first ERASE): party X: the server recorded an erasure at seq k. The protocol requires the server to delete this role's address, notes and k_rec from its table and its backups (SPEC 5.7, 12). This verifier cannot observe that deletion. Copies exported before seq k, and the signed record itself (digests, any party name), are not recalled.
  • An export that carries plaintext for an erased role is REJECTed (PLAINTEXT_AFTER_ERASE, §10 step 13).
  • Plaintext claimed but absent (the role is in claimed and no data is given, no ERASE of the role) is REJECTed (PLAINTEXT_MISSING, §10 step 13). Otherwise a missing plaintext without an ERASE is a status (PLAINTEXT_UNAVAILABLE). (r8, F2: r7 said DETECT here.)
  • An ERASE event publicly reveals that an erasure was recorded (by tag and seq, not by whom). Parties SHOULD NOT put personal data in a party name: a name is in signed bytes and is never removed.

#5.8 Party digests

  • k_rec is random per record per role (k_rec_P, k_rec_C; 32 bytes each), held only in the deletable table.
  • digest = hex(HMAC-SHA256(k_rec_role, UTF-8(normalize(email)))), normalize = Unicode NFC, then remove leading and trailing white space (code points with the Unicode White_Space property), then Unicode default lower-casing (fill: the whole address is lower-cased).
  • note_digest = hex(HMAC-SHA256(k_rec_role, UTF-8(note))) (no normalisation).
  • No domain tag is added inside the HMAC (as adopted).
  • The server REFUSEs a COMMIT whose two normalized addresses are equal (V50). The verifier cannot see addresses, so it REJECTs only equal digests (COMMIT_SELF_DEALING).

#6. The log, tips and heads

#6.1 Tree hashing (RFC 9162 §2.1)

  • leaf_hash(x) = H(0x00 ‖ x); node_hash(l, r) = H(0x01 ‖ l ‖ r).
  • MTH([]) = H(empty string) = e3b0c442...b855; MTH([d0]) = d0 (a leaf hash); for n > 1, k = the largest power of two < n, MTH(D) = node_hash(MTH(D[0:k]), MTH(D[k:n])). (The list holds leaf hashes.)

#6.2 The event log

One append-only log holds every event of every record, in append order. The leaf for event e of record R: leaf_input(e) = DT("leaf") ‖ record_tag(R) ‖ u64be(e.seq) ‖ event_hash(e). A verifier always takes R from the export's top-level record_id (the tag of §10 step 2), never from the event's own record_id; an event spliced in from another record therefore fails its inclusion proof as well as the chain (21 + 32 + 8 + 32 = 93 bytes), and the log stores leaf_hash(leaf_input(e)). A record's events appear in the log in seq order (log_index increasing with seq).

#6.3 Inclusion proof (RFC 9162 §2.1.3.2)

Encoded in JSON as path: an array of hex64 nodes, checked before any hashing in this order: not an array → PROOF_MALFORMED; more than 64 nodes → PROOF_TOO_LONG; any node not hex64 → PROOF_MALFORMED. To verify leaf index i in a tree of size n against root r with leaf hash lh:

if not 0 <= i < n: fail PROOF_INDEX
fn = i; sn = n - 1; x = lh
for p in path:
    if sn == 0: fail PROOF_INCLUSION
    if fn is odd or fn == sn:
        x = node_hash(p, x)
        if fn is even:
            while not (fn is odd or fn == 0): fn >>= 1; sn >>= 1
    else:
        x = node_hash(x, p)
    fn >>= 1; sn >>= 1
if sn != 0 or x != r: fail PROOF_INCLUSION

#6.4 Consistency proof (RFC 9162 §2.1.4.2)

consistency: an array of hex64 (same limits). From size m with root r1 to size n with root r2:

if m > n: fail PROOF_CONSISTENCY
if m == n: ok iff path is empty and r1 == r2 (else PROOF_CONSISTENCY)
if m == 0: ok iff path is empty and r1 == MTH([]) (else PROOF_CONSISTENCY)
if path is empty: fail PROOF_CONSISTENCY
if m is a power of two: path = [r1] + path
fn = m - 1; sn = n - 1
while fn is odd: fn >>= 1; sn >>= 1
fr = sr = path[0]
for c in path[1:]:
    if sn == 0: fail PROOF_CONSISTENCY
    if fn is odd or fn == sn:
        fr = node_hash(c, fr); sr = node_hash(c, sr)
        if fn is even:
            while not (fn is odd or fn == 0): fn >>= 1; sn >>= 1
    else:
        sr = node_hash(sr, c)
    fn >>= 1; sn >>= 1
ok iff fr == r1 and sr == r2 and sn == 0 (else PROOF_CONSISTENCY)

#6.5 The tip tree

At each head, the server builds a second Merkle tree over every record's tip at that head's tree_size:

  • A record's tip at size N is its last event with log_index < N.
  • tip_leaf_input(R) = DT("tip") ‖ record_tag(R) ‖ u64be(last_seq) ‖ event_hash(last_event) ‖ u64be(log_index_of_last) (20 + 32 + 8 + 32 + 8 = 100 bytes); leaf hash = leaf_hash(tip_leaf_input).
  • Leaves are sorted by record_tag, ascending, as unsigned 32-byte strings (byte-wise). Two tips for one tag are not allowed. tip_root = MTH(sorted tip leaf hashes); tip_count = number of tips.

JSON form of a tip leaf (leaf): exactly {record_tag: hex64, last_seq: int ≥ 0, event_hash: hex64, log_index: int ≥ 0} (else TIP_FORM).

Membership proof member: exactly {index: int ≥ 0, leaf, path}. Checked in this order: not an object with exactly these members → TIP_FORM; leaf form → TIP_FORM; index not an int ≥ 0 → TIP_FORM (leaf before index here; a non-membership neighbour checks index before leaf, below: both orders are as written) (r7, gap 1: a negative index is a form error, before any proof check); path encoding (§6.3) → PROOF_TOO_LONG/PROOF_MALFORMED; leaf.record_tag ≠ this record's tag → TIP_WRONG_RECORD; the §6.3 inclusion check of leaf_hash(tip_leaf_input(leaf)) at index in a tree of size tip_count against tip_root → PROOF_INDEX/PROOF_INCLUSION.

Non-membership proof absent: an object with OPTIONAL left and right, each {index: int ≥ 0, leaf, path}. Form first, whatever tip_count is: absent not an object with members ⊆ {left, right} → TIP_FORM; then left, then right (each if present): not exactly {index, leaf, path} → TIP_FORM; index not an int ≥ 0 → TIP_FORM; leaf form → TIP_FORM; path encoding (§6.3) → PROOF_TOO_LONG/PROOF_MALFORMED. Then it is valid iff:

  • tip_count = 0: neither neighbour, and tip_root = MTH([]) (else TIP_ABSENCE);
  • otherwise at least one neighbour (else TIP_ABSENCE); then left (if present): its §6.3 inclusion proof against tip_root and tip_count (PROOF_*), then left.record_tag < tag (else TIP_ABSENCE); then right (if present): its inclusion proof, then tag < right.record_tag (else TIP_ABSENCE); finally, with both, right.index = left.index + 1; with only right, right.index = 0; with only left, left.index = tip_count − 1 (else TIP_ABSENCE).

What a tip proof shows, and what it assumes (r7, N4). A valid membership proof shows that the signed head commits this tip in its tip map. It does not show that the server built the tip map honestly from every leaf of the log the same head commits: v0 has no proof binding the two trees together. The completeness guarantee is therefore relative:

  • assumed: the signed tip map is the true last-event map at tree_size. Under that assumption, an export that omits a suffix of anchored events is DETECTed (TIP_AHEAD_TRUNCATED, class completeness: the export is incomplete; no two signed objects contradict, §11.1) by anyone holding the export and its anchor head;
  • a verifier holding events of the record can check the map against them: an anchored event later than the committed tip is TIP_BEHIND (REJECT; both trees are signed by the same head, so the export contradicts itself), and a tip that disagrees with an event the verifier holds is TIP_MISMATCH or, across heads, TIP_FORK/TIP_BACKWARDS;
  • a verifier that holds only some events cannot conclude anything about events it does not hold: if a dishonest server keeps an old tip in the map while logging a later event, a recipient of the older export alone sees a valid record (XN4-1). Whoever holds the later event, or audits the whole log against the tip map, can see the contradiction (XN4-2). Equal-size heads must also commit the same tip map (§10 step 11(g)).

#6.6 The head object

A head is a JSON object (EVENT limits for C(head)): REQUIRED format ("handoff/v0"), n (int ≥ 0, sequential from 0), date (YYYY-MM-DD, UTC), tree_size (int ≥ 0), log_root (hex64 = MTH of the first tree_size log leaves), tip_root (hex64), tip_count (int ≥ 0), prev_head (hex64), published_at (time: the publication time the publisher declares; a signature proves that the publisher declared it, not that the head existed or was public then, §11.2), server_key_id (hex32, the primary key signing it); OPTIONAL next_key (a key entry, §7.1, announcing the next key). Any other member, a missing required member, or a malformed value → HEAD_FORM, except: a date that matches YYYY-MM-DD but is not a valid date, and a malformed published_at (including a non-string), → TIME_FORM (a date that is not a string matching YYYY-MM-DD is HEAD_FORM); canonicalisation failures → the §3 code. Check order: member set, C(head), format, n, tree_size, tip_count, date, published_at, log_root, tip_root, prev_head, server_key_id, next_key (a malformed next_key → HEAD_FORM; "malformed" means failing the §7.1 entry form: member set, value forms and key_id = key_id(pubkey); whether it equals the history's entry is the head signature rule's step 5), then the head-0 prev_head rule.

#6.7 Head chain

  • head_hash(h) = H(DT("head") ‖ C(h)).
  • Head 0: prev_head = hex(H(DT("genesis-head") ‖ "handoff/v0")) = 02554aac158443c123e70319ecee3903d05923ca0e7fdc3ec6227393dc3f6374 (fill; else HEAD_FORM).
  • Head n ≥ 1: prev_head = hex(head_hash(head n−1)).
  • At most one head per UTC date; dates strictly increase with n; published_at does not decrease. These are rules on the publisher's declarations, checked between signed heads (§10 step 5).

#6.8 Head signatures

S_head = Ed25519(K, DT("sig-head") ‖ H(C(head))) for each signing key K. A head entry (how heads travel) is an object: REQUIRED head, sigs (array of 1-4 objects, each exactly {key_id: hex32, sig: hex128}, distinct key_ids); OPTIONAL consistency (§6.4 proof from head n−1's tree_size to this head's; present for n ≥ 1 where the previous head is also carried); in known-heads lines only, OPTIONAL tip (a bare §6.5 member object {index, leaf, path}, for one record; its content is NOT checked when the line is loaded, only at §10 step 11(i), where a malformed or failing tip gives warning WITNESS_TIP_IGNORED and the head itself stays a witness).

Head-entry check order (r7, gap 6). A head entry is checked in this order, and the first failure is the entry's code; a conforming verifier reports that code and no other for the entry:

  1. not an object, or a member set other than {head, sigs} plus optionally consistency (and, in known-heads lines only, tip) → HEAD_FORM;
  2. the head value, in the §6.6 order (HEAD_FORM, TIME_FORM, or the §3 code of C(head));
  3. sigs not an array of 1-4 items → HEAD_FORM; then for each item in order: not an object with exactly {key_id, sig} → HEAD_FORM; key_id not hex32 → HEAD_FORM; sig not hex128 → HEAD_FORM; key_id equal to an earlier item's → HEAD_FORM;
  4. consistency present: its §6.3 encoding, in the §6.3 order (not an array → PROOF_MALFORMED; more than 64 nodes → PROOF_TOO_LONG; a node not hex64 → PROOF_MALFORMED). Its content is checked only by the chain checks (§10 step 5, §9.2);
  5. the head signature rule below (steps 1-5 in order). So every form check of the whole entry precedes every signature check (XG6-1..3).

Head signature rule (with the key history kh of §7): let required = every key valid at n (§7.3), sorted.

  1. required empty → HEAD_KEY_WINDOW.
  2. For each sig in order: key not in kh → HEAD_UNKNOWN_KEY (V12); not valid at n → HEAD_KEY_WINDOW (V19); strict verification fails → HEAD_SIG_INVALID.
  3. A required key with no signature → HEAD_SIG_MISSING (inside the overlap both keys MUST sign).
  4. head.server_key_id ∉ required → HEAD_KEY_WINDOW.
  5. next_key present: it MUST be byte-identical (canonical form) to the key history's introducing entry for that key_id, and its valid_from_head MUST be n + 1 (else HEAD_NEXT_KEY).

#7. The server key history

#7.1 keys.json

{"format": "handoff/v0", "entries": [entry, ...]} exactly (non-empty entries). An entry is an object: REQUIRED key_id (hex32 = key_id(pubkey)), alg ("ed25519"), pubkey (hex64), valid_from_head (int ≥ 0), endorsement (hex128); OPTIONAL retired_after_head (int ≥ 0), revoked_from_head (int ≥ 0), reason ("rotation" or "compromise"), endorsed_by (hex32). keys.json is append-only and published in the mirror.

#7.2 Endorsement

endorsement = Ed25519(K_signer, DT("sig-key") ‖ H(C(entry without the endorsement member))).

  • Entry 0 has no endorsed_by (an entry 0 naming one → KEYS_ENDORSEMENT) and is signed by its own key (the trust anchor is social: the published key history and its mirror; nothing certifies it).
  • Every later entry names endorsed_by (a key already in the history and not revoked) and is signed by it. A revocation of key K is endorsed by a key other than K.
  • Authority of a retired key (r7, C2). A key whose effective entry, at that point in the history, has retired_after_head set is retired. A retired key keeps exactly one authority: it MAY endorse a revocation (an update that first sets revoked_from_head) of another key. It MUST NOT endorse an introduction, a retirement or any other update (else KEYS_ENDORSEMENT). A key's own retirement entry is endorsed while the key is not yet retired (§7.4), so it is allowed. Rationale: a stolen retired key can then at most revoke a current key, which makes verification fail closed; it cannot introduce a key that would be trusted. The history file is still a trusted input (§7.6): these rules check it, they do not make an untrusted history trustworthy.

#7.3 Validation (all failures make the key history unusable: verifier ERROR, exit 3, with the code below)

Process entries in order; state[key_id] = the latest entry for that key_id.

  1. keys.json fails the §3 profile (SIDE context) or is not exactly {format, entries} with format handoff/v0 and a non-empty entries array → KEYS_FORM.
  2. Form as §7.1 and key_id = key_id(pubkey) → else KEYS_FORM; pubkey passes §2.2 steps 1-3 → else KEYS_BAD_KEY.
  3. Endorser as §7.2 (present in the history, not revoked, and, if retired, only for a revocation of another key) → else KEYS_ENDORSEMENT; endorsement verifies strictly → else KEYS_ENDORSEMENT.
  4. First entry for a key_id (introduction): no retired_after_head, revoked_from_head or reason; for i > 0, valid_from_head ≥ every earlier key's valid_from_head → else KEYS_FORM.
  5. Later entry for the same key_id (update): same pubkey and valid_from_head; retired_after_head and revoked_from_head once set never change (dropping one that was set is a change); retired_after_head ≥ valid_from_head; every update carries reason, = "compromise" if revoked_from_head is set, else "rotation" → else KEYS_FORM. Within one entry the steps run 1, 2, then 3 or 4.
  6. Overlap (checked once, after all entries are processed, on the final state): for each key K introduced after entry 0 with m = K.valid_from_head > 0, every other non-revoked key Q with Q.valid_from_head < m and Q.retired_after_head set MUST NOT have m − 1 ≤ Q.retired_after_head < m + 1 → else KEYS_OVERLAP. (A retirement before m − 1 is not an overlap error; the heads with no valid key then fail HEAD_KEY_WINDOW.) Every entry has passed the §7.1 form check before any C(entry) is computed, so a key entry cannot exceed the EVENT limits.

A key is revoked iff its effective entry has revoked_from_head.

valid_at(k, n) (the key-validity rule), evaluated on k's effective entry state[k] (its latest entry): k is in the history; n ≥ valid_from_head; not (retired_after_head set and n > retired_after_head); not (revoked_from_head set and n ≥ revoked_from_head).

#7.4 Rotation (server procedure)

  • Head n is signed by K_old and carries next_key = K_new's introducing entry (valid_from_head = n + 1, endorsed by K_old). K_old's update entry sets retired_after_head = n + 2 (reason rotation).
  • Heads n+1 and n+2 are signed by both keys (the 2-head overlap). From head n+3 only K_new signs.
  • A key introduced (in an entry after entry 0) with valid_from_head m MUST be announced by head m − 1: when the export carries head m − 1, that head's next_key MUST be present with key_id = that key (else REJECT HEAD_NEXT_KEY). A key introduced after entry 0 with valid_from_head 0 is legal (§7.3 step 3 allows it), has no announcing head, and gets neither the check nor the warning below. A supplied head counts as announcement evidence for that key (r8, F5) only if it is an accepted head (an export head that passed its checks, or a witness head accepted under §9.3), has n = m − 1, and carries a next_key whose key_id is that key (its canonical bytes are byte-identical to the introducing entry, §6.8 step 5; a head whose next_key fails that step is not accepted). A head at m − 1 with no next_key, or whose next_key announces another key, is not announcement evidence, however validly it is signed. When that key signs an export head or event and no supplied head (export, mirror or known heads) is announcement evidence for it, the verifier has not seen the announcement and MUST say so: warning KEY_ANNOUNCEMENT_UNSEEN (r7, C2), whose message states that the announcement is unverified (r8, F5). The warning is the same whether no head m − 1 is supplied or the supplied one lacks the announcement. The key is still trusted on the strength of the supplied history; this is a separate warning, not a finding about that history.
  • Events are signed by the newest key valid at the head that will first include them.

#7.5 Compromise

  • Append an update with revoked_from_head set as early as the evidence supports, endorsed by another key.
  • An event or head signed by a revoked key and first included in a head before revoked_from_head stands only if that head is in a supplied witness (mirror or known heads); otherwise DETECT KEY_COMPROMISED_UNWITNESSED (§10 step 12).
  • An unanchored event signed by a revoked key: REJECT if the key cannot be valid at the next head, else DETECT EVENT_KEY_REVOKED (§10 step 9).

#7.6 Where the verifier gets the key history

From --keys FILE, else keys.json in --mirror DIR. The export's own keys copy is NEVER trusted (an export cannot vouch for the key that signed it). With neither source the verifier returns ERROR (exit 3).

#8. The export envelope

#8.1 Top level

An export is one JSON document parsed under the EXPORT context (§3.5):

Member Req. JSON type Rule
formatREQUIREDstring"handoff/v0" (the version discriminator)
kindREQUIREDstring"record_export"
record_idREQUIREDhex32the record
eventsREQUIREDarray of entries (§8.2)1-1,000, in seq order
headsREQUIREDarray of head entries (§6.8)possibly empty; strictly increasing n
tipOPTIONALobject (§8.3)REQUIRED when heads is non-empty; absent when empty
keysOPTIONALkeys.json documentan untrusted copy for convenience
plaintextOPTIONALobject (§8.4)the parties' plaintext, if the exporter includes it

Any other member, or a missing required member → EXPORT_FORM. Wrong format or kind → EXPORT_FORM. The tip rule of the table (required with heads, absent without) is checked at §10 step 10 (TIP_FORM), not here. Unknown fields are never ignored; a later format adds members only under a new format string.

#8.2 Event entries

Each element of events is an object: REQUIRED event (§4.1), sigs (§4.8); OPTIONAL log_index (int ≥ 0, the leaf index in the log) and inclusion ({head_n: int, path} exactly: an inclusion proof against the log_root of the export head with that n). log_index and inclusion are both present (anchored) or both absent (unanchored). Anchored entries precede unanchored ones (checked at §10 step 9, ANCHOR_ORDER). A missing event or sigs, or any other member → EXPORT_FORM; exactly one of log_index and inclusion → INCLUSION_FORM (§10 step 6).

The exporter SHOULD produce every inclusion proof against the export's latest head (the anchor). A verifier checks a proof against whichever carried head head_n names.

#8.3 tip

Exactly {head_n, member} or exactly {head_n, absent} (else TIP_FORM), where head_n is the anchor's n (else TIP_HEAD, also for a head_n that is not an integer) and member/absent are §6.5 proofs against the anchor's tip_root and tip_count.

#8.4 plaintext

Object with REQUIRED claimed (array of distinct strings from {"P","C"}; absent → PLAINTEXT_FORM) and OPTIONAL p, c, each exactly {email: string, k_rec: hex64}. Rules in §10 step 13.

An export that carries plaintext gives its holder the role's address and k_rec_role. A later ERASE cannot recall a copy already handed out: its holder can still link the role's digest to the address, whatever the server deletes afterwards (§5.7, §17).

#8.5 The head chain an export carries

The export carries a contiguous run of head entries ending at the anchor. It starts at head 0, or at the head just before the first head that includes the export's first anchored event (so the verifier can see that event was not yet in the earlier head). The first carried head SHOULD have no consistency (if present it is not used); every later one MUST carry the consistency proof from its predecessor. A carried run with a gap in n is REJECTed (HEAD_CHAIN_GAP); an absent or failing consistency proof is REJECTed (HEAD_CONSISTENCY_UNPROVEN). Both are missing or invalid evidence, so the export is incomplete; neither is evidence that the heads conflict (r7, N6).

#9. Witness inputs

#9.1 --known-heads FILE

A file of lines separated by LF. Blank lines (only ASCII space, tab, CR, VT or FF) are skipped. Every other line is one head entry (§6.8, SIDE context), optionally with tip (a membership proof for one record at that head). Lines are written in canonical form by --update-known-heads, but any profile-valid JSON is accepted.

#9.2 --mirror DIR

The public mirror is a git repository; the verifier reads a local snapshot only:

  • heads/YYYY/MM/DD.json: one head entry per UTC date (SIDE context), including its consistency proof from the previous head (absent for head 0). Files are read in sorted path order. Files not ending in .json are ignored.
  • keys.json at the root (§7.1).
  • The mirror is append-only: a published head file never changes. Independent witness co-signatures are a v1 item.
  • The mirror's own chain must be proven (r7, N6). Take the mirror heads accepted under §9.3, sorted by n, keeping the first file in path order for each n (a second, different head with the same n is an equivocation, §10 step 11(b)). For each consecutive pair (a, b): b.n ≠ a.n + 1 (including a gap left by an ignored file), or (when b.tree_size ≥ a.tree_size) b's consistency proof from a absent or failing §6.4 → finding level error, code WITNESS_UNPROVEN. The mirror snapshot may start at any n; it must be contiguous from its first head to its last. An unproven mirror is a defect of the verifier's input, not of the export: it never makes the export REJECT or DETECT, but the result is ERROR unless a reject or detect finding exists (§10, result rule).
  • A mirror snapshot is a copy of bytes. Holding it shows that those signed heads exist; it does not show when they were published (§11.2).

#9.3 Acceptance of witness heads

A known-heads line or mirror file is accepted as a witness head iff it parses, passes the head-entry form (§6.6, §6.8), and passes the head signature rule (§6.8) under the trusted key history. Otherwise it is ignored with warning WITNESS_HEAD_IGNORED; it never makes the export REJECT. A head is witnessed iff a witness head with byte-identical C(head) exists. A head absent from the given mirror is unanchored (§10 step 11, --require-anchored).

Linked witness heads (r7, N7). A witness head w is linked to the export iff C(w.head) is byte-identical to an export head, or w is a mirror head, the mirror chain is proven (no WITNESS_UNPROVEN) and at least one mirror head is byte-identical to an export head, or w is byte-identical to such a linked mirror head. Two proven, contiguous chains that share one head form one chain, so every linked head is consistent with the export's chain. An unlinked head is still compared (§10 step 11: equivocation, rollback, equal-size forks, size decreases), but no consistency with it is claimed: warning WITNESS_UNLINKED.

#9.4 What the verifier can detect, and what is only missing evidence

DETECT, class conflict (authenticated: every object named is validly signed and, for tips, its membership proof verified; all of them are carried as evidence):

  • (a) two heads with the same n, or the same date, and different C(head): equivocation, HEAD_EQUIVOCATION (V30);
  • (b) two heads, from any sources, with the same tree_size but a different log_root, tip_root or tip_count: a fork at equal size, HEAD_FORK_SAME_SIZE (XN7). One log state has one root and one tip map, so a single pair is enough;
  • (c) a higher n with a smaller tree_size, within a chain or across sources: HEAD_SIZE_DECREASE (V34);
  • (d) consecutive heads of one chain whose prev_head link, date order or published_at order is broken: HEAD_PREV_LINK, HEAD_DATE, HEAD_TIME_ORDER;
  • (e) this record's tip going backwards, or forking between heads: TIP_BACKWARDS, TIP_FORK, using only tips whose membership proof verified (r8, F1: TIP_AHEAD_TRUNCATED is not in this class, see (h)).

DETECT, class freshness: (f) the export's anchor has a smaller tree_size than a witness head, or no anchor while a known head exists: rollback, HEAD_ROLLBACK (V29).

DETECT, class anchoring (a policy or key-witness check, not a conflict): (g) HEAD_UNANCHORED (V33) under --require-anchored; KEY_COMPROMISED_UNWITNESSED, EVENT_KEY_REVOKED (§7.5); CONFIRM_ANCHOR_LATE (§5.4).

DETECT, class completeness (r8, F1; the export is unsigned, so this is a statement about the export, not about any signed object): (h) a validly signed head, with a verified tip membership proof for this record, commits a tip with last_seq at or beyond the number of events the export carries: TIP_AHEAD_TRUNCATED (V10; the export's own anchor head at step 10, or a known head's tip at step 11(i)). The signed head and the exported signed events do not contradict each other: the head commits an additional event the export lacks. The finding does not say who left the event out, when, or whether any operator did anything wrong; anyone holding the export can remove an entry from it.

Missing or invalid evidence is never DETECT:

  • in the export: a gap in the carried head chain → REJECT HEAD_CHAIN_GAP; an absent or failing consistency proof → REJECT HEAD_CONSISTENCY_UNPROVEN; an invalid tip proof → REJECT with its code, and that tip feeds no comparison;
  • in the mirror: a gap or an absent or failing consistency proof → WITNESS_UNPROVEN (level error, §9.2);
  • in known heads: a head that fails §9.3 or a tip that fails → warnings WITNESS_HEAD_IGNORED, WITNESS_TIP_IGNORED. With no witness at all, split view is undetectable; the verifier says so (NO_WITNESS). Heads that are not linked (§9.3) are compared but not claimed consistent (WITNESS_UNLINKED).

#10. The verification algorithm (normative)

Inputs: export bytes; key-history bytes (from --keys, else the mirror's keys.json); zero or more known-heads lines; the mirror (absent, or zero or more mirror head files); the flag require_anchored; the verifier's declared local caps, if any (§3.5). The verifier collects findings, each {level, code, seq, message, evidence?} with level ∈ {reject, detect, error, warning, info, status}; a detect finding also carries class ∈ {conflict, freshness, completeness, anchoring}, fixed by its code (Appendix A). "Stop" means: return the findings so far.

Result rule. REJECT if any finding has level reject; else DETECT if any has level detect; else ERROR if any has level error; else ACCEPT. (An error finding never hides a reject or an authenticated conflict.) A conforming verifier MUST run the steps in this order and MUST report at least the codes this procedure produces at reject, detect and error level, and the warning KEY_ANNOUNCEMENT_UNSEEN where §7.4 requires it (r8, F5); it MAY add other warning, info and status findings. Sub-steps run in the order written; labels follow that order.

  1. Usage and local caps (before reading the export's content): require_anchored with no mirror → ERROR, single finding level error code USAGE (r7, gap 2), stop. The export's byte length is checked first: > the EXPORT byte limit (§3.5) → go on (step 1 REJECTs it with LIMIT_SIZE); else, byte length within that limit and > a declared local cap → ERROR, single finding LOCAL_CAP, stop (r8, F3: the content is not read, and the finding claims no other protocol limit was checked; its message says so); else go on.
  2. Parse the export under EXPORT (§3) → on failure REJECT with the §3 code; stop.
  3. Envelope (§8.1): member set → EXPORT_FORM, stop; format/kind → EXPORT_FORM, stop; record_id hex32 → EXPORT_FORM, stop. Let tag = record_tag(record_id).
  4. Key history (§7): no source → ERROR, single finding level error code USAGE, stop. Parse under SIDE and validate (§7.3); any failure → ERROR, single finding level error with the §7.3 code (KEYS_FORM, KEYS_BAD_KEY, KEYS_ENDORSEMENT or KEYS_OVERLAP), stop. If the export has keys: not an object with exactly {format, entries}, or not canonicalisable under EXPORT → REJECT EXPORT_FORM, stop; if its canonical bytes differ from the trusted history's → warning KEYS_EXPORT_DIFFERS.
  5. Witness heads: load known-heads lines (allowing tip) and mirror files per §9.3 (failures → warning WITNESS_HEAD_IGNORED).
  6. Export heads: heads not an array → REJECT EXPORT_FORM, stop. For each entry in order: head-entry checks (§6.8 order, including the head signature rule); a failure → REJECT with its code and skip that entry; a second entry that passed its checks with an n already seen among passed entries → REJECT HEAD_DUPLICATE. If any REJECT so far, stop. If the entries are not in strictly increasing n → REJECT EXPORT_FORM, stop. Announcements (§7.4) → REJECT HEAD_NEXT_KEY (continue). Chain checks over consecutive pairs (a, b) of the export's heads, in this order: b.n ≠ a.n + 1 → REJECT HEAD_CHAIN_GAP (skip the rest for this pair); b.tree_size < a.tree_size → DETECT HEAD_SIZE_DECREASE (skip the rest); b.prev_head ≠ hex(head_hash(a)) → DETECT HEAD_PREV_LINK; b.date ≤ a.date → DETECT HEAD_DATE; b.published_at < a.published_at → DETECT HEAD_TIME_ORDER; b.consistency absent or failing §6.4 from (a.tree_size, a.log_root) to (b.tree_size, b.log_root) → REJECT HEAD_CONSISTENCY_UNPROVEN. (The chain-check REJECTs do not stop here; step 8's stop applies.) Let anchor = the last export head, if any.
  7. Events, signatures, inclusion: events not a non-empty array → REJECT EXPORT_FORM, stop; more than 1,000 → REJECT LIMIT_EVENTS, stop (a backstop: under the EXPORT item limit the parser already refuses such an array with LIMIT_ITEMS). For each entry i, in order:

    • entry member set (§8.2) → EXPORT_FORM; event structure (§4.1-§4.3) → its code; event.seq ≠ i → R1_SEQ; sigs (§4.8) → its code. Any of these: REJECT with that code, mark the export structural, continue with entry i+1.
    • S_w: sigs.w.key_id ≠ event.server_key_id → SIG_W_KEY_ID; key not in the history → SIG_W_UNKNOWN_KEY; §2.2 verification of S_w over DT("sig-event") ‖ H(C(event)) fails → SIG_W_INVALID. Only the first failing S_w check is reported for the entry. REJECT; continue with the inclusion checks of this entry.
    • inclusion: exactly one of log_index/inclusion present → REJECT INCLUSION_FORM, next entry. Both absent: next entry. Otherwise: log_index not int ≥ 0, or inclusion not exactly {head_n, path} → INCLUSION_FORM; path (§6.3 encoding) → PROOF_TOO_LONG/PROOF_MALFORMED; head_n not an int → INCLUSION_FORM; no export head with that n → INCLUSION_HEAD_UNKNOWN; §6.3 for leaf_hash(DT("leaf") ‖ tag ‖ u64be(event.seq) ‖ event_hash(event)), with tag from step 2, at log_index in that head's tree_size and log_root → PROOF_INDEX/PROOF_INCLUSION. Each failure is a REJECT; only the first inclusion failure of an entry is reported. (A proof against any carried head is checked; it need not be the anchor.)
  8. Chain pass (only if not structural), for every i (all breaks are reported, not just the first): i = 0: prev_hash ≠ genesis(event.record_id) → REJECT CHAIN_GENESIS; i > 0: record_id ≠ events[0].record_id → REJECT RECORD_ID_MISMATCH, else prev_hash ≠ hex(event_hash(events[i−1])) → REJECT CHAIN_PREV_HASH. Then events[0].record_id ≠ the export's record_id → REJECT RECORD_ID_MISMATCH.
  9. Rules replay (only if not structural; a link break found in step 7 is found again here, so the same code is reported twice: this is intended): apply (§5.2) each event in order with its S_a, from the empty state; at the first failure REJECT with its code and stop replaying. If any REJECT exists now, stop.
  10. Anchoring, key windows and time: anchored = indexes with log_index present. Not exactly 0..k−1 → REJECT ANCHOR_ORDER. log_index not strictly increasing → REJECT LOG_ORDER. Then, whatever those two found, for each anchored i: first head fi = the first export head (by n) with tree_size > log_index; none → REJECT FIRST_INCLUSION_UNPROVEN; fi is the first carried head and fi.n ≠ 0 → REJECT FIRST_INCLUSION_UNPROVEN. If any REJECT exists now, stop. Then for each anchored i: not valid_at(server_key_id, fi.n) → REJECT EVENT_KEY_WINDOW (V19); at > fi.published_at → REJECT TIME_AFTER_HEAD (V41: the two declarations contradict each other); record the time bound (§11.2). (No stop after these REJECTs: steps 10-13 still run.) Let next_n = the maximum of (anchor.n + 1, or 0 with no anchor), every accepted witness head's n + 1, and every key's valid_from_head. For each unanchored i: not valid_at(server_key_id, next_n) → REJECT EVENT_KEY_WINDOW; else if that key is revoked → DETECT EVENT_KEY_REVOKED; record "unanchored, not yet provable" (§11.2). If COMMIT has confirm_by: for each anchored CONFIRM whose ref = hex(event_hash(events[0])), if fi.published_at > confirm_by + 86,400 s → DETECT CONFIRM_ANCHOR_LATE.
  11. Tip (G3): no anchor: a tip present → REJECT TIP_FORM; warning NO_HEADS. With an anchor: verify tip per §8.3/§6.5 against the anchor (absent tip → TIP_FORM). If that verification fails: REJECT with its code, skip the tip comparisons below, and the export's tip is not collected in step 11(i) (r7, N6: an unverified leaf is never evidence). Otherwise: absent-proof and some event anchored → REJECT TIP_ABSENT_BUT_ANCHORED. Member-proof with leaf t: let last = number of events − 1 and la = the last anchored index (−1 if none): t.last_seq > last → DETECT TIP_AHEAD_TRUNCATED (V10; class completeness, §9.4 (h)), with the signed anchor head and the member proof as evidence; else t.last_seq < la → REJECT TIP_BEHIND; else t.last_seq > la → REJECT TIP_MISSING_PROOF; else t.event_hash ≠ hex(event_hash(events[la])) or t.log_index ≠ log_index of la → REJECT TIP_MISMATCH. Then, in every case with an anchor or not: unanchored events → info UNANCHORED_EVENTS.
  12. Witnesses (G4). Sources in order: export heads (the step-5 chain), mirror (accepted heads in path order), known heads (accepted lines in file order).

    (a) No witness heads at all (no accepted mirror or known head) → warning NO_WITNESS.

    (b) Equivocation: any two heads from any sources with equal n, or equal date, and different C(head) → DETECT HEAD_EQUIVOCATION with both signed heads (report each distinct pair once).

    (c) Rollback: no anchor and at least one known head (mirror heads do not count here) → DETECT HEAD_ROLLBACK; with an anchor: the witness head with the largest tree_size (mirror before known on ties) has tree_size > anchor.tree_size → DETECT HEAD_ROLLBACK (one finding).

    (d) Mirror chain (§9.2), over the mirror heads sorted by n keeping the first file for each n, consecutive pairs (a, b) in order: b.n ≠ a.n + 1 → error WITNESS_UNPROVEN (skip the rest for this pair); b.tree_size < a.tree_size → DETECT HEAD_SIZE_DECREASE (skip the rest); b.prev_head ≠ hex(head_hash(a)) → DETECT HEAD_PREV_LINK; b.date ≤ a.date → DETECT HEAD_DATE; b.published_at < a.published_at → DETECT HEAD_TIME_ORDER; b.consistency absent or failing §6.4 → error WITNESS_UNPROVEN.

    (e) Links (§9.3): every mirror head, then every known head, that is not linked → warning WITNESS_UNLINKED. Only WITNESS_UNPROVEN makes the mirror chain unproven; a detect finding from (d) does not unlink it.

    (f) require_anchored: for every n in {fi.n of every anchored event} ∪ {anchor.n}, the export head with that n not witnessed by the mirror → DETECT HEAD_UNANCHORED (one finding per distinct n, ascending); no anchor → DETECT HEAD_UNANCHORED.

    (g) Equal-size forks (r7, N7): for every pair of heads from any sources (export, mirror, known, in source order, each pair once) with equal tree_size and a different (log_root, tip_root, tip_count) → DETECT HEAD_FORK_SAME_SIZE with both signed heads; report each distinct pair of C(head) values once.

    (h) Sizes across sources: take one head per n (first source in the order above), sorted by n; for each head a, the first later head b with b.tree_size < a.tree_size → DETECT HEAD_SIZE_DECREASE. (Steps (b), (g) and (h) also see pairs that step 5 or step (d) already reported; reporting them again is intended.)

    (i) Tips: collect the export's tip leaf (at anchor.n), only if step 10 verified it, then every known-heads tip in file order, checked in this order: not an object with exactly {index, leaf, path}, or leaf form → warning WITNESS_TIP_IGNORED; leaf.record_tag ≠ tag → skipped silently (another record's tip); index not int ≥ 0, path encoding, or the §6.3 inclusion check against that known head's tip_root and tip_count fails → warning WITNESS_TIP_IGNORED. For each collected known tip k: k.last_seq ≥ number of events → DETECT TIP_AHEAD_TRUNCATED; else event_hash(events[k.last_seq]) ≠ k.event_hash → DETECT TIP_FORK. Sort all collected tips by head n (stable); for consecutive (x, y): y.n > x.n and (y.last_seq < x.last_seq, or equal last_seq and different event_hash) → DETECT TIP_BACKWARDS; y.n = x.n and (different last_seq or event_hash) → DETECT TIP_FORK.

  13. Compromised keys (§7.5): for each anchored event whose server key is revoked and whose first head fi is not witnessed → DETECT KEY_COMPROMISED_UNWITNESSED; for each export head with a signature by a revoked key and not witnessed → DETECT KEY_COMPROMISED_UNWITNESSED. Then (report only): fill each time bound's witnessed_by (§11.2), and the announcement warnings KEY_ANNOUNCEMENT_UNSEEN (§7.4).
  14. Party data: for each erased role, status PARTY_ERASED (§5.7 text). plaintext absent → status PLAINTEXT_UNAVAILABLE for each role not erased. plaintext present: not an object with members ⊆ {claimed, p, c} and claimed an array of distinct strings ⊆ {"P","C"} → REJECT PLAINTEXT_FORM, end of step. For P then C (data = member p or c): if the role is erased, then data present or role claimed → REJECT PLAINTEXT_AFTER_ERASE, and in every case nothing more is reported for that role; otherwise: data absent and role claimed → REJECT PLAINTEXT_MISSING (r7, N6: the export contradicts itself; r6 had DETECT); data absent, not claimed → status PLAINTEXT_UNAVAILABLE; data present but not claimed, or data not exactly {email: string, k_rec: hex64} → REJECT PLAINTEXT_FORM; digest(k_rec, email) (§5.8) ≠ the role's COMMIT digest → REJECT PLAINTEXT_DIGEST. A party name with letters from more than one script → warning NAME_MIXED_SCRIPT (V54; names are self-described; informative: the reference takes a letter's script as the first word of its Unicode character name). Status RECORD_STATE with the final state.

Early stops (C3). Steps 1-3, 5, 8 and 9 can stop before the witness step. A report that stops there, or any REJECT report without a DETECT, is not an audit of the supplied witnesses: it does not show that they agree with each other or with the log. Re-run with a valid export (or audit the witnesses on their own) for that.

#11. Results, time and wording

#11.1 Results

Result Exit Meaning
ACCEPT0VERIFIED: the export is internally valid; no authenticated conflict, freshness, completeness or anchoring failure was found against the supplied signed heads; consistency is claimed only with linked heads (§9.3)
REJECT1the export is internally invalid or incomplete (including a missing or failing proof)
DETECT2internally valid, but an authenticated conflict, a freshness failure, a completeness failure or an anchoring failure (the class of each detect finding says which)
ERROR3usage, I/O, local cap, key-history or witness-input problem (WITNESS_UNPROVEN); no verdict on the export

Detect classes (r7, N6; r8, F1): conflict = two validly signed objects that contradict each other, each verified, both in evidence; freshness = a supplied signed head is newer than the export (HEAD_ROLLBACK): the export may be stale or may hide later events; completeness = a validly signed head commits events of this record that the export does not carry (TIP_AHEAD_TRUNCATED): the unsigned export is incomplete, and no signed contradiction and no actor is claimed; anchoring = a policy or key-witness check failed (HEAD_UNANCHORED, EVENT_KEY_REVOKED, KEY_COMPROMISED_UNWITNESSED, CONFIRM_ANCHOR_LATE): no signed contradiction is claimed. Only a conflict finding is evidence of operator misbehaviour, and it is evidence of signed statements, not of who acted.

Result text of a completeness finding (r8, F1; normative for verifier output, JSON message and text): it MUST say that the export is incomplete relative to the signed head, and it MUST NOT say or imply that two signed statements contradict or conflict, that a head or tip equivocates or forks, that anyone signed contradictory statements, or that Weio, an operator or any other actor removed, hid, withheld or altered events. The fixtures carry expect.finding_text (§15) for V10-1 and the XF1 cases.

  • VERIFIED ≠ ACCEPTED ≠ PAID. A verifier ACCEPT says the bytes check out. Whether the counterparty accepted the delivery is the record state (RECORD_STATE), reported separately. Handoff records no payment.
  • A REJECT event is a status of a valid record: a record whose state is REJECTED verifies as ACCEPT.
  • Human-readable output MUST print the result word and the record state as separate lines, and SHOULD say "verified" rather than "accepted" when describing a verifier ACCEPT.
  • A signature proves which key signed which bytes. It does not establish which person controls that key, nor that the agreed criteria were the right ones (HO-13 wording).

#11.2 Time

  • What is proven: inclusion. An anchored event is in the log committed by its first including head fi (the inclusion proof), and fi is a validly signed head. That is a position in the logical history, not a wall-clock time.
  • What is declared: an event's at is declared by its writer; a head's published_at is declared by the publisher. A signature proves the declaration was signed, not when. A publisher could sign today a consistent history whose published_at values lie years in the past.
  • What bounds wall-clock time: only an observation made independently of the publisher, outside these bytes: for example the moment a party saved a head in its known-heads file, or a mirror copy whose fetch time its holder can attest. The verifier reads no clock and sees no such observation, so it never outputs a proven time. It reports, for each head that bounds an event, which supplied witnesses hold that head byte-identically (witnessed_by), so the holder of that witness can apply its own observation time.
  • For each anchored event the verifier reports (JSON time_bounds, in seq order) {seq, type, head_n: fi.n, head_published_at: fi.published_at, time_source: "publisher-declared", declared_at: at, declared_gap_seconds, witnessed_by, statement}, where declared_gap_seconds = fi.published_at − at as an integer number of seconds (a signed integer; it is ≥ 0 for every event that passes TIME_AFTER_HEAD; an event declared one second before its head's published_at gives 1, XG5-1), witnessed_by ⊆ ["mirror", "known-heads"] in that order, and statement = included in head N; publisher-declared publication time T; declared event time X is self-declared.
  • For an unanchored event it reports, in the same time_bounds array after the anchored ones, {seq, type, declared_at, statement: "unanchored, not yet provable"} (no head_n): its truncation cannot be detected until the next head. Fixture expect.time_bounds lists the anchored entries only.
  • Backdating (V43) is ACCEPT with the gap shown.
  • TIME_AFTER_HEAD (REJECT) and CONFIRM_ANCHOR_LATE (DETECT, anchoring) compare declared values with each other; they prove nothing about the real time.

#11.3 Wording

Verifier output and record text MUST NOT present at as proven (no "signed at", "timestamped") and MUST NOT use the three r5 §11 E3 phrases that claim a record can never change, nor "certified" or "verified identity". Say: "a change after a published head is detectable by anyone holding an earlier export or head". It MUST NOT present a time as proven (§11.2) nor an erasure as proven deletion or unlinkability (§5.7). The exact pattern lists used by the V44, XN2 and XN3 tests are in fixtures/text/.

#12. Server rules (REFUSE)

A server (the write path) MUST:

  • before it signs an event written with auth.method email_token, check online (r7, C1): the token_nonce was issued by this server, for this record, this role and this action; it is unused; and the current server time is not after the token's expiry. The at it signs is its own declared time, never later than that expiry. A verifier can replay only what is signed (§5.2 R4 compares the declared at with token_expires); that this online check ran is the server's attestation, not something a verifier proves;
  • canonicalise every object it signs under §3 and REFUSE what fails;
  • run §5.2 on a copy of the record state before appending, REFUSE on any failure, and append only on success;
  • generate record_id (16 bytes), k_rec_P and k_rec_C (32 bytes each), email-token nonces (16 bytes) and share tokens (32 bytes) from a CSPRNG;
  • REFUSE a COMMIT whose two normalized addresses are equal (V50);
  • store notes and addresses only in the deletable table; put only digests in bodies (V57);
  • treat the share token as read-only: refuse any write it alone authorises (V25); serve pages with Referrer-Policy: no-referrer; log only H(DT("share") ‖ token) (V27);
  • build each head atomically with its tip tree; publish at most one head per UTC date with sequential n, and commit it (with its consistency proof) to the mirror;
  • rotate and revoke keys as §7.4-§7.5;
  • on ERASE, delete the role's address, notes and k_rec_role from the table, and from backups within the period its published retention text states (V40). This duty cannot reach copies already exported (§8.4), nor signed bytes (digests, names, the ERASE itself), and no verifier can confirm it was done (§5.7).

#13. The CLI

handoff verify RECORD.json
    [--known-heads FILE]        §9.1
    [--mirror DIR]              §9.2
    [--require-anchored]        DETECT if any anchoring head is not in --mirror; without --mirror: ERROR (exit 3)
    [--update-known-heads FILE] append the export's verified heads (only on ACCEPT; never silent)
    [--keys FILE]               key history (§7); default = keys.json in --mirror
    [--json]                    {"result": "ACCEPT|REJECT|DETECT|ERROR", "findings": [...], "time_bounds": [...]}
Exit code: 0 ACCEPT, 1 REJECT, 2 DETECT, 3 ERROR (usage, I/O, local cap, key history, unproven mirror).
  • Offline: the CLI never opens a network connection and never reads a clock.
  • Flags are exact (no abbreviations). --require-anchored without --mirror is a usage ERROR (exit 3, code USAGE) raised before any verification (§10 step 0, gap 2).
  • An internal error MUST be exit 3, never exit 1. Usage, flag, I/O and internal errors are reported with code USAGE.
  • The mirror reader takes every file under heads/ whose name ends in .json, in sorted path order (§9.2); a path that does not follow heads/YYYY/MM/DD.json is still read (its head is checked like any other).
  • The JSON report is output, not a protocol object; seq is the event index or null; detect findings carry class; time_bounds follow §11.2. Output holds no wall-clock time and no file paths.
  • --update-known-heads appends, as canonical lines, each export head entry {head, sigs} (the anchor with the export's tip.member as tip) that the file does not already hold, and reports KNOWN_HEADS_UPDATED; on any other result it changes nothing and reports KNOWN_HEADS_NOT_UPDATED.

#14. Protocol limits versus local caps

The limits in §3.5 and §4 are protocol rules: they are fixed by "format":"handoff/v0", the server REFUSEs beyond them and the verifier REJECTs beyond them. Local caps (a CLI's maximum file read, a mirror file count) are implementation safety guards, with the single rule of §3.5: the input's byte-length protocol limit is checked first; with the byte length within it, hitting a local cap is ERROR LOCAL_CAP (exit 3), never REJECT, and claims nothing about the other protocol limits (r8, F3). A full verifier sets no local cap below a protocol limit; a capped verifier MAY, and states its caps. A server MUST NOT sign beyond the protocol limits.

#15. Fixtures (normative test data)

fixtures/ holds one JSON file per case (format handoff-v0-fixture/1), listed in fixtures/INDEX.md and fixtures/MANIFEST.json and hashed in fixtures/SHA256SUMS. Kinds:

  • verify: the export bytes are inputs.export_b64, or inputs.export_gen (below); optional keys_b64 (the --keys file), optional known_heads_b64 (the --known-heads file), optional mirror {heads_b64: [file bytes in path order], optional keys_b64} (a mirror member, even with an empty list, means --mirror was given), require_anchored, optional local_caps {export_bytes: int} (the verifier's declared local cap for this run, §3.5). expect.result is ACCEPT/REJECT/DETECT/ERROR; expect.code is the first reject code (REJECT), the first detect code (DETECT) or the first error code (ERROR) this algorithm produces; expect.reject_codes, expect.detect_codes and expect.error_codes list all of them in order; expect.other_codes lists warning, info and status codes (informative); expect.absent_codes, when present, lists codes the verifier MUST NOT report at any level; expect.time_bounds, when present, lists {seq, head_n, declared_gap_seconds} for every anchored event in seq order (§11.2) and MUST be reproduced; expect.detect_classes (r8), when present, lists the class of each detect finding in the order of expect.detect_codes and MUST be reproduced (the class is fixed by the code, Appendix A); expect.required_codes (r8), when present, lists codes of any level, including warnings, that MUST be reported (used for KEY_ANNOUNCEMENT_UNSEEN, §7.4); expect.finding_text (r8), when present, is {code, regex_flags ("i"), must_match, must_not_match}: the message of every detect finding with that code MUST match every must_match pattern and none of the must_not_match patterns (§11.1); expect.error_text (r8.1), when present, is {code, regex_flags ("i" or ""), must_match (non-empty), must_not_match (possibly empty)}: there is at least one error finding with that code, and the message of every one MUST match every must_match pattern and none of the must_not_match patterns (used for the LOCAL_CAP message of §3.5 and §10 step 0: the content was not read, no other protocol limit was checked, and the cap is stated; the XF3-3 patterns accept the usual wordings of these three statements, not only the reference's); expect.server is "REFUSE" where the vector's write is refused by a server. Fixtures with the member profile (r8.1, §15A) may also carry expect.server_code (the code the server returns) and expect.server_refused_seq (the seq of the first event it refuses). These two members and expect.server bind a server, are informative for a verifier and are not executed by the reference runner. When no keys_b64 is given and the mirror has keys, the mirror's keys.json is the key history.
  • Generator rule inputs.export_gen = {prefix_b64, unit_b64, count, suffix_b64, sha256, length}: the export bytes are prefix ‖ unit repeated count times ‖ suffix (each part base64-decoded). A runner MUST check that the SHA-256 of the constructed bytes equals sha256 (lowercase hex) and its length equals length before using them; a mismatch means the fixture is corrupt (runner error, not a verdict). The rule keeps large limit cases (V53-1, V53-3, V53-4, XN5-2) small on disk; the constructed bytes are exactly the bytes the case tests.
  • canon: inputs.json_b64 → expect {result: OK, canonical_b64} or {result: ERROR, code}, under the EVENT context (§3.5).
  • digest: §5.8 party digests and the erasure linkability checks.
  • text: the V44, XN2 and XN3 wording checks. (r8.1) These three count the banned patterns in fixed strings stored in the fixture (cli_output_text, cli_output_json), not in the candidate's output: the count is a literal regex-unit check, and only expect.result comes from the candidate. The candidate's own output is checked by render.
  • render (r8.1): the wording and output-form checks on what the candidate's own CLI prints. inputs holds the export (export_b64), keys_b64, optional known_heads_b64, cli_argv (a non-empty array of non-empty argument arrays, one per invocation: the plain invocation and the --json invocation), banned_regex (a non-empty array of patterns, none of which matches the empty string) and regex_flags ("i"). In an argument array, an argument equal to RECORD.json, keys.json or known-heads.json is a placeholder: the runner writes the decoded export, keys or known-heads bytes to a temporary file and substitutes that file's path. The runner runs the candidate's own CLI (§13) once per entry of cli_argv with no other input and captures stdout and stderr apart. An invocation that prints nothing but white space (stdout and stderr together) is a failure (§11.1: the plain output prints the result word; §13: --json prints the report). The runner then checks every member of expect that is present, and at least one is: result (the result member of the JSON object that the --json invocation prints on stdout; stderr is not part of the report); cli_exit (an integer: the exit code of every invocation); banned_matches (an integer: the number of matches of the banned_regex alternatives, joined with |, in the captured stdout and stderr of each invocation, non-overlapping, left to right, summed over the invocations); and time_bounds_form (from §11.2), an object {anchored_count, unanchored_count, anchored_members, time_source, statement_template, optional witnessed_by, unanchored_members, unanchored_statement} that is checked on the time_bounds array of that JSON object: every entry is an object; it holds exactly anchored_count entries with a head_n and unanchored_count entries without one; each entry with a head_n has at least the members anchored_members, time_source equal to the given value, an integer declared_gap_seconds, a statement equal to statement_template with {head_n}, {head_published_at} and {declared_at} replaced by that entry's values, and witnessed_by a subset of ["mirror", "known-heads"] in that order (equal to the given witnessed_by when present); each entry without a head_n has at least the members unanchored_members and statement equal to unanchored_statement. Other members are allowed: §11.2 lists the members without "exactly", and the report is output, not a protocol object (§13). The reference runner (gen/run_fixtures.py) calls the candidate's cli.main(argv, out=, err=) in process and takes a SystemExit raised by that call as the invocation's exit code (a SystemExit without a code is 0); a candidate with another entry point supplies an adapter that returns the same captured text and exit code. A fixture that raises or exits in any other place (any exception, SystemExit included), in any kind, is a failure of the candidate, whatever its note says. A render or error_text fixture whose own data breaks the rules above is reported as a failure ("fixture: ...").
  • Profile fixtures (r8.1): a verify or digest fixture with the top-level member "profile": "weio-ed25519-only/1" belongs to §15A. It is run exactly like a fixture of its kind. Its verifier half is r8's and is held to the same bar; the runner counts base and profile fixtures apart and says that server expectations are not executed. Ids: P1-n (verify) and P1-dn (digest). A fixture may carry an informative member profile_context (identifiers, key labels); no runner reads it.
  • ed25519 (r7, N1): inputs {pubkey_hex, msg_hex, sig_hex} → run §2.2 steps 1-8 → expect {result: OK, code: null} or {result: FAIL, code} with the first failing Ed25519 code. informative says which of the two equations would hold on the raw points; it is not an expectation.

Fixture ids are unique across all kinds (canon ids end in -cK, digest -dK, text -tK, render -rK, ed25519 -eK). Kind-specific inputs:

  • digest with inputs.k_rec_hex and inputs.value: compute §5.8 digest of value (normalised as §5.8; the fixture also shows normalized) → compare with expect.digest (hex).
  • digest with inputs.candidates, inputs.export_b64, inputs.methods, inputs.forms (r7, gap 3, exact rule):

    • forms are identifiers: as_given (the candidate unchanged), lower (Unicode default lower-casing of the whole string), nfc_strip_lower (Unicode NFC, then remove leading and trailing white space, then lower-casing: the §5.8 normalize);
    • methods: sha256 = lowercase hex SHA-256 of the UTF-8 bytes of the form; sha256-lower = the same of the form's lower-casing; hmac-sha256-empty-key = lowercase hex HMAC-SHA256 with an empty key over those bytes;
    • V = the set of all values, over every candidate × form × method (duplicates collapse);
    • W = the set of all 64-character windows of the export text (decoded as UTF-8) that consist only of [0-9a-f], taken at every offset, so windows overlap and a longer run of hex yields several windows; uppercase hex is not matched;
    • expect.matches = |V ∩ W|, the number of distinct values found (an integer). V36-d2 tests overlap, uppercase, duplicates and a near-miss.
  • text: inputs.regex_flags is "i" (case-insensitive, the only flag used); count matches of the inputs.banned_regex alternatives (joined with |) in inputs.cli_output_text and inputs.cli_output_json (non-overlapping, left to right, per string) → expect.banned_matches (an integer); expect.result is the verifier result for the given export and keys.

A conforming verifier MUST reproduce all of the following, where the fixture gives them, and nothing else is required of it by a fixture:

  • every verify fixture (a profile fixture included: its verifier half is r8's): expect.result; expect.code among its findings of the matching level; none of the codes of expect.absent_codes, at any level; expect.time_bounds; expect.detect_classes; expect.required_codes; expect.finding_text; expect.error_text;
  • every render fixture: expect.result, expect.cli_exit, expect.banned_matches and expect.time_bounds_form;
  • every ed25519 fixture: expect in full. The canon, digest and text kinds add no MUST of their own here: they exercise §3 and §5.8, and the text kind compares stored strings (see above).

expect.code is the first code the reference algorithm produces at the level of expect.result, and the ordered arrays reject_codes, detect_codes and error_codes (with other_codes) are what the reference runner compares, exactly and in order. A conforming verifier is held to the weaker bar above, expect.code among its findings of the matching level (r8's bar, kept). The stronger check, the first code and the arrays in order, is the runner's self-check of the reference, not a conformance requirement. This release adds no first-code and no ordered-array requirement. The fixture bar does not loosen §10: the check rules, the stop rules and the order rules of the algorithm are normative and still bind every conforming verifier, whether or not a fixture shows them.

All keys in fixtures are TEST-ONLY keys, published deliberately in fixtures/TEST-KEYS.json; they MUST NOT be used for any real record. Some are invalid by design (mixed-order keys for N1).

#15A. Profile extension weio-ed25519-only/1 (not part of unqualified r8, r8.1 or r8.1a conformance)

#15A.0 Status and scope

  • Name and version. The profile is named by the string weio-ed25519-only/1. An operator that runs it states that string in its published terms. A change to any rule of this section is a new version string.
  • An extension, not a rule of r8.1a. Nothing in §1-§15 depends on this section. A record, an export or a verifier that never mentions it is r8.1a exactly as before. "Conformant to r8.1a" means §1-§15 and nothing in §15A; "operates weio-ed25519-only/1" is a second, separate claim, and the two MUST NOT be merged into one.
  • Profiles are invisible to verifiers. No event member, export member, code or exit status names the profile. A verifier's rules, codes and output are r8's for every record: it applies §10 to a profile record exactly as to any other (this restates r8 and adds no verifier rule). Where a profile rule cannot be seen in the signed bytes (an email_token event that is correctly signed, equal identifiers with different salts, a key the other role holds), the verifier ACCEPTs or REJECTs exactly as §10 says, and the profile is enforced by the server before it appends. §12 already holds server refusals that no verifier can check (the online token check, V50); this section only names more refusals.
  • Who is bound. This section binds the servers that write records under the profile and the operator that publishes them. It does not bind a verifier, a mirror or a record written elsewhere. The verifier halves of the profile fixtures (15A.10) use r8 rules only; §15 lists them with the other verify fixtures, and their server halves bind no verifier.
  • Purpose. Every P and C event is authorised by the party's own Ed25519 key and no email is sent, collected or stored. No signed byte, schema member or verifier rule changes.

#15A.1 The identifier

  • Each party's contact identifier is exactly the ASCII string k-<key32>@registry.weio.invalid, where <key32> is key_id(agent_key) of that party's agent_key (§2.3: the first 16 bytes of SHA-256(DT("key") ‖ the 32-byte key), written as 32 lowercase hex characters, [0-9a-f]{32}). As a pattern, matched over the whole string: k-[0-9a-f]{32}@registry\.weio\.invalid. It is 56 characters long. (r8's key_id is 16 bytes written as 32 hex characters, the same form as every hex32 in §4; no other form is used.) .invalid is reserved by RFC 2606, so the string cannot name a mailbox.
  • The server derives the identifier from the party's agent_key. It never reads an identifier from a request. It accepts no other form: no upper case, no other prefix, no other domain, no name, no address.
  • The §5.8 normalize function leaves a string of this form unchanged. The server still applies it before it compares or hashes, so that one rule covers all forms.

#15A.2 Digest, salts and the equal-identifier check

  • The party digest is §5.8 unchanged: hex(HMAC-SHA256(k_rec_role, UTF-8(normalize(identifier)))), with a 32-byte k_rec_P and k_rec_C, one per record and per role, each from a CSPRNG (§12), kept only in the deletable table. No domain tag is added. note_digest is unchanged. (The salts in the fixtures are derived from a seed; that is test data, not a rule for how to draw one.)
  • Order of work for a COMMIT. (1) Derive each role's identifier from that role's proven key (15A.3). (2) Compare the two normalized identifiers. If they are equal, REFUSE with PROFILE_ID_EQUAL and stop: no salt is drawn, no digest is computed and no table row is stored. (3) Only then draw the two salts and compute the two digests.
  • Why before hashing. Two roles with one identifier get two different digests from their two salts (fixtures P1-d2 and P1-d3), so the COMMIT_SELF_DEALING check of §5.3 step 1, which compares digests, cannot see the collision, and neither can any verifier (fixture P1-9 is ACCEPT). Only the server, which holds the identifiers, can catch it, and only before it hashes. Because the identifier is a function of the key, equal identifiers mean equal key_id and therefore the same agent_key, apart from a 128-bit collision.

#15A.3 Key possession

  • A key proof is evidence held by the server that the registry account of a role controls a key and lists it as ACTIVE, made by that account itself (never supplied by the counterparty). The registry operator defines the proof message. It MUST be domain-separated from every §2.1 tag, in particular from DT("sig-agent") ‖ H(C(event)). If the registry cannot guarantee that, the registry key MUST NOT be reused as an agent_key; the party then proves a separate agent key in the registry.
  • A COMMIT MUST carry an agent_key for both roles. Otherwise the server REFUSEs it with PROFILE_AGENT_KEY_REQUIRED.
  • A role's agent_key is accepted only from that role's own key proof, for a key that is ACTIVE when the COMMIT is written. If the key was proven by the other role, by no one, by an account that is not the role's, or is not ACTIVE, the server REFUSEs the COMMIT with PROFILE_KEY_UNPROVEN. A proposer therefore cannot name a key it holds as the counterparty's key (the case that §5.2 step 7 cannot see: fixture P1-8).
  • For every later event written for a role, the server MUST check that the request comes from that role's account. A party writing the other role's step is REFUSEd with PROFILE_WRONG_ROLE. This is in addition to S_a (§5.2 step 7), which shows only that some holder of the key signed.
  • This version defines no rule for a later P or C event signed by a key that the registry has revoked or retired after the COMMIT.
  • Limit: this cannot detect one controller that holds two registry records and so two keys.

#15A.4 Authorisation: ed25519 only, email_token refused

  • Every P and C event MUST use auth.method ed25519, with S_a under the role's committed agent_key. ERASE keeps server (§5.7).
  • The profile server sends no email, issues no email token and keeps no mailbox. A P or C event written with auth.method email_token is REFUSEd with PROFILE_EMAIL_TOKEN. (§12 already requires a token issued by this server; none can be.)
  • This is a server rule, not verifier proof. A verifier cannot see the profile and ACCEPTs a correctly signed email_token event (fixture P1-10). A valid record therefore does not show that no email was used; the evidence of how the profile was run is the operator's published statement, not a verifier result.

#15A.5 The agent key is fixed per record; no fallback

  • The agent_key of a role is fixed by the COMMIT. r8 has no event that changes it. The server MUST NOT offer any way to change, add or recover one for an existing record. A request to do so is REFUSEd with PROFILE_KEY_FIXED.
  • A role that loses its key cannot write again. It cannot ACCEPT or REJECT. The other role can WITHDRAW where §5.3 allows it (never after an ACCEPT) and start a new record.
  • There is no email fallback: the server MUST NOT accept an email-based recovery, an email-confirmed write or an address given later.

#15A.6 The plaintext rule (one rule, bound across §5.7, §5.8, §8.4, §10 step 13 and §12)

Chosen rule: plaintext, when present, carries the identifier. The plaintext member stays OPTIONAL as in §8.4. When a profile server includes it for a role, email is exactly that role's identifier (15A.1) and k_rec is that role's salt. A server MUST NOT put a mailbox address anywhere: not in the deletable table, not in plaintext, not in a request it stores. A profile server MUST NOT include plaintext in an export unless the requester asked for it, and MUST NOT include it for an erased role (§5.7).

Why this rule and not "omit plaintext": (a) a requester who asks for it can check that each digest is the HMAC of the identifier derived from that role's key, which is the one binding between the digest and the key that an opaque digest cannot give (fixtures P1-1, P1-5). The check has two parts: the unchanged §10 step 13 checks the digest against plaintext.email, and the requester compares plaintext.email with k-<key_id(agent_key)>@registry.weio.invalid for that role's committed agent_key. The second part is not a §10 step and no verifier reports it: step 13 also passes when plaintext.email is some other string whose HMAC under k_rec is the committed digest; (b) it needs no verifier change and no new code; (c) it is the wording both coordinators adopted. The cost is the one §8.4 already states: the holder of such an export holds the role's salt and can test note_digest values by guessing and link the digest. That is why the default export omits plaintext (fixture P1-2). The identifier itself adds nothing to that exposure, because anyone can derive it from the public agent_key; what the salt protects in such an export is the notes.

r8 section In this profile
§5.7 Erasure"Address" means the identifier row in the deletable table. ERASE deletes that row, the notes and k_rec_role; fields: email names the identifier row. The agent_key, the key_id and the digest stay in the signed record. The PARTY_ERASED text is r8's and says "address"; here that word means the identifier row, and the operator's page SHOULD say so.
§5.8 Party digestsThe HMAC input is the identifier (15A.1). V50 compares identifiers, before hashing (15A.2).
§8.4 plaintextplaintext.email is the identifier, never a mailbox; default exports omit plaintext (above).
§10 step 13Unchanged. For each role that is not erased: claimed in plaintext.claimed and no data → REJECT PLAINTEXT_MISSING (§5.7; fixture P1-11); not claimed and no data, or no plaintext member at all → status PLAINTEXT_UNAVAILABLE (fixture P1-2); data present → checked as in §10 step 13 (form, then the COMMIT digest). For an erased role: data present or role claimed → REJECT PLAINTEXT_AFTER_ERASE (fixture P1-7), otherwise only PARTY_ERASED.
§12 Server rulesThe first bullet (online check of email tokens) never applies, because no token is issued; the email_token rule is 15A.4. The V50 bullet applies to identifiers. "Store addresses" reads "store identifiers". The ERASE bullet deletes the identifier row, the notes and k_rec_role. Every other bullet is unchanged.

#15A.7 Server REFUSE codes

These codes are returned by a server to a writer. They are not verifier codes, they never appear in a verifier report and they are not in Appendix A. The first refusal ends the request. r8's own server refusals (§5.2 and §12, for example R3_ERASED_ACTOR) still apply.

Code When Fixture
PROFILE_AGENT_KEY_REQUIREDa COMMIT party has no agent_keynone
PROFILE_KEY_UNPROVENa role's agent_key is not that role's own proof of an ACTIVE registry keyP1-8
PROFILE_ID_EQUALthe two normalized identifiers are equal, checked before any salt or digestP1-9
PROFILE_WRONG_ROLEa party writes the other role's stepnone (the record cannot show who made the request)
PROFILE_EMAIL_TOKENa P or C event with auth.method email_tokenP1-10
PROFILE_KEY_FIXEDa request to change, add or recover an agent keynone (no wire event)

#15A.8 What the profile does not claim

The profile asserts none of the following, and an operator's text MUST NOT assert it:

  • That the digest hides which registry record a party is. COMMIT keeps the public agent_key; anyone derives the identifier and the key_id from it and can look for the registry record by key_id. This holds after an ERASE and in every export made before it.
  • That erasure unlinks anything. ERASE deletes the identifier row, the notes and the salt; it does not touch the agent_key, the digest or the events, and no verifier can observe the deletion (§5.7).
  • Identity. A signature shows which key signed, not who holds it; registry records are self-described. A party or an agent is never called "verified" in this profile's text or output.
  • That the two parties are unrelated. One controller can hold two registry records (15A.3).
  • That no email was used (15A.4).
  • That the digest carries information. It is the HMAC of a string derived from a public key; it satisfies the required digest member and the P ≠ C check and nothing more.

Permitted public wording: "parties authorise every step with their own Ed25519 key; the operator sends no email in this profile".

#15A.9 Conditions: all seven are required

An operator runs weio-ed25519-only/1 only when all seven conditions below hold. Fewer is not this profile.

# Condition Where it is met
1The digest input is the contact identifier of 15A.115A.1, 15A.2, 15A.6
2Positive and refusal fixturesP1-1 to P1-11, P1-d1 to P1-d3 (15A.10)
3The registry proof message is domain-separated from DT("sig-agent") ‖ H(C(event))15A.3
4Agent key fixed per record, no rotation, no email fallback15A.5
5Every Handoff party is a registry record, so the one-controller limit of 15A.3 applies to registry records15A.3
6Public wording: no "verified" party or agent15A.8
7Possession of each committed key: a role's key comes only from that role's own proof; the identifier is derived from it15A.1, 15A.3

#15A.10 Profile fixtures

Fixtures with the member "profile": "weio-ed25519-only/1" are verify (P1-1 to P1-11) and digest (P1-d1 to P1-d3) fixtures. They are listed in a separate part of fixtures/INDEX.md. The runner runs them like any other fixture and reports base and profile counts separately (§15). Their verifier half is r8 and is held to the same bar as every verify fixture. The extra members expect.server, expect.server_code and expect.server_refused_seq state what a profile server does with the first event it refuses; they bind a profile server, they are not executed by the fixture runner, and no verifier can reproduce them.

Fixture Case Verifier (r8) Server (profile)
P1-1COMMIT, CONFIRM, DELIVER, ACCEPT; export carries plaintext = identifierACCEPTaccept
P1-2the same record, default export without plaintextACCEPTaccept
P1-3AMEND and CONFIRM before DELIVER and ACCEPTACCEPTaccept
P1-4ACCEPT, then REVERSE and CONFIRMACCEPT (state REVERSED)accept
P1-5ACCEPT, ERASE of C, then P writes a DISPUTEACCEPT (C reported erased)accept
P1-6ERASE of C, then C writes a DISPUTEREJECT R3_ERASED_ACTORREFUSE R3_ERASED_ACTOR
P1-7P1-5 export with C's plaintext put backREJECT PLAINTEXT_AFTER_ERASEmust not export it
P1-8P names its own key as C's agent_keyACCEPTREFUSE PROFILE_KEY_UNPROVEN
P1-9both roles commit one agent_key, two salts; premise: each role's own proof of that key passes (else the first refusal is PROFILE_KEY_UNPROVEN)ACCEPTREFUSE PROFILE_ID_EQUAL
P1-10C's CONFIRM written with email_tokenACCEPTREFUSE PROFILE_EMAIL_TOKEN
P1-11the P1-1 record, C not erased, export with plaintext {claimed: ["C"]} and no plaintext.c (R81-N1)REJECT PLAINTEXT_MISSINGn/a (r8 case)
P1-d1digest of P's identifier in P1-1digestn/a
P1-d2, P1-d3one identifier (P1-9) under two salts: two different digestsdigestn/a

#17. Limits of what a valid record shows (non-normative)

  • Integrity is relative to what the verifier holds: with no known heads and no mirror, a rewrite by the log operator before any head the verifier has seen cannot be detected (V4 is ACCEPT without an earlier head). The CLI prints "no witness supplied". Consistency is claimed only with linked heads (§9.3).
  • Events after the last head are unanchored, not yet provable.
  • Completeness is relative to the signed tip map (§6.5): a dishonest map can hide a later event from a recipient who holds only earlier events.
  • Times are declared. Inclusion in a head is proven; a wall-clock bound comes only from an independent observation of that head, which the verifier does not see (§11.2).
  • The first key's trust anchor is social (the published key history and its mirror). Nothing here is certified. The key history is a trusted input; its rules check it but do not make an untrusted file trustworthy (§7.6).
  • Parties are self-described, not verified. Mixed-script names get a display warning. A signature shows which key signed, not which person.
  • Erasure: an ERASE shows that the server recorded an erasure. The role's digest, any party name and the ERASE stay in the signed chain. Erasure requires removal of the address and k_rec_role from the stores the server's retention policy covers (which a verifier cannot confirm); it does not establish unlinkability. Retained signed identifiers (the digest, any party name, agent keys) and earlier exports can still identify or link the role, for whoever holds them (§5.7, §8.4). (r8, F4.)

#Appendix A. Codes

Canonical form (§3): LIMIT_SIZE JSON_BOM JSON_BAD_UTF8 JSON_SYNTAX JSON_NAN_INF JSON_NULL JSON_CONTROL_CHAR JSON_DUP_KEY NUM_NOT_INTEGER NUM_NEG_ZERO NUM_RANGE STR_LONE_SURROGATE KEY_FORM LIMIT_STRING STR_BANNED_CHAR STR_NOT_NFC LIMIT_DEPTH LIMIT_MEMBERS LIMIT_ITEMS. Ed25519 (§2.2, inside messages; the ed25519 fixtures' codes): ED_BAD_LENGTH ED_NONCANONICAL_A ED_NOT_ON_CURVE_A ED_SMALL_ORDER_A ED_TORSION_A ED_NONCANONICAL_R ED_NOT_ON_CURVE_R ED_SMALL_ORDER_R ED_TORSION_R ED_S_NOT_REDUCED ED_BAD_SIGNATURE. Events (§4): FIELD_FORM FIELD_MISSING FIELD_UNKNOWN FORMAT TIME_FORM AUTH_METHOD AUTH_FORM SIGS_FORM LIMIT_SIGS SIG_W_MISSING SIG_W_KEY_ID SIG_W_UNKNOWN_KEY SIG_W_INVALID. Rules (§5): R1_SEQ R1_FIRST_NOT_COMMIT CHAIN_GENESIS CHAIN_PREV_HASH RECORD_ID_MISMATCH R2_ACTOR R3_ERASED_ACTOR R5_TIME_ORDER COMMIT_BAD_AGENT_KEY R4_NO_AGENT_KEY R4_AGENT_KEY_MISMATCH R4_SIG_A_MISSING R4_SIG_A_INVALID R4_SIG_A_UNEXPECTED R4_NONCE_REUSED R4_PARTY_DIGEST R4_TOKEN_EXPIRED COMMIT_SELF_DEALING G1_TERMINAL CONFIRM_NOTHING_PENDING CONFIRM_WHILE_DELIVERED CONFIRM_SELF CONFIRM_REF CONFIRM_TERMS CONFIRM_LATE G1_ACCEPTED_CLOSED G1_WRONG_ROLE G1_NOT_AGREED DELIVER_TERMS DELIVER_PENDING_AMEND REF_NOT_LATEST_DELIVER AMEND_SAME_TERMS REVERSE_NO_ACCEPT REVERSE_REF REVERSE_TERMS DISPUTE_NO_DELIVER DISPUTE_REF R6_ILLEGAL. Proofs, heads (§6): PROOF_INDEX PROOF_INCLUSION PROOF_CONSISTENCY PROOF_TOO_LONG PROOF_MALFORMED TIP_FORM TIP_HEAD TIP_WRONG_RECORD TIP_ABSENCE HEAD_FORM HEAD_KEY_WINDOW HEAD_UNKNOWN_KEY HEAD_SIG_INVALID HEAD_SIG_MISSING HEAD_NEXT_KEY. Export and verification (§8-§10), reject level: EXPORT_FORM LIMIT_EVENTS HEAD_DUPLICATE HEAD_CHAIN_GAP HEAD_CONSISTENCY_UNPROVEN INCLUSION_FORM INCLUSION_HEAD_UNKNOWN ANCHOR_ORDER LOG_ORDER FIRST_INCLUSION_UNPROVEN EVENT_KEY_WINDOW TIME_AFTER_HEAD TIP_ABSENT_BUT_ANCHORED TIP_BEHIND TIP_MISSING_PROOF TIP_MISMATCH PLAINTEXT_FORM PLAINTEXT_MISSING PLAINTEXT_AFTER_ERASE PLAINTEXT_DIGEST (plus every code above). Detect level, with the class each code always carries (§11.1):

  • conflict: HEAD_EQUIVOCATION HEAD_FORK_SAME_SIZE HEAD_SIZE_DECREASE HEAD_PREV_LINK HEAD_DATE HEAD_TIME_ORDER TIP_BACKWARDS TIP_FORK;
  • freshness: HEAD_ROLLBACK;
  • completeness: TIP_AHEAD_TRUNCATED (r8, F1; r7 listed it under conflict);
  • anchoring: HEAD_UNANCHORED EVENT_KEY_REVOKED KEY_COMPROMISED_UNWITNESSED CONFIRM_ANCHOR_LATE. Error level (result ERROR, exit 3): USAGE LOCAL_CAP KEYS_FORM KEYS_BAD_KEY KEYS_ENDORSEMENT KEYS_OVERLAP WITNESS_UNPROVEN. Warning: KEYS_EXPORT_DIFFERS KEY_ANNOUNCEMENT_UNSEEN WITNESS_HEAD_IGNORED WITNESS_TIP_IGNORED WITNESS_UNLINKED NO_WITNESS NO_HEADS NAME_MIXED_SCRIPT. Info: UNANCHORED_EVENTS KNOWN_HEADS_UPDATED KNOWN_HEADS_NOT_UPDATED. Status: PARTY_ERASED PLAINTEXT_UNAVAILABLE RECORD_STATE. Removed in r7 (r6 detect codes): HEAD_GAP (now HEAD_CHAIN_GAP in the export, WITNESS_UNPROVEN in the mirror) and HEAD_CONSISTENCY (now HEAD_CONSISTENCY_UNPROVEN in the export, WITNESS_UNPROVEN in the mirror); PLAINTEXT_MISSING moved from detect to reject. Moved in r8 (F1): TIP_AHEAD_TRUNCATED from class conflict to class completeness (same level and result).

#Appendix B. References

RFC 2119, RFC 8174 (requirement words); RFC 8259 (JSON); RFC 7493 (I-JSON); RFC 8785 (JCS); RFC 8032 (Ed25519); RFC 2104 (HMAC); FIPS 180-4 (SHA-256); RFC 9162 (Certificate Transparency 2.0, §2.1 Merkle trees); Unicode UAX #15 (NFC).

Source: handoff-v0-r8.1a.txt · SHA-256 bf232aef9b831f848135c072d1f44625d81ba6de828b85abbb0d7693c558021a

Back to the documents