Beta Pre-1.0 — the file-naming convention may still change. v1.0 will freeze it. ZDDC home · All specifications

ZDDC - Custody Extension ~1.0
Spec Decisions Appendix — every wire-level choice the memo did not pin
Status: Non-normative appendix

Status: REVIEWED AND CLOSED 2026-08-12. Each entry is a place where federation-and-custody.md was silent, ambiguous, or (twice) self-contradictory, and where writing an implementable spec forced a choice. Every choice made here is the boring, JSON-canonical, greppable option per the Stage 3 brief.

Review outcome (maintainer, 2026-08-12):

D-10 (⚠ was blocking) RESOLVED — the old root's proof of continuity is signatures[1] of the same DSSE envelope; there is no old_sig payload field.
D-12 (⚠ was blocking) RESOLVED — two fields with different lifecycles; "lives once" is the operator guarantee. The memo's §2e sentence carries a dated correction.
The other 20 ACCEPTED as written.

Two follow-ons were queued outside this spec set. One is now closed:

  • D-19 — VALIDATED AND CLOSED 2026-08-12. The Ed25519 verification-semantics clause ([M-E10]) was run against both reference implementations over the Wycheproof EdDSA corpus and rewritten from the result; the implementations agreed on every case, so the provisional marking is lifted and the spec set no longer carries an exception to its approved status. Details in the D-19 entry below.
  • D-13 — PENDING RENAME. Renaming the shipped peers-row emails: key to forwards: is a code change and lands separately. Nothing in these specs depends on which name wins; the party-row emails described in keys.md is unaffected either way.

Second pass, 2026-08-13 — what building two implementations found. Stage 4 slice 1 built a Go verifier (zddc/internal/custody/) and a browser verifier (shared/custody*.js) independently, from this prose alone, and both now pass the conformance suite. They CONVERGED on every reading, which is the result that matters: where the spec was ambiguous, there was usually only one workable answer and both found it. That convergence is also what makes the entries below mostly documentation of settled behaviour rather than new design.

Two substantive decisions were referred back and approved (D-23, D-24); one unimplementable requirement was re-framed (D-25); the rest are defect fixes, recorded together in "Defects found by implementing" at the end of this file.

Third pass, same day — the divergence map, closed. Converging on every reading is not the same as agreeing on every input, so the implementations were diffed on behaviour rather than on reports: 2 853 mutation probes, which found twelve places they could still differ. All twelve are now closed, along with five more the fixes surfaced, and the map was re-run — 4 424 probes, zero disagreements. Three of the closures were normative choices rather than defect fixes and have their own entries: D-26 ([M-C11], typing), D-27 ([M-E23], ordering) and D-28 ([M-C10], lone surrogates — the one place the map's own stated premise was wrong, and where following it would have made both verifiers violate RFC 8785). The full method, table and residual are in "Where the two implementations can still differ" below.

Format below: what the memo says → what the spec chose → why → cost of reversing, with the review verdict on each.


D-1 — Merkle chunk size = 1 MiB

Memo: "fixed-size chunk leaves" (§6.3, §10.2) — the size is never named. Spec: C = 1 048 576 bytes ([M-M1]). Why: big enough that a 1 GB drawing set is ~1024 leaves (a 10-element proof), small enough that a browser holds one chunk plus the hash state at constant memory. A power of two keeps the tree arithmetic exact. Reversal: one constant + regenerate merkle/ vectors. Changing it after Stage 4 ships is a payloadType version bump, since every root changes.

Review: ACCEPTED as written, 2026-08-12.

D-2 — Tree shape = RFC 6962 §2.1, empty tree = SHA-256("")

Memo: "domain-separated leaf vs interior prefixes — RFC 9162 style" (§6.3). Spec: RFC 6962 §2.1 verbatim with chunks as leaves: L(i) = SHA-256(0x00 ‖ chunk), N = SHA-256(0x01 ‖ l ‖ r), split at the largest power of two strictly less than n, and MTH({}) = SHA-256("") ([M-M2], [M-M3]). Why: RFC 6962 and RFC 9162 share this MTH; 6962 is the one everyone has implemented, and its audit-path definition gives [M-M5] for free. The empty case has to be something and the RFC already says what. Reversal: regenerate vectors; same version-bump cost as D-1.

Review: ACCEPTED as written, 2026-08-12.

D-3 — Digest encoding = lowercase hex everywhere

Memo: "statement id = SHA-256 of the canonical payload bytes" (§6.11) — no encoding. Spec: every digest that appears in JSON or in a filename is 64 lowercase hex characters; uppercase is rejected, not normalized ([M-M4], [M-E11], [M-C5]). Why: it is what peer.Fingerprint already emits, what sha256sum emits, and what greps. Rejecting rather than normalizing keeps canonical form single-valued — if two spellings both verified, the statement id would not be a function of content. Reversal: trivial, but it touches every vector.

Review: ACCEPTED as written, 2026-08-12.

D-4 — Timestamps RFC 3339 UTC seconds; dates YYYY-MM-DD

Memo: ts is an "asserted UTC timestamp" (§6.11); nothing more. Spec: YYYY-MM-DDTHH:MM:SSZ for instants, YYYY-MM-DD for calendar dates, other forms rejected ([M-C6]). Why: the repo's standing rule is that dates are always YYYY-MM-DD and never a locale string, and the calendar-date-vs-instant distinction is a known trap here (a UTC test runner cannot see the bug). Forbidding sub-second precision and offsets removes the only places two producers could disagree on bytes for the same moment. Reversal: trivial.

Review: ACCEPTED as written, 2026-08-12.

D-5 — A critical field for future extensions ⚑ addition, not a transcription

Memo: extensibility is handled entirely by the version in payloadType (§6.11); unknown-field behaviour is never stated. Spec: unknown fields are ignored and stay signature-covered ([M-C7]); a payload MAY carry critical: [names] and a verifier not implementing every named field MUST reject ([M-C8]); v1 producers emit none ([M-C9]). Why: without it, every semantics-changing addition is a whole payloadType version, which strands old verifiers on changes they could safely have refused per-statement. With it, the deferred features (TSA token, completeness head, encryption envelope) have a landing site that fails closed by default. JOSE's crit is the same mechanic and the shape is familiar. Recommendation: keep. It costs one line in a verifier and nothing at all in a v1 producer. Reversal: delete two requirements and two vectors — but only cleanly before anything ships, since a later addition would need its own version.

Review: ACCEPTED as written, 2026-08-12.

D-6 — Canonical payload size ceiling = 16 MiB

Memo: silent. Spec: a verifier MUST reject a canonical payload > 16 777 216 bytes (too_large) and MUST accept anything conforming below it ([M-M13]). Why: a verifier with no ceiling is a memory-exhaustion target on the inbound door, and a ceiling that implementations pick individually is an interop failure waiting to happen (A's package is unverifiable at B). 16 MiB holds a manifest of well over 10 000 entries; beyond that, issue two transmittals. Reversal: one constant. Note the ceiling is not vector-tested (see vectors/README.md §5) — committing a 16 MiB fixture is disproportionate.

Review: ACCEPTED as written, 2026-08-12.

D-7 — The default mailbox is written explicitly

Memo: the default mailbox is default and the mailbox rides the signed manifest (§5c). Spec: mailbox is REQUIRED in every transmittal payload and carries the literal default when it is the default ([M-M12], [M-S11]) — it is never omitted-and-implied. Why: a canonical payload must have exactly one byte form. An optional field with a default value gives it two (present-with-default and absent), which changes the statement id for the same act. Reversal: trivial, but it would reintroduce the two-forms problem, so the alternative is "forbid the literal default", not "make it optional".

Review: ACCEPTED as written, 2026-08-12.

D-8 — payloadType is compared byte-exactly; no media-type parsing

Memo: application/vnd.zddc.<type>+json; v=1 (§6.11). Spec: exact bytes — lowercase, one space after ;, no other parameters ([M-E4]); a media-type parser that treats ;v=1 as equivalent is non-conforming. Why: the string is inside the PAE. If two byte-strings named one type, one of them would have a valid signature that the other's verifier could not reproduce. Reversal: none needed; the alternative (parse the media type) is strictly worse and is the reason alg-confusion happened in JWS.

Review: ACCEPTED as written, 2026-08-12.

D-9 — Three encodings, each following its own format's convention

Memo: silent. Spec: DSSE payload/sig = standard base64 with padding (RFC 4648 §4, the DSSE spec's own encoding, [M-E2]); raw key bytes in registry rows and key files = base64url unpadded (what Stage 1 shipped, peer.go); digests = lowercase hex (D-3). Why: each is the convention of the format it appears in. Harmonizing on one would put ZDDC at odds with the DSSE spec (breaking off-the-shelf tooling, which is the whole reason DSSE was chosen) or force a re-encoding of already-shipped registry rows. Recommendation: keep the three; the spec names which is which at every field so nothing is guessed. Reversal: cheap now, expensive after Stage 4.

Review: ACCEPTED as written, 2026-08-12.

D-10 — RESOLVED 2026-08-12 — succession's old_sig is the second envelope signature

Memo: §6.15 — "the statement is signed by the NEW key and carries old_sig — the old root's signature over the same payload". The contradiction: if old_sig is a field inside the payload, the old key must sign a payload that contains its own signature. That is circular and cannot be implemented without inventing a "signing view" (canonicalize with old_sig omitted) that the memo never mentions and that would give the payload two canonical forms — breaking [M-C2] and the content-addressed id. Spec: the old root's signature is signatures[1] of the same DSSE envelope, over the identical PAE ([M-E14]); the payload carries a boolean linked that MUST agree with the signature count ([M-S38]). This is the literal reading of "over the same payload", it is exactly what DSSE's signature array exists for, and it is the only place in v1 where an envelope carries more than one signature ([M-E13]). Review: RESOLVED 2026-08-12 as stated above. The alternative — a defined signing view with old_sig excluded — was rejected: it adds a second canonicalization rule to a spec whose main safety property is that there is only one. What the resolution pinned (envelope.md §5, split into cardinality, order/identity, and no-thresholds):

  • exactly one signature on every type except succession ([M-E13]), even when a second one verifies ([M-E20]);
  • succession carries one or two, and the count MUST agree with the payload's linked flag in both directions ([M-E14]);
  • signatures[0] MUST be the key named in new_root ([M-E15]);
  • signatures[1], when linked, MUST be this party's currently anchored root — not a counterparty's root, not one of its own operational keys ([M-E16]);
  • a reversed array is rejected, never repaired, because accepting either order would let the same two signatures assert succession in both directions and a leaked historical envelope could roll a root backwards ([M-E17]);
  • every signature present MUST verify; no thresholds ([M-E19]);
  • no old_sig payload field exists ([M-S39]); one appearing is an ordinary unknown field, ignored under [M-C7] and consulted for nothing.

Vectors added: envelope/valid/09-succession-linked, 10-succession-linked-rename, 11-succession-break; envelope/invalid/19–23 (reversed, missing second signature, second signature by the counterparty's root, second signature by an operational key, break-with-two-signatures); statements/valid/15-old-sig-field-is-ignored.

D-11 — Key files store raw key bytes, not JWK

Memo: §6.16 — {kind, alg, created, public (JWK), private (JWK | wrapped)}. Spec: public/private are base64url-unpadded raw bytes (the 32-byte Ed25519 public key and the 32-byte seed) ([M-K2]). Why: keyid is already the hash of the raw bytes ([M-E11]) and the shipped peers rows and .zddc.d/keys/link.yaml already store raw bytes. A JWK would be a second encoding of the same key inside one system, and the fingerprint would have to specify which encoding it hashes anyway. Web Crypto imports raw Ed25519 public keys directly and PKCS#8 private keys with a fixed 16-byte prefix — no JWK needed at either end. Reversal: one encode/decode pair; no vector impact (vectors carry raw).

Review: ACCEPTED as written, 2026-08-12.

D-12 — RESOLVED 2026-08-12 — the custody anchor is a party-registry field, distinct from the shipped transport key

Memo, in tension with itself:

  • §2e: "Key material lives ONCE, in the party registry row", and "the same anchored key later verifies the party's signed transmittals — access and trust converge on one keyring."
  • §3 / §10.3 / §10.10.1: the custody anchor is an offline org root that countersigns short-lived operational keys, and counterparties pin that root.
  • Shipped reality (Stage 1 S2): the transport key is an always-online, lazily-minted instance key at .zddc.d/keys/link.yaml, anchored in a second registry, <ZDDC_ROOT>/peers/<name>.yaml, as sign_key.

An offline root cannot be an always-online request signer, and "lives ONCE" is already false in the built system (two registries exist). Spec: two fields with different lifecycles — the party registry row's root_key is the pinned custody root; the peers row's sign_key stays the transport link key; a verifier MUST NOT accept a transport key as a custody signer just because the request arrived over that link ([M-K8], [M-K9]). "One keyring" is preserved at the operator level: one row per relationship, one page, one place to rotate. Review: RESOLVED 2026-08-12 as stated above. The alternative — one key doing both — was rejected: it would require every server to hold its party root online, deleting the compromise containment §10.3 chose the operational-key tier for. What the resolution pinned:

  • keys.md §3.1.1 states the two-lifecycle rule and adds [M-K30]: the two keys MUST NOT be interchanged in either direction — a custody root is never a ZDDC-Sig signer, just as a transport key is never a custody signer ([M-K8]).
  • "Lives once" is recorded as the operator guarantee it was always for: one relationship = one row, one page, one place to rotate or sever. No second key-list construct exists anywhere.
  • The memo's §2e bullet carries a dated CORRECTION note (2026-08-12) reading it as "credentials live in ONE registry row per relationship", in the same style as the §10.2 sha256File correction.

D-13 — The emails name collides across the two registries

Memo: §2c/§3 use "the [emails] allowlist" for both. Spec: on a peers row, emails = which forwarded X-ZDDC-User identities this link may assert (shipped, S6). On a party row, emails = which acting persons may sign as this party ([M-K13]). Same match syntax, different question, deliberately not merged — a site instance forwards people who never sign anything. Recommendation: rename one before Stage 4 — forwards: on the peers row reads best and the shipped field is young. Flagged rather than done here because renaming a shipped key is a code change, and this stage is documents-only. Reversal: n/a — this is a naming request, not a format choice.

Review: ACCEPTED as written, 2026-08-12.

D-14 — No mandatory maximum operational-key lifetime

Memo: "short-lived operational keys" (§3, §10.3) — never quantified. Spec: not_before/not_after are REQUIRED on every add/rotate op ([M-S30]) but the spec sets no ceiling ([M-K26]); root keys have no expiry at all and are replaced by succession. Why: a mandated ceiling strands the offline rung-1 producer who cannot re-certify without a network — which is the constraint the whole trust model exists to satisfy. "Short-lived" is an operator posture; the format enforces the window, the deployment picks its length. Reversal: trivial (add a maximum); doing it later only invalidates keys, never artifacts.

Review: ACCEPTED as written, 2026-08-12.

D-15 — from.party is a key fingerprint; from.person is required for length-2 chains

Memo: from is "the party (+ optional acting person)" (§6.11) — the party's representation is never specified, and §2b says names are local to each side. Spec: from.party / to[].party are the lowercase-hex SHA-256 fingerprints of the parties' anchored root keys; name is an optional informational display string a verifier MUST ignore ([M-S2], [M-S3]). from.person is optional in general but REQUIRED when the signing chain is length 2, because binding an act to a person is the entire purpose of the operational-key tier ([M-K17]). Why: Stage 1 already decided the wire identifies peers by key fingerprint precisely so the two sides never need to agree on a name; the custody layer has the same problem and should not solve it differently. Using names would also mean a rename silently breaks link binding ([M-S9]). Reversal: cheap; it is one field's contents.

Review: ACCEPTED as written, 2026-08-12.

D-16 — The receipt's echo field is named manifest_root

Memo: §6.11.2 — "the received manifest's root hash echoed verbatim". Spec: the field echoes the statement id of the received statement ([M-S17]) and MUST equal re ([M-S18]); it is used for every type, not only transmittals ([M-S20]), so the transmittal-flavoured name is slightly wrong for an anchor-update receipt. Why the id and not a per-file root: a transmittal's id already covers every file's Merkle root (it is the hash of the payload containing them), so echoing the id binds strictly more than echoing any single root, and it is the only value that exists for non-transmittal statements. Recommendation: rename to re_id or acknowledges in review. Kept as manifest_root here only because the memo names it. Reversal: trivial before Stage 4.

Review: ACCEPTED as written, 2026-08-12.

D-17 — validity expanded into not_before / not_after

Memo: [{op, key, validity, compromise_date?}] (§6.11.3). Spec: an explicit pair of RFC 3339 instants, not_before < not_after ([M-S30]). Why: "validity" as one field would need its own sub-format (a duration? a range string?), and every such format has a parsing ambiguity. Two instants have none. Reversal: trivial.

Review: ACCEPTED as written, 2026-08-12.

D-18 — _ledger/ placement and the <seq>-<type>-<id8>.dsse.json name

Memo: §6.11 gives both under an explicit hedge — "Where statements LIVE (leaning — confirm in the lifecycle pass)" — and the lifecycle pass (§6.15) never revisited it. Spec: transcribed as-is, plus the parts the leaning left open: <seq> is a 4-digit zero-padded per-directory counter in chain order, <id8> is the first 8 hex characters of the statement id, and the filename is presentation, never authority — a verifier tolerates any name ([M-S43], [M-S44]). Why the filename cannot matter: two statements could collide on <id8>, a mirror may rename, and a .zip from a foreign implementation may use its own convention. Authority lives in payloadType and the payload hash. Reversal: free — nothing depends on it.

Review: ACCEPTED as written, 2026-08-12.

D-19 — Ed25519 verification semantics ✅ validated against both implementations

Memo: §6.4 flags the "Taming the many EdDSAs" divergence and says "the DSSE profile must fix exact verify semantics" — without fixing them. Spec (as originally written): pinned by agreement between the two reference implementations (Web Crypto/BoringSSL in the tools, Go crypto/ed25519 in the server): reject non-canonical S (S ≥ L), cofactorless verification equation, no additional small-order/non-canonical-R rejection ([M-E10]). Why it was flagged harder than the rest: it was written from knowledge of those implementations, not verified against them, and it is the one clause where being wrong means "an artifact verifies in the browser and not on the server".

The validation run (2026-08-12)

Both implementations were driven over three corpora. Go ran through the repo's containerized toolchain (AGENTS.md § Test); Chromium ran headless via the Playwright harness, over file:// — the same origin the .html tools verify in — using crypto.subtle.importKey('raw', …, {name:'Ed25519'}) and crypto.subtle.verify.

corpus cases Go crypto/ed25519 1.24.13 Chromium 147.0.7727.15 (BoringSSL)
Wycheproof ed25519_test.json (v1 schema, google-wycheproof 0.9rc5) 151 151/151 match the expected result 151/151 match the expected result
ed25519-speccheck (the "many EdDSAs" companion cases) 12 — identical to Go on all 12
ZDDC envelope signature transforms of envelope/valid/01 6 — identical to Go on all 6

Both third-party corpora are now vendored at corpora/, with provenance, licence and pinned SHA-256 in corpora/README.md, and the run above was repeated against those exact committed bytes before they were committed. They are vendored rather than fetched so that [M-R5]'s offline guarantee covers the whole suite with no exception, and so the corpus a run tests against cannot change underneath the contract — the pinned Wycheproof file had in fact been modified upstream two days before it was vendored, by a commit adding cases for x = 0 with the sign bit set.

Disagreements between the two implementations: none, in any of the 169 cases. Chromium's Ed25519 is present and unflagged in the version the repo pins (Playwright 1.59.1 → Chrome for Testing 147; Web Crypto Ed25519 has shipped on by default since Chrome 137), so the browser half is a real result and not a skipped test.

What the corpora actually settle, axis by axis:

  • S ≥ L — Wycheproof's four SignatureMalleability cases (s+L, s+2L, s+4L, s+8L, each unchanged mod L) plus InvalidKtv tcId 85 ("S just above the bound") and speccheck 6–7. Both reject all. The range check is confirmed.
  • Cofactorless vs cofactored — speccheck cases 4 and 5 are the discriminator: recomputing independently, [8S]B = [8]R + [8k]A holds while [S]B = R + [k]A does not. Both reject, so both are cofactorless. This axis is not covered by Wycheproof at all, which is why the speccheck corpus was added rather than assumed.
  • Small-order A — speccheck cases 0 and 1 carry a small-order public key with an otherwise-satisfied equation. Both accept. A verifier that added a small-order-A rejection would diverge, confirming the original clause's "MUST NOT add it".
  • Non-canonical R — Wycheproof's 22 InvalidEncoding cases (built so that a verifier decoding R leniently, or comparing points instead of encodings, would accept) plus speccheck 8–9. Both reject. This is where the original wording was wrong: it said a verifier "MUST NOT add non-canonical-R rejections on top", which reads as though such R is accepted. It is rejected — as a consequence of comparing the encoding of the recomputed R, not via a canonicality check. Neither implementation decodes R as a point at all.

One joint deviation from RFC 8032, recorded because it is real: both accept a public key encoded as y = p − 1 with the x-sign bit set (ecff…ff), which §5.1.3 requires to fail decoding (the only point with that y has x = 0). Isolated by speccheck 10 vs 11 — same key, same signature, different message, one accepted and one rejected, which is only possible if the key decoded. They deviate identically, so the two-implementation contract still holds.

Outcome: the clause is rewritten as a six-rule procedure and the PROVISIONAL marking is LIFTED. Two of the three original assertions survived unchanged (reject S ≥ L; cofactorless equation); the third was corrected as above. Because the implementations agree everywhere tested, the contract guarantees the full behaviour rather than an intersection — ZDDC refuses nothing that one implementation would accept and the other would not, because no such case was found.

Vectors: the expressible axes became envelope/invalid/32–35 (S+L, S+2^255, negated R, S=0), generated by gen.js so the drift guard covers them; every one was measured in both implementations before being committed. The small-order-A and cofactored axes cannot be expressed as ZDDC envelope vectors — they need keys and signatures that no anchored test key can produce — and stay in vectors/README.md §5, pinned instead by the vendored corpora that [M-R12] makes a runner obligation.

Reversal: re-running the corpora is cheap and should be repeated whenever either implementation's floor moves (a Go release, a Chromium bump in package.json) — and, because the corpora are pinned, whenever their pins are refreshed (corpora/README.md gives the procedure: re-fetch, diff, re-run both implementations, adopt deliberately). A future disagreement does not invalidate the artifacts — it narrows the clause to the intersection, and the vectors plus the corpora are what would catch it.

D-20 — The reason-class vocabulary

Memo: silent — it never discusses failure taxonomy. Spec: eleven closed classes (conformance.md §3): malformed, not_canonical, unknown_critical, too_large, bad_path, bad_signature, bad_pae, unknown_key, unsupported_type, bad_proof, bad_link. Why: the conformance contract needs implementations to agree on why they rejected, or "rejects everything" passes the suite. The classes are deliberately coarse — they name the stage that failed, not the field. Note: the classes are for logs, local UI, and the conformance suite. A network surface answers a single undifferentiated refusal ([M-E22]) — a reason class on the wire is an oracle. Reversal: free.

Review: ACCEPTED as written, 2026-08-12.

D-21 — Vector inputs use a pinned byte pattern, not committed multi-MiB blobs

Memo: §6.2 asks for "known-answer test vectors (PAE bytes, manifest canonical form, Merkle hashing)". Spec: merkle/ inputs are described as {"pattern": "zddc-vector-p251", "size": N} where byte i = i mod 251, with small cases additionally carrying literal bytes_b64 (vectors/README.md §2). Why: the chunk-boundary cases need ≥ 2 MiB of input; committing them would add ~10 MB of binary to a repo whose whole premise is greppable plain files. 251, not 256, is load-bearing: 1 MiB is a multiple of 256, so a mod 256 pattern would make every chunk byte-identical and a broken implementation that ignored chunk order would still pass. Reversal: free; a conforming runner can materialize the bytes either way.

Review: ACCEPTED as written, 2026-08-12.

D-22 — Envelope members are closed; payload fields are open

Memo: silent on both. Spec: an unknown envelope member is rejected ([M-E1]); an unknown payload field is ignored and stays signature-covered ([M-C7]). Why: the envelope is the security perimeter — an unrecognized member there means the producer relied on something this verifier does not implement, and the safe answer is refusal. The payload is the extension surface, where deferred features must be able to ride without breaking old verifiers; critical (D-5) is how a payload field opts into fail-closed treatment. Reversal: trivial, but loosening the envelope later is safe while tightening it is not — so it starts tight.

Review: ACCEPTED as written, 2026-08-12.

D-23 — APPROVED 2026-08-13 — succession's signatures[0] is deliberately unanchored

The contradiction, found by both implementations independently: [M-E12] said a verifier MUST NOT verify against a key the artifact supplies and MUST reject an envelope whose keyid matches no anchored key; [M-E15] required signatures[0] to be the key named in the payload's own new_root. For a succession those cannot both hold — the new root is precisely the key the counterparty has not anchored, since anchoring it is what the statement exists to ask for. Resolving index 0 through the anchor state would make every succession unverifiable.

Spec: the exception is stated outright in envelope.md §6 step 7 and cross-referenced from [M-E12] and [M-E15]. Index 0 is verified against the key new_root names and is not required to be anchored. Index 1, when present, is resolved normally and is what carries the authority ([M-E16]).

And what follows is written down rather than left to be discovered: an unlinked succession therefore carries no cryptographic authority at all. One signature, by a key of the signer's choosing, over a payload naming that same key — anyone can mint one naming any party. Its entire assurance is the out-of-band ceremony [M-S40] already requires, and [M-S40] now says so as the whole of the assurance rather than as a layer on top of one. A surface presenting a break succession must present it as a claim awaiting re-verification, never as an authenticated act.

Why the sharp edge is acceptable: it is the same trust event as the original TOFU ([M-K28]), on the same channel, and the honest alternative — refusing break successions outright — would mean a party that loses its root key can never be reached again, which is exactly the lock-in ZDDC exists not to have. The ledger showing an honest break beats a spliced chain.

Reversal: none available. The alternative is not a different rule, it is dropping key-loss recovery.

Review: APPROVED 2026-08-13. Neither implementation changed; both already did exactly this.

D-24 — APPROVED 2026-08-13 — a succession MUST NOT name an already-anchored key ([M-S50])

Memo: silent. Spec (new): new_root MUST NOT name a key already anchored to any party in the verifier's anchor state, as a root key or as an operational key. Violation: malformed.

Why: from.party is the fingerprint of a party's anchored root ([M-S2]), so two parties anchored to one key are one identity on the wire — every from/to, every link binding ([M-S9]) and every signature would stop distinguishing them, and a statement signed by either would verify as the other. Succeeding to a key anchored elsewhere is not a rotation but a merge of two identities, which no counterparty agreed to. The degenerate cases fall out of the same rule: naming your own current root succeeds nothing, and naming your own operational key promotes a certified leaf into the root that certified it — [M-K18] run backwards.

What motivated it: statements/invalid/30 expected a rejection when new_root named the counterparty's anchored root, while claiming to exercise [M-S37] — a rule about agreeing with signatures[0], which a payload-only vector has no signature array to compare against. The Go implementation inferred this rule from the vector and implemented it; the browser implemented a narrower version (a different party's root_key only, compared as base64url text). The vector now names [M-S50], its anchor context is what makes it honestly checkable, and statements/invalid/31 exercises the operational-key half.

Reversal: delete one requirement and two vectors. Doing it later would be worse than doing it never: the collision it prevents is silent.

Review: APPROVED 2026-08-13. Both implementations now enforce the full rule, matching on fingerprint rather than on encoding.

D-25 — DECIDED 2026-08-13 — [M-K14]'s scope is evaluated against the container's location

Memo: scope narrows what an anchor's statements may cover (§3). Spec, as written: "a verifier MUST reject a statement whose manifest paths or consent-withdrawal scope fall outside it."

The defect: that is unimplementable. scope is a path in the verifier's own tree (/100004/) while [M-M9] guarantees manifest paths are relative to the transmittal container (drawings/PLT-100_B_IFR.pdf). Different frames, so a literal prefix test can never match — which is how both implementations left it: written, inert, matching nothing, each with a note saying so.

Spec: scope is evaluated against the container's location, supplied by the verifier's context, and never against manifest paths. A consent-withdrawal's own scope is compared directly, since both are prefixes in one frame. A verifier that has not been told where the container lives does not evaluate the rule and does not report it as satisfied.

Why this rather than re-framing scope as container-relative: it is not a workaround, it is the correct test. Manifest paths are container-relative by construction, so every one of them is inside the container's location: if the container is within scope so is everything in it, and if it is not, no manifest path can rescue it. Testing the container is necessary and sufficient; testing the paths was a category error. Re-framing scope to the container frame would instead have made it unable to express the thing it exists for — "this party's statements are confined to project 100004".

Reversal: cheap; nothing depends on it yet.

Review: DECIDED 2026-08-13 per the recommendation. Both implementations re-framed.

Now vector-covered (same day, second pass). Contexts carry an OPTIONAL container ([M-K16] §5.0), and envelope/valid/12 + envelope/invalid/37 are the same envelope bytes read at two addresses, one inside the anchor's scope and one outside it. Adding the key was worth doing rather than leaving the rule listed as untestable: an inert requirement is indistinguishable from an absent one, and the pass that added the vector found the two implementations had already drifted underneath it — Go evaluated the bound only for the types carrying a manifest (transmittal, response) while the browser evaluated it on the common header for all seven. The bound is on where this party's statements are accepted, so fencing only the manifest-carrying types would have left the link-level acts — the ones that change keys — outside it. Go moved; [M-K14] now says so outright.

D-26 — DECIDED 2026-08-13 — a declared field of the wrong type is a refusal ([M-C11])

Memo: silent. Spec, as written: silent too — [M-C7] said unknown fields are ignored, and nothing said what a named field of the wrong shape costs. Spec (new): a field this document set declares, present with a JSON type it does not declare, MUST be rejected — never read as absent, never coerced, never ignored under [M-C7]. bad_link inside from/to and for prev/re/thread; malformed everywhere else, ts included.

Why a refusal: fail closed is the house rule everywhere else in this profile, and [M-C7] is a forward-compatibility affordance for versions that do not exist yet — a named field of the wrong shape is the opposite situation, where the producer and the verifier disagree about a field they both know, over bytes that are signed. from.name is the sharpest case: a verifier MUST NOT use it in any decision ([M-S2]), which is a rule about identity, not a licence to accept "name": {}.

Why now, rather than "both implementations agreed so leave it": they did not. One read a wrong-typed optional field as absent and accepted; the other refused. And the accepting one was internally inconsistent — it did type-check withdrawal.reason and derived_from[].verbatim — which is the evidence that the ignore behaviour was never a decision, just the shape of whichever accessor was reached for.

Reversal: cheap in the spec, but it would restore four divergences at once (D-S3, D-S4, and half of D-S6/D-S8). Both implementations now carry the rule as one ordered table rather than a check per field, because the failure mode being fixed is forgetting a field, and a table cannot.

D-27 — DECIDED 2026-08-13 — the rule families are fully ordered ([M-E23])

Memo: silent. Spec, as written: [M-E23] pinned two orderings — body before re/thread, and structure before path form inside a manifest entry — because those were the two a vector reached. Spec (new): the body phase runs before the whole common header, the header families run from/to → link binding → scope → ts → prev → chain tip → re/thread, and inside each phase the [M-C11] type checks run as a block before that phase's content rules.

Derived vs chosen, stated because the difference matters to a reviewer: body-first is derived (statements/invalid/37 pins it, and it is rule 1's own reasoning generalized); from/to preceding the rest is derived, because [M-S9] and [M-K14] both read from.party and cannot be judged before it. The sequence scope → ts → prev → chain tip was chosen — no vector implied one, and the two implementations had invented two different answers. The order runs from the outside in: the link, then this statement's place on it, then its place in a thread.

Why order at all, when every one of these is a refusal either way: the reason class is part of the conformance contract (vectors/README.md §1), so two verifiers refusing one payload with two classes is a contract break even though neither accepts it. An unordered rule set makes that inevitable the moment a payload breaks two rules.

Reversal: free in principle; in practice it would re-open D-S8 and leave the class of any doubly-broken payload to whichever check an implementation happens to run first.

D-28 — DECIDED 2026-08-13 — an unpaired surrogate is refused, not escaped ([M-C10])

The premise that turned out to be wrong. The 2026-08-13 divergence map recorded that the browser accepts {"a":"\ud800"} and Go refuses it, and that "RFC 8785 §3.2.2.2 requires unpaired surrogates to be emitted as \udXXX", so Go was the implementation to fix. The RFC says the opposite. §3.2.2.2 ends:

Note: Since invalid Unicode data like "lone surrogates" (e.g., U+DEAD) may lead to interoperability issues including broken signatures, occurrences of such data MUST cause a compliant JCS implementation to terminate with an appropriate error.

Spec: [M-C10] — such a payload has no canonical form and MUST be rejected not_canonical, by both implementations. The class is not malformed: the bytes are legal JSON and legal UTF-8, and "legal JSON with no canonical form" is exactly what not_canonical names for a non-integer number ([M-C3]).

Why this entry exists rather than a line in the defects table: the map's reading was the stated premise of the fix, and it was wrong in a way that would have produced a conforming-looking implementation that violates the RFC — Go preserving lone surrogates through canonicalization, as instructed, would have made both verifiers accept what JCS requires them to refuse. Recorded so the next reader does not re-derive it from the same sentence.

What each implementation was actually doing: Go rejected by accident (its decoder substitutes U+FFFD, and the byte-compare then fails on bytes nobody sent — which is also why ill-formed UTF-8 was mis-classed, see the defects below); the browser's JSON.stringify escapes a lone surrogate and round-trips it happily. This is the one place a conforming JCS implementation must not be JSON.stringify, and the check has to be explicit in both.

Reversal: none available without contradicting the RFC.


D-29 — DECIDED 2026-08-16 — the local record-revision statement (registry entry #8)

What the design memo says (docs/design/record-signing.md, DECIDED 2026-08-16, S2): the server operational key is the first producer against this spec set; a record-revision statement pins {path, revision, sha256(finalBody), previous_sha, principal, elevated, timestamp}; envelopes live at <dir>/.zddc.d/sigs/<stem>/<revision>-<sha8>.dsse.json; S2 "does NOT invent a parallel envelope."

What the spec chose (statements.md §11, [M-S53]–[M-S63]; envelope.md [M-E24]; keys.md §8, [M-K31]) — the wire-level pins the memo left open:

  • An eighth [M-E4] registry entry, not a parallel profile. The envelope, PAE, canonical form and [M-E10] apply byte-for-byte; only signer resolution (a record-signer set, not party anchor state) and step 9 (no common header) substitute. The registry taking an eighth entry cleanly is exactly what conformance.md §5 predicted of it.
  • The common header does not apply, and its fields are UNDECLARED here — a record-revision carrying from/prev has them ignored under [M-C7]. The alternative (declare-and-refuse) would make the local type carry link vocabulary solely to reject it.
  • sha256 is a plain SHA-256 of the stored bytes, not zddc-merkle-v1 — it must equal what the successor revision's previous_sha audit field carries, so the chain and the envelopes speak ONE hash vocabulary and every link is checkable against every envelope. Records are small YAML; streaming verification buys nothing here.
  • previous_sha is REQUIRED, null on the first tracked revision — and deliberately NOT coupled to revision == 1: writeWithHistory's lazy migration can stamp revision: 1 onto a record that HAS a predecessor, so a coupling rule would refuse honest statements about real records. The [M-S5] absent-is-not-null reasoning carries over verbatim.
  • key_kind v1 implements server-operational only; personal-software / personal-webauthn are reserved tokens ([M-S52]'s malformed vs unsupported_type split). Their binding rules (person↔︎key registration) belong to the personal-signing slices and are NOT designed here — spec'ing a registry that does not exist yet is how inert rules get written.
  • The signer entry's kind binds to the payload's key_kind (unknown_key on mismatch): D5's "no UI may render an operational signature as personal" made checkable at the crypto layer instead of remaining a presentation courtesy.
  • Enclave placement, deliberately unlike _ledger/: record-revision envelopes are the server's own local evidence, verified in place or served through mediated surfaces like history — never packaged. [M-S45]'s "skipped by nothing" is about evidence that must travel with an artifact; this evidence is about the server's tree and travels nowhere.
  • No currency axis, no staleness axis in v1: not an issue act, and a record-signer set carries no revocation machinery — the tri-state collapses to valid/invalid and the spec says so rather than letting a surface print unknown-because-stale against a state that cannot be stale.

Why: every choice is the reuse-over-invention option, and the two that add anything (kind binding, the uncoupled previous_sha) close holes the memo's one-line payload pin left open.

Cost of reversing: the registry entry is versioned like any other — a changed shape is a v=2 string old verifiers refuse. The enclave placement is the one choice with migration cost (moving envelopes out of .zddc.d would need a serving-surface decision first).


D-30 — DECIDED 2026-08-17 — personal-software implemented; the users-registry binding

What the design memo says (docs/design/personal-signing.md, DECIDED 2026-08-17, forks F1–F5): personal keys sign the review sign-off as a record-revision with key_kind: "personal-software"; public halves are registered at <root>/users/<email>.yaml; commit signing is deferred to the Stage-4 transmittal producer.

What the spec chose: widen, don't fork — [M-S59] now implements the second kind; ONE new rule, [M-S64]: the matched signer entry must carry the registered identity (email) and it must equal the payload's principal, case-insensitively ([M-K13]'s match posture). An entry with no identity refuses (unknown_key) — a key registered to nobody vouches personally for nobody. The operational kind deliberately keeps NO principal binding: the server key signs for every authenticated principal, and inventing a binding there would misdescribe what an operational signature attests. [M-K32] pins the users-registry row as an ordinary record and the self-service-own-row rule, with [M-K29] restated so a registration is never over-presented. personal-webauthn stays reserved — its assertion encoding is a different profile needing its own vectors ([M-S52]'s split unchanged).

Why: the S2 vectors deliberately pinned the reserved-kind refusal with personal-webauthn, not personal-software, so this widening changes no existing vector's outcome — the additive-vectors rule holds byte-for-byte.

Cost of reversing: removing the kind would strand personal envelopes as unsupported_type — old evidence stays refusable-but-present, nothing mis-verifies.


D-31 — DECIDED 2026-08-17 — personal-webauthn implemented; the assertion profile

What the design memo says (docs/design/webauthn.md, DECIDED 2026-08-17, forks F1–F6): hardware authenticators sign the same record-revision payload via WebAuthn assertions; the credential registers as a flat group on the [M-K32] users row; the challenge carries the payload binding; no Go WebAuthn library (the two-verifier discipline forces a from-spec implementation anyway).

What the spec chose: the payload does not change AT ALL — one new signature ENCODING, scoped to one kind. [M-E25] adds the closed webauthn signature-entry extension (three base64url members, permitted only under personal-webauthn); [M-E26] pins the verification order with the challenge = base64url(SHA-256(PAE)) equality as the payload bind (bad_pae on mismatch — it IS the PAE check, one hash away); [M-E27] pins ES256 with the key as the 65-byte raw point, DER carried verbatim, minimal DER required, low-S deliberately NOT required (malleability buys nothing — the statement id is the payload hash). [M-S65] couples kind↔︎encoding in both directions (malformed either way). [M-S64]'s principal↔︎email binding extends verbatim; [M-K33] pins the registry group with rp_id + credential_id riding the signer-set entry so the rpIdHash and handle checks resolve offline. UP required, UV recorded-never-required (memo F6). signCount is carried, never evaluated — passkeys return zero and an offline verifier holds no counter state.

Two vector-suite consequences, named because the additive rule demands it: (1) implementing the last reserved kind necessarily changes the answer for artifacts claiming it — that is what reservation MEANT ([M-S52]'s split was the forward-compat door, and D-30 spent it deliberately). The committed vector at statements/invalid/58 was re-pointed at a still-unimplemented kind (personal-hsm) so the tree keeps pinning the refusal branch without renumbering its neighbours; the accepting half of personal-webauthn is pinned by the new S4 vectors. (2) ECDSA signing is randomized, so the assertion vectors carry generated-once, pinned signatures (gen.js --mint regenerates them; the committed table keeps the default run byte-deterministic, [M-R7]) — the vendored-corpora posture, not an exception to it.

Cost of reversing: same shape as D-30 — removing the kind strands webauthn envelopes as unsupported_type; nothing mis-verifies. Removing only the extension member would orphan the kind entirely ([M-S65] couples them), which is why they land together.


D-32 — DECIDED 2026-08-17 — the local content-attestation statement (registry entry #9)

What the design memo says (docs/design/transmittal-producer.md, DECIDED 2026-08-17): content responsibility and transport responsibility are two different people's acts — a lead engineer attests the files, the document controller issues the transmittal — sometimes the same person, never the same meaning. The attestation is an in-staging gesture on the assembled container, moving nothing; the two acts join by content.

What the spec chose: a second LOCAL type, content-attestation (§12) — [M-S53]'s closed-grammar posture verbatim, personal kinds ONLY ([M-S69]: server-operational is declared-and-forbidden, malformed — a server authenticates people, it does not take content responsibility), body = a [M-M6]–[M-M11] manifest + principal/key_kind/ts, signer resolution [M-S71] = the [M-S64] identity binding applied across every registry the context supplies (record-signer set locally, anchored operational folds cross-link). Placement is the container's _ledger/ ([M-S72]) — it TRAVELS, unlike record-revision's enclave sidecar, because the recipient seeing who took responsibility is the point. The join to a transmittal is (path, root) equality, reporting-only, no linking field in either payload: content-addressing already joins them, an edited file visibly falls out of coverage, and an unchanged file's attestation survives re-issuance with no new ceremony.

Why not reuse: record-revision is per-record and does not travel; transmittal has the wrong signer, a mandatory recipient, and [M-S13] makes it a thread root. Widening either would fork its semantics; the registry ([M-E4]) is shaped precisely to take a new type instead.

Cost of reversing: removing the type strands attestation envelopes as unsupported_type — refusable-but-present, nothing mis-verifies.


D-33 — DECIDED 2026-08-17 — in-band carriage of link-level acts; DC-as-certifier declined

What was probed (Casey, 2026-08-17): let the transmittal carry the public keys of all attestation signers, the DC's signature asserting the key↔︎email bindings, so keys change without prior distribution.

What the spec chose: the GOAL, not the mechanism. [M-S66]: a container's _ledger/ MAY carry copies of anchor-update / succession envelopes so the recipient resolves person-bound signers from the container alone, pinning only the party root ([M-K11], [M-K27]) — first use needs no prior distribution. Copies are evidence, never chain insertions ([M-S6] is a door rule and is NOT reported satisfied); the fold is conservative (same-key ops with unestablishable order → that key is unresolved, unknown_key, fail closed — never tie-broken by filename or ts); producers carry the CURRENT certifying op per signer, not key history. [M-S73]: anchor-update ops MAY carry confirmed_by (informational only, [M-S2] from.name posture) — recording which DC's commit gesture confirmed a first-use binding, since a root-signed statement is length-1 and cannot carry from.person.

Why the probed mechanism was declined: DC-signed bindings walk root → DC key → signer key — the length-3 chain [M-K18] forbids, and for its stated reason: a compromised operational key must forge one signer's acts at most, never mint identities. It would also open a second key-learning path beside the [M-S33] fold, and it is weaker than asked — "the org's perspective" is what a root-signed statement already means. Root-signed anchor-updates minted lazily at the commit gesture (the DC as certification GATEKEEPER, the root as its signer) deliver the same operational fluidity inside the existing grammar.

Cost of reversing: none on the wire — [M-S66] permits carriage, it never requires it; a deployment that stops carrying copies is exactly as verifiable as its recipients' out-of-band anchor state allows.


Defects found by implementing — 2026-08-13

Everything here is a defect fix: the spec said something wrong, something self-contradictory, or nothing at all, and two implementations had already agreed on the answer. None of them changed a byte on the wire.

Two vectors were unsatisfiable as committed

merkle/invalid/05-proof-siblings-reordered Generated by reversing the audit path of chunk 2 of a three-chunk file. RFC 6962's PATH(2, D[3]) has exactly ONE element, so the reversal was a no-op and the vector's input was byte-identical to merkle/valid/10, which demands the opposite verdict. Fixed in gen.js by moving the case to chunk 2 of the five-chunk file (path length 3), with a hard failure if a future edit picks a shorter path. Both quarantines removed.
statements/invalid/04-manifest-size-mismatch Demanded [M-M6] — a claim about file bytes — from a payload-only input carrying no file, no pattern and no bytes; size: 813 against an 812-byte root is indistinguishable from a legal entry without them. Fixed by giving payload vectors an optional files input, following the ledger precedent: the vector supplies what its rule needs. statements/valid/01 carries it too, so the check is exercised in both directions. Go's runner had papered over this with a hard-coded path→size map of its own invention; both runners now read the bytes from the vector and from nowhere else.

Prose that contradicted itself, the vectors, or reality

what fix
[M-C6] listed *_date among the instants, then named compromise_date as a calendar date two sentences later Rewritten as two closed field families by explicit name — instants ts/not_before/not_after/created, dates effective/compromise_date/anchored — which is what both implementations do, because the suffix rule refuses every legal revocation. Also states the class: a malformed ts is malformed, not not_canonical.
Check order inside step 9 was outcome-bearing and unstated [M-E23] (new) pins the two orderings a reason class depends on: per-type body rules before the re/thread linking rules, and inside a manifest entry, structural/hex before path form. Both implementations derived the first from the vectors alone; they disagreed on the second (Go validated the path first), and Go now matches.
[M-S8] said a verifier without the chain reports currency unknown, while statements/invalid/20 expects bad_link with no ledger present Written as both implementations resolved it: walk the re-chain as far as it resolves, and when it does not, require thread == re. The cost both flagged is now in the spec too — a legitimately mid-thread statement verified in isolation is rejected, and the fix is evidence, not leniency.
Reason class for a bad party fingerprint — hex violations are malformed everywhere else, but statements/invalid/17 demands bad_link [M-S51] (new): party-identity fields are link plumbing (bad_link); digest fields inside bodies are content (malformed).
§6 step 6's summary said "malformed on any violation" while two of its own checks demand unknown_key Split into shape (malformed, incl. [M-E15]'s self-consistency check) and identity ([M-E16], unknown_key — it asks the anchor state a question).
The derived anchor-state shape existed only in contexts.json [M-K16] §5.0 specifies it: the per-link and per-party keys, and the operational list with its certification and revocation members — the one member that exists only in derived state, because a counterparty pins the root and learns the rest.
[M-S31] revocation without a compromise date had no representable instant Specified as an optional derived-state revoked_at (never on the wire), with an explicit fail-closed rule when neither it nor a compromise date is present. Both implementations had already invented it; the browser now records it when folding a revoke op.
[M-C8]/[M-C9] stated critical only as a producer rule The verifier sentence both implement is now written: any critical at v1 is a refusal, including critical: [].
[M-M6]'s payload-decidable fragment was implicit Stated: size == 0 iff root is the empty-tree hash, and a verifier MUST always enforce that half while never claiming the byte half passed without bytes.
[M-S30]'s replaces was defined nowhere Defined: the raw public key of the superseded key, same encoding as key, informational only — never required, never revoking.
[M-S34] required a trailing / that [M-M9] forbids Reconciled: strip one trailing / before applying the path rules, accept the bare "/" as the whole-link scope, keep everything else refusing — and a trailing slash is never added or removed on the producer's behalf, because it changes what is un-shared.
[M-K17] left the window endpoints unstated Both inclusive. The browser had not_after exclusive; no vector lands on the boundary, so the suite would never have caught the one-second disagreement.
[M-S2] was silent on from.person with a length-1 root signer A self-certified root certifies no person, so from.person MUST be absent; a present one is unknown_key. Go enforced it, the browser did not, and now does.
[M-S9]'s "in that direction" is unenforceable — link.parties is an unordered set Dropped. The check is membership plus distinctness; direction is established where it actually lives, at signature resolution against from.party.
[M-E10] in the browser read as a rule an implementation obeys It is a statement about the platform. Web Crypto exposes no point arithmetic, so a browser verifier implements only rules 1 and 2 and inherits 3–6 from BoringSSL. If a future Chromium changed its equation, a conforming implementation could not compensate — only DETECT it. That makes [M-R12]'s vendored corpora the only real check on [M-E10] in the browser, i.e. load-bearing rather than tidy.
Counts drifted across three documents README.md said 135 fixtures and 84 of 116 ids; vectors/README.md said 91 of 123; the tree held 144. All recounted (145 / 126 / 94 / 32) and now derived by the drift guard, which also checks the traceability table both ways — [M-S41] had been listed as not-vector-testable while a vector names it.

Where the two implementations can still differ — CLOSED 2026-08-13

The previous pass found five divergences by diffing behaviour, not by reading reports, and every one of them sat where no vector reached. That is a reproducible method, so it was repeated after both implementations moved — and then repeated again after the fixes, which is the half that matters: a fix that introduces a new divergence is exactly what this catches.

How. A deterministic mutation harness takes every statements/valid payload and every envelope/valid envelope, walks each JSON path, applies a fixed catalogue of type/absence mutations (absent, number, null, string, array, object, boolean), re-canonicalizes the result so [M-C2] cannot mask the rule under test, and runs the lot through both verifiers — plus every statement under every context, a mutation sweep of the anchor row itself, and hand-written probes for the corners a mutation cannot reach (scope grammar, ops verbs, canonical-form edge cases, currency ledgers, proof shapes, re/thread combinations across all seven types, and every pairing of a broken header rule with a broken body rule). Both reports are diffed on (accept/reject, reason class).

run probes divergences
2026-08-13, first 2 853 12
2026-08-13, after the fixes 4 424 0

The re-run is larger because the fixes needed corners the first sweep had no reason to reach — the order/ family alone is 120 probes pairing every header break with every body break, and it exists because [M-E23] rule 3 had to be chosen rather than derived.

What was clean stayed clean, which is worth recording because it bounds the problem: the envelope door (thirteen envelopes × sixteen contexts, plus every envelope-member mutation, tri-state included), and the anchor arithmetic — [M-K17]'s window endpoints on both boundaries and one second either side, [M-S31]'s revoked_at branches in all six shapes, [M-K13]'s four emails match forms and case folding.

The twelve, and how each was settled

# Ids Which implementation was wrong, and what settles it
D-S1 [M-C10] (new), [M-C1] BOTH. The map said the browser was right because "RFC 8785 §3.2.2.2 requires unpaired surrogates to be emitted as \udXXX". It does not. That paragraph ends: "occurrences of such data MUST cause a compliant JCS implementation to terminate with an appropriate error." So {"a":"\ud800"} has no canonical form at all and both verifiers must refuse it — not_canonical, the same answer [M-C3] gives a number with no single spelling. Go's verdict was right by accident (its decoder substituted U+FFFD, and the byte-compare then failed on bytes nobody sent); the browser's JSON.stringify escaped the surrogate and round-tripped it, which is the one place a conforming JCS implementation must not be JSON.stringify. [M-C10] states the rule; both now refuse deliberately. Vectors: canonical/invalid/16–19, with canonical/valid/09 as the paired control through the same door.
D-S2 [M-K7] Go. It defaulted an absent root_alg — and an absent operational alg — to ed25519. [M-K7] now names absence and the empty string outright: absence is where a default is most tempting, because ed25519 is the only algorithm v1 implements, so guessing costs nothing today and becomes a downgrade surface the moment a second one exists. Vector: envelope/invalid/38, whose context differs from 29-unsupported-anchor-alg only in having no root_alg at all.
D-S3 [M-C11] (new), [M-S2] Go, and the spec. A wrong-typed from.name/from.person was read as absent and accepted. See the two sentences below. Vector: statements/invalid/41.
D-S4 [M-C11] (new) Go, and the spec. Same gap on optional body fields. Vector: statements/invalid/42.
D-S5 [M-S4], [M-C6] Go. ts absent or non-string answered bad_link; [M-C6] fixes the time vocabulary's class outright and [M-S51] reserves bad_link for values that name a party — a timestamp names nobody. Vectors: statements/invalid/45, 46.
D-S6 [M-S23], [M-E23] The browser. A documents[] entry that is not an object, or whose path is missing, answered bad_path; an entry that is not an entry has no path to judge. [M-E23] rule 2 is written for a manifest entry, so [M-S23] now says it for documents[] too. Vector: statements/invalid/43.
D-S7 [M-S8] The browser, and the spec — a genuine gap, since "absent on roots" was never written as a MUST in the other direction. thread is now REQUIRED whenever re is present: the redundancy only buys anything if it is always there, and an offline tool that must fall back to walking the chain whenever a producer felt like omitting it has gained nothing. Vector: statements/invalid/47.
D-S8 [M-E23] The spec. See the two sentences below. Vector: statements/invalid/44.
D-S9 [M-S34] The browser. scope: "" answered malformed. Absence and a non-string are structural; once the field IS a string, everything about its value is judged by the path rules, and "" breaks them exactly as /a/ or ../x does. The other reading has to invent a rule, and it would put one illegal spelling of a scope in a different class from all the rest. Vector: statements/invalid/48.
D-S10 [M-S47] The browser. It let a withdrawal whose thread matched the subject withdraw it. thread is a grouping aid that a tool may rewrite ([M-S8], [M-S18]); matching on it retires every statement in a thread as soon as any one is withdrawn, which is a much larger act than the signer performed. [M-S47] now says "names" means re and supersedes. Vector: currency/valid/06.
D-S11 [M-S47] The browser. A transmittal superseding ITSELF read as superseded. It cannot occur honestly — a payload naming its own id would have to contain its own hash — so a projection asserting it is corrupt, and corrupt derived state must retire nothing rather than retire the record it is attached to. Vector: currency/valid/07.
D-S12 [M-K16] The browser, and it was a crash: a null in operational threw a TypeError. A verifier that cannot survive its own state file cannot report on anything else in it either, and "a broken entry must not mask a valid one" is worth nothing if a broken entry aborts the walk. Vectors: envelope/valid/13 (it must not mask) and envelope/invalid/39 (with nothing valid behind it, the answer is the ordinary unknown_key).
D-S13 [M-S8] The Go verifier (found 2026-08-24 by the F-D D5 response producer — the first mid-thread statement anyone produced). [M-S8]'s terminus is walked "as far as the verifier can resolve it, and when the next hop is unresolvable the walk stops there"; Go instead treated a partially-resolvable chain as "chain not held" and demanded thread == re, refusing every honest receipt-for-a-response at the container vantage (response held beside its receipt, submittal in another container). No vector distinguished the readings, which is exactly how the divergence survived 231 vectors. Vectors: statements/valid/26 (partial chain, honest thread accepts) and statements/invalid/71 (same vantage, thread naming the response refuses — the held hop makes the disagreement checkable). A rider from the same find: at-rest ledger loaders in both implementations now re-verify refusals to a FIXED POINT with the verified statements fed back (chain-tip still absent, so [M-S6] stays the inbound door's rule), because a mid-thread statement's verification is evidence-dependent and file order must not decide verdicts.

The two sentences that collapsed D-S3, D-S4, D-S6 and D-S8

[M-C11] — typing. A declared field carrying the wrong JSON type is a REFUSAL: never read as absent, never coerced, never ignored under [M-C7], whose "ignore unknown fields" is about fields the spec never named. Fail closed is the house rule everywhere else here, and a named field of the wrong shape is the opposite of a forward-compatibility affordance — the producer and the verifier disagree about a field they both know, over bytes that are signed. The class follows the line [M-S51] already draws: bad_link inside from/to and for prev/re/thread, malformed everywhere else, with ts explicitly malformed.

Go's inconsistency is the evidence that the ignore behaviour was never a decision: it did type-check withdrawal.reason and derived_from[].verbatim while reading every other wrong-typed optional field as absent. That is not a policy, it is the shape of whichever accessor happened to be reached for — so both implementations now carry one ordered table, because a table cannot forget a field.

[M-E23] — ordering. Extended from "body before re/thread" to the whole order: the body phase before the whole header, then from/to, link binding, scope, ts, prev, chain tip, re/thread; and inside each phase the [M-C11] type checks run as a block before that phase's content rules.

Where it was derived: body-first is pinned by statements/invalid/37, and from/to must precede [M-S9] and [M-K14] because both read from.party. Where it was chosen: scope before ts, ts before prev, prev before the chain tip — no vector implied an order and the two implementations had invented two, so the sequence runs from the outside in (the link, then this statement's place on it, then its place in a thread) and the spec says plainly that this part was picked.

Five more, found while closing those

Each is the same kind of thing and is closed the same way — spec sentence, whichever implementation was wrong, vector.

what which was wrong settled by
Payload bytes that are not valid UTF-8 Go — encoding/json substituted U+FFFD and the byte-compare then refused bytes nobody sent (not_canonical for what is a malformed input) [M-C1]; canonical/invalid/20
A leading byte-order mark The browser — TextDecoder('utf-8') strips one unless told ignoreBOM: true, so it parsed bytes it had deleted [M-C1]; U+FEFF is not part of JSON's grammar (RFC 8259 §8.1); canonical/invalid/21
alg: "" on an op or a succession The browser — malformed, treating an empty string as absence [M-K7]; presence and type are structural, the VALUE is judged against the registry, so "" needs no special case; statements/invalid/49
An inclusion proof whose root is not a root Go — its proof door returned only bad_proof and had no vocabulary for malformed [M-M4]/[M-M5], shape before content one door over; VerifyInclusionProof now returns a Reason; merkle/invalid/09
derived_from[].file absent The browser — it required the field and answered bad_path, a rule nothing states [M-S15]: manifest is the required pin, file and verbatim are optional, because a whole-package re-issue names no single file; statements/valid/17

And one item that was not a divergence but was the same silence: [M-S9]'s link binding was skipped entirely by both when link.parties was not exactly two ends, so a context with one, three or zero ends accepted everything. Zero ends is now explicitly not evaluated (the offline case — a zip handed over out of band arrived on no link); anything other than exactly two distinct ends is bad_link, because a ZDDC link is bilateral permanently ([M-S3]) and skipping the rule amounts to accepting every statement offered on a malformed context. Vector: statements/invalid/50.

What the re-run cannot compare

69 of the 4 424 probes mutate the type of a field in the anchor row itself (emails: 7, operational: "x"). The Go runner cannot even load those: the row is a typed struct, so a type error is a load-time failure, while an untyped implementation carries the value into the verifier. That is a representation boundary, not a contract one — [M-K16] specifies which keys derived state must carry and explicitly leaves how an implementation holds it open — so there is nothing to converge and the diff excludes them by name rather than counting them as agreement.


The three memo contradictions — all CLOSED

These were not spec choices; they were places where the memo said two things, routed back rather than improvised. All three are now settled, and each correction landed in the memo itself:

  1. "Key material lives ONCE" (§2e) vs the offline-root model (§3/§10.3) vs the shipped two-registry reality (Stage 1 S2). CLOSED 2026-08-12 — D-12; §2e carries a dated correction note.
  2. The circular old_sig (§6.15). CLOSED 2026-08-12 — D-10; the continuity proof is the second envelope signature, and statements.md [M-S39] states that no such payload field exists.
  3. shared/hash.js "buffers whole files — a real latent bug" (§6.3 / §10.2). CLOSED 2026-08-12 — corrected on main in commit 14e1582; the chunked Merkle construction stands on streaming verification of a signed value, inclusion proofs, and domain separation, not on a hashing bug that no longer exists. Noted in manifest.md §2.
Rendered from “Spec Decisions Appendix — every wire-level choice the memo did not pin” ZDDC