# Handoff v0: completion-record protocol 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 string | `JSON_SYNTAX` | | The literals `NaN`, `Infinity`, `-Infinity` | `JSON_NAN_INF` | | The literal `null`, anywhere | `JSON_NULL` (§3.4) | | An unescaped character U+0000-U+001F inside a string | `JSON_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 `-0` | `NUM_NEG_ZERO` | | An integer with |n| > 2^53 − 1 (9007199254740991) | `NUM_RANGE` | | A `\uD800-\uDBFF` escape not followed by a `\uDC00-\uDFFF` escape, or a lone `\uDC00-\uDFFF` escape | `STR_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 NFC | `STR_NOT_NFC` | | An object or array that would be at depth > the context's max depth | `LIMIT_DEPTH` | | An object with more members than the context allows | `LIMIT_MEMBERS` | | An array with more items than the context allows | `LIMIT_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) | 8 | 64 | 64 | 4,096 | | EXPORT: a whole export file | 16,777,216 (as received) | 11 | 64 | 1,000 | 4,096 | | SIDE: keys.json, one known-heads line, one mirror head file | 1,048,576 (as received) | 8 | 64 | 1,000 | 4,096 | Further protocol limits: | Item | Limit | Code | |---|---|---| | Events per record (per export) | ≤ 1,000 | `LIMIT_ITEMS` at parse (the events array), else `LIMIT_EVENTS` | | Signatures per event | exactly one S_w plus at most one S_a | `LIMIT_SIGS` / `SIG_W_MISSING` | | Signatures per head | 1 to 4, distinct key_id | `HEAD_FORM` | | Proof path length (inclusion, consistency, tip) | ≤ 64 nodes, checked before any hashing | `PROOF_TOO_LONG` | | Party name | 1 to 80 Unicode code points | `FIELD_FORM` | | DELIVER evidence items | 1 to 16 | `FIELD_FORM` | | Integers | |n| ≤ 2^53 − 1; money, hashes and ids are strings | `NUM_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 | |---|---|---| | `format` | string | exactly `"handoff/v0"` (else `FORMAT`) | | `record_id` | hex32 | §4.4 | | `seq` | int ≥ 0 | 0-based position in the record | | `prev_hash` | hex64 | §4.6 | | `type` | string | one of `COMMIT CONFIRM DELIVER ACCEPT REJECT AMEND REVERSE DISPUTE WITHDRAW ERASE` | | `actor` | string | `P`, `C` or `W` (W = the log server; ERASE only) | | `at` | time | self-declared time of the event (E2: proves nothing by itself) | | `body` | object | §4.3, by type | | `auth` | object | §4.2 | | `server_key_id` | hex32 | key_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 | |---|---|---|---| | COMMIT | `terms_digest`, `parties` (+pd) | `confirm_by`, `oasf_ref` | terms_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) | | CONFIRM | `ref`, `terms_digest` (+pd) | — | ref hex64, terms_digest hex64 | | DELIVER | `terms_digest`, `evidence` (+pd) | `note_digest` | evidence = array of 1-16 items, each exactly `{kind, value}` (§4.3.1) | | ACCEPT, REJECT, DISPUTE | `ref` (+pd) | `note_digest` | ref hex64 | | AMEND | `terms_digest` (+pd) | `note_digest` | terms_digest hex64 (every `note_digest` in this table is hex64) | | REVERSE | `ref`, `terms_digest` (+pd) | `note_digest` | terms_digest = the current confirmed terms (fill; §5.6) | | WITHDRAW | (+pd) | `note_digest` | | | ERASE | `role`, `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_sha256` | hex64 | | `note_digest` | hex64 | | `link` | string starting with `https://`, at most 2,048 code points | | `payment_ref` | string 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_method | `auth.method` | flip breaks S_w (V18) | | email token nonce | `auth.token_nonce` | reuse within a record → REJECT (V20) | | token expiry | `auth.token_expires` | `at` > expiry → REJECT (V23) | | agent key binding | `auth.agent_key_id` = key_id(committed K_a) | wrong key → REJECT (V16) | | party binding | `body.party_digest` = the role's COMMIT digest | other address → REJECT (V21) | | record binding | `record_id` (and the genesis, §4.6) | other record → REJECT (V9, V22) | | position | `seq`, `prev_hash` | reorder, drop, duplicate → REJECT (V5-V7) | | server key | `server_key_id` | must equal S_w's key_id (`SIG_W_KEY_ID`) | | actor and role | `actor` | G1 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 | |---|---|---| | none | COMMIT (P or C) | PROPOSED, pending COMMIT | | PROPOSED | CONFIRM (other role) | AGREED | | PROPOSED, AGREED, DELIVERED, REJECTED (never after an ACCEPT) | WITHDRAW (P or C) | WITHDRAWN | | AGREED, REJECTED | DELIVER (P), current terms, no pending AMEND | DELIVERED | | DELIVERED | ACCEPT / REJECT (C), ref = latest DELIVER | ACCEPTED / REJECTED | | AGREED, DELIVERED, REJECTED | AMEND (P or C), new terms | same, pending AMEND | | AGREED, REJECTED | CONFIRM of pending AMEND (other role) | AGREED, new terms | | DELIVERED | CONFIRM of pending AMEND | REJECT (decide the delivery first) | | ACCEPTED | REVERSE (P or C), ref = the ACCEPT | ACCEPTED, pending REVERSE | | ACCEPTED | CONFIRM of pending REVERSE (other role) | REVERSED | | DELIVERED, REJECTED, ACCEPTED, REVERSED; WITHDRAWN only if ≥ 1 DELIVER | DISPUTE (P or C) | same, disputed + 1 | | any | ERASE (W) | same, erased ∪ {role} | | AGREED (even after an earlier delivery) | DISPUTE | REJECT (the adopted table omits AGREED) | | REVERSED, WITHDRAWN | anything but DISPUTE or ERASE | REJECT (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. 0. 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`. 1. Form as §7.1 and key_id = key_id(pubkey) → else `KEYS_FORM`; pubkey passes §2.2 steps 1-3 → else `KEYS_BAD_KEY`. 2. 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`. 3. 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`. 4. 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. 5. 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 | |---|---|---|---| | `format` | REQUIRED | string | `"handoff/v0"` (the version discriminator) | | `kind` | REQUIRED | string | `"record_export"` | | `record_id` | REQUIRED | hex32 | the record | | `events` | REQUIRED | array of entries (§8.2) | 1-1,000, in seq order | | `heads` | REQUIRED | array of head entries (§6.8) | possibly empty; strictly increasing n | | `tip` | OPTIONAL | object (§8.3) | REQUIRED when `heads` is non-empty; absent when empty | | `keys` | OPTIONAL | keys.json document | an untrusted copy for convenience | | `plaintext` | OPTIONAL | object (§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. 0. **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. 1. **Parse** the export under EXPORT (§3) → on failure REJECT with the §3 code; stop. 2. **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). 3. **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`. 4. **Witness heads**: load known-heads lines (allowing `tip`) and mirror files per §9.3 (failures → warning `WITNESS_HEAD_IGNORED`). 5. **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. 6. **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.) 7. **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`. 8. **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.** 9. **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`. 10. **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`. 11. **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`. 12. **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). 13. **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 | |---|---|---| | ACCEPT | 0 | VERIFIED: 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) | | REJECT | 1 | the export is internally invalid or incomplete (including a missing or failing proof) | | DETECT | 2 | internally valid, but an authenticated conflict, a freshness failure, a completeness failure or an anchoring failure (the `class` of each detect finding says which) | | ERROR | 3 | usage, 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-@registry.weio.invalid`, where `` 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-@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 digests | The HMAC input is the identifier (15A.1). V50 compares identifiers, before hashing (15A.2). | | §8.4 plaintext | `plaintext.email` is the identifier, never a mailbox; default exports omit `plaintext` (above). | | §10 step 13 | Unchanged. 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 rules | The 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_REQUIRED` | a COMMIT party has no `agent_key` | none | | `PROFILE_KEY_UNPROVEN` | a role's `agent_key` is not that role's own proof of an ACTIVE registry key | P1-8 | | `PROFILE_ID_EQUAL` | the two normalized identifiers are equal, checked before any salt or digest | P1-9 | | `PROFILE_WRONG_ROLE` | a party writes the other role's step | none (the record cannot show who made the request) | | `PROFILE_EMAIL_TOKEN` | a P or C event with `auth.method` `email_token` | P1-10 | | `PROFILE_KEY_FIXED` | a request to change, add or recover an agent key | none (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 | |---|---|---| | 1 | The digest input is the contact identifier of 15A.1 | 15A.1, 15A.2, 15A.6 | | 2 | Positive and refusal fixtures | P1-1 to P1-11, P1-d1 to P1-d3 (15A.10) | | 3 | The registry proof message is domain-separated from `DT("sig-agent")` ‖ H(C(event)) | 15A.3 | | 4 | Agent key fixed per record, no rotation, no email fallback | 15A.5 | | 5 | Every Handoff party is a registry record, so the one-controller limit of 15A.3 applies to registry records | 15A.3 | | 6 | Public wording: no "verified" party or agent | 15A.8 | | 7 | Possession of each committed key: a role's key comes only from that role's own proof; the identifier is derived from it | 15A.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-1 | COMMIT, CONFIRM, DELIVER, ACCEPT; export carries plaintext = identifier | ACCEPT | accept | | P1-2 | the same record, default export without plaintext | ACCEPT | accept | | P1-3 | AMEND and CONFIRM before DELIVER and ACCEPT | ACCEPT | accept | | P1-4 | ACCEPT, then REVERSE and CONFIRM | ACCEPT (state REVERSED) | accept | | P1-5 | ACCEPT, ERASE of C, then P writes a DISPUTE | ACCEPT (C reported erased) | accept | | P1-6 | ERASE of C, then C writes a DISPUTE | REJECT `R3_ERASED_ACTOR` | REFUSE `R3_ERASED_ACTOR` | | P1-7 | P1-5 export with C's plaintext put back | REJECT `PLAINTEXT_AFTER_ERASE` | must not export it | | P1-8 | P names its own key as C's `agent_key` | ACCEPT | REFUSE `PROFILE_KEY_UNPROVEN` | | P1-9 | both 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`) | ACCEPT | REFUSE `PROFILE_ID_EQUAL` | | P1-10 | C's CONFIRM written with `email_token` | ACCEPT | REFUSE `PROFILE_EMAIL_TOKEN` | | P1-11 | the P1-1 record, C not erased, export with `plaintext` {`claimed`: ["C"]} and no `plaintext.c` (R81-N1) | REJECT `PLAINTEXT_MISSING` | n/a (r8 case) | | P1-d1 | digest of P's identifier in P1-1 | digest | n/a | | P1-d2, P1-d3 | one identifier (P1-9) under two salts: two different digests | digest | n/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).