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 toforwards:is a code change and lands separately. Nothing in these specs depends on which name wins; the party-rowemailsdescribed inkeys.mdis 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]); successioncarries one or two, and the count MUST agree with the payload'slinkedflag in both directions ([M-E14]);signatures[0]MUST be the key named innew_root([M-E15]);signatures[1], whenlinked, 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_sigpayload 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, assign_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
sha256Filecorrection.
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 fourSignatureMalleabilitycases (s+L,s+2L,s+4L,s+8L, each unchanged modL) plusInvalidKtvtcId 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]Aholds while[S]B = R + [k]Adoes 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-Arejection would diverge, confirming the original clause's "MUST NOT add it". - Non-canonical
R— Wycheproof's 22InvalidEncodingcases (built so that a verifier decodingRleniently, 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-Rrejections on top", which reads as though suchRis accepted. It is rejected — as a consequence of comparing the encoding of the recomputedR, not via a canonicality check. Neither implementation decodesRas 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-revisioncarryingfrom/prevhas them ignored under [M-C7]. The alternative (declare-and-refuse) would make the local type carry link vocabulary solely to reject it. sha256is a plain SHA-256 of the stored bytes, notzddc-merkle-v1— it must equal what the successor revision'sprevious_shaaudit 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_shais REQUIRED, null on the first tracked revision — and deliberately NOT coupled torevision == 1:writeWithHistory's lazy migration can stamprevision: 1onto 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_kindv1 implementsserver-operationalonly;personal-software/personal-webauthnare 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
kindbinds to the payload'skey_kind(unknown_keyon 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-staleagainst 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:
- "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.
- The circular
old_sig(§6.15). CLOSED 2026-08-12 — D-10; the continuity proof is the second envelope signature, andstatements.md[M-S39] states that no such payload field exists. shared/hash.js"buffers whole files — a real latent bug" (§6.3 / §10.2). CLOSED 2026-08-12 — corrected onmainin commit14e1582; 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 inmanifest.md§2.