Status: APPROVED 2026-08-12. Transcribes
federation-and-custody.md §4, §6.2–6.3, §6.11.1,
§10.2. Requirement ids: [M-C*] (canonical form,
shared by every spec) and [M-M*]
(manifest/Merkle). MUST/SHOULD/MAY per RFC 2119.
The manifest is the heart of the transmittal
statement (statements.md §1): the
list of {path, size, root} entries covered by the
issuer's signature. This document defines (a) the canonical
JSON form every signed payload uses, (b) the chunked Merkle
file-hash construction that produces root, (c)
path encoding, and (d) limits and extensibility.
1. Canonical JSON form (all signed payloads)
Every signed payload in the custody layer — the transmittal
manifest and all other statement types — is a JSON object
serialized to RFC 8785 (JCS) canonical bytes
(memo §6.11: "a real, small, published spec … ~50 lines of
browser JS"). The DSSE envelope (envelope.md) wraps
exactly those bytes.
[M-C1] A producer MUST serialize the payload per RFC 8785: object keys sorted by UTF-16 code units, no insignificant whitespace, UTF-8 encoding, minimal string escaping per RFC 8785 §3.2.2.2.
The verifier's side of "UTF-8 encoding", spelled out because it decides a reason class: the received payload bytes MUST be valid UTF-8, and a verifier MUST reject bytes that are not (
malformed). It MUST NOT substitute U+FFFD for an ill-formed sequence and then judge the result — a canonicalization gate that silently rewrites its input is deciding about bytes nobody sent. The same applies to a leading byte-order mark: U+FEFF is not part of JSON's grammar (RFC 8259 §8.1), so a payload beginning with one ismalformed, and a decoder that strips it before parsing is again judging bytes it invented. Both are stated because both are the default behaviour of an ordinary decoder — Go'sencoding/jsonsubstitutes, andTextDecoder('utf-8')strips a BOM unless told not to.[M-C2] A verifier MUST re-serialize the decoded payload per RFC 8785 and byte-compare with the received payload bytes; any difference MUST be rejected (reason class
not_canonical). This is what makes the statement id (below) a function of the content, not of one serializer's habits.[M-C3] Every number in a payload MUST be an integer
nwith|n| ≤ 2^53 − 1. Non-integer numbers (floats, exponents resolving to fractions) MUST be rejected (not_canonical). Quantities that are not naturally integers (timestamps, dates, versions) are strings.[M-C4] The payload MUST be a JSON object at the top level; any other JSON type MUST be rejected (
malformed).[M-C5] Statement id = lowercase-hex SHA-256 of the canonical payload bytes (64 hex chars). Content-addressed and envelope-independent (memo §6.11); an id is only meaningful once its envelope verifies.
[M-C6] Time values have exactly two spellings, and which one applies is decided by the FIELD NAME, never by a suffix pattern. v1 defines these two closed families; a field in neither is not a time value to a verifier and is not inspected ([M-C7]).
family form fields instant RFC 3339 UTC, seconds precision, Zsuffix —YYYY-MM-DDTHH:MM:SSZts([M-S4]),not_beforeandnot_after([M-S30]),created([M-K1])calendar date YYYY-MM-DDeffective([M-S35]),compromise_date([M-S31]),anchored([M-K9])A verifier MUST reject any other form — including an offset, sub-second precision, or a space separator — with reason class
malformed, notnot_canonical: the bytes are perfectly canonical JSON, it is the value vocabulary that is wrong, andnot_canonicalis reserved for a serialization that a re-serializer would spell differently ([M-C2], [M-C3]). The check is recursive, becausenot_before/not_after/compromise_datelive insideanchor-updateops rather than at the top level.This rule judges FORM, and only a value that is already a string. A
tsthat is a number or an array is a typing violation, which is [M-C11]'s business — and [M-C11] runs in step 9, after the signature, while this check runs at step 4/5, before it. The distinction is not pedantry: a payload whose signature does not verify has a more important thing wrong with it than a field's type, and pre-emptingbad_signaturewithmalformedfrom a check that could have waited hides a forgery behind a shrug. The class ismalformedeither way — only when it is decided differs.Classification is by explicit name because the obvious shortcut is wrong:
compromise_dateends in_dateand is a calendar date, so a*_date ⇒ instantrule would refuse every legal revocation. A calendar date and an instant are also indistinguishable to a UTC test runner once parsed, which is exactly why the field — not the value — has to decide.Asserted timestamps are informational only — trusted time is the counterparty's receipt (memo §6.5).
[M-C10] A payload MUST NOT contain an unpaired surrogate, and a verifier MUST reject one (
not_canonical). This includes an escaped lone surrogate ("\ud800","\udc00", a high surrogate not followed by a low one) in a string value or in an object key. It is not a stylistic rule: RFC 8785 §3.2.2.2 closes withNote: 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.
so such a payload has no canonical form at all — which is exactly what
not_canonicalnames, the same answer [M-C3] gives a number that has no single spelling. The class is notmalformed: the bytes are legal JSON and legal UTF-8 (a lone surrogate can only reach a payload as a\uXXXXescape — its raw encoding is refused by [M-C1] before this rule is reached).This is the one place where a JCS implementation and
JSON.stringifymust differ. ECMAScript's "well-formed JSON.stringify" escapes a lone surrogate as\udXXXand round-trips it happily, so a browser verifier built onJSON.stringifyaccepts what RFC 8785 requires it to refuse — the check has to be explicit. A Go verifier built onencoding/jsonfails the payload too, but only as a side effect of substituting U+FFFD ([M-C1]), which is a rejection for the wrong reason and only rejects by luck: it rejects because the substituted bytes no longer match, not because the input was refused. Both implementations MUST make the refusal deliberate, because the accident is what produced a real accept/reject split between them (decisions.md, D-S1).
1.1 Unknown fields & criticality
The payloadType string carries the version (envelope.md §2), so
changed semantics can never verify under old rules. Within one
version:
[M-C7] A verifier MUST ignore unknown fields in a payload (they remain signature-covered; they simply carry no v1 semantics), EXCEPT as governed by
criticalbelow. This is the extension surface: deferred features (e.g. an embedded TSA token) ride optional fields old verifiers skip."Unknown" means a field this spec never named — see [M-C11], which is the other half of this sentence.
[M-C11] A DECLARED field present with the wrong JSON type is a REFUSAL. A verifier MUST NOT read it as absent, MUST NOT coerce it, and MUST NOT ignore it under [M-C7]. "Declared" means named by a requirement in these documents for the common header ([M-S2]–[M-S9]) or for the payload's own type (
statements.md§3–§7, plus the manifest entry of [M-M6]–[M-M8]); it is not the same thing as "required", and an OPTIONAL field is declared exactly as a required one is. Absence is governed by the field's own requirement; this rule governs presence with the wrong shape.The class follows what the field is FOR, on the line [M-S51] already draws:
field class anything inside fromorto[], andprev/re/threadbad_link— these name a party or a linked statement, i.e. link plumbingtsmalformed— the time vocabulary, whose class [M-C6] already fixesevery other declared field, in any body and in a manifest entry malformedWhy a refusal and not a shrug. Fail closed is the house rule everywhere else in this profile, and [M-C7]'s "ignore unknown fields" is about fields the spec never named — a forward-compatibility affordance for versions that do not exist yet. A named field of the wrong shape is the opposite situation: the producer and the verifier disagree about a field they both know, and the payload is signed, so waving it through means accepting bytes whose meaning the two ends do not share.
from.nameis 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": {}.It is also, empirically, the rule neither implementation had: one type-checked
withdrawal.reasonandderived_from[].verbatimwhile silently reading every other wrong-typed optional field as absent, which is not a decision anybody made — it is the shape of whichever accessor happened to be reached for (decisions.md, D-S3/D-S4).Ordering: the type checks of one family run with that family and before that family's content rules — see [M-E23] rules 3 and 4.
[M-C8] A payload MAY carry a top-level field
critical: an array of unique strings naming top-level fields of this payload. A verifier that does not implement the semantics of every named field MUST reject the statement (unknown_critical). Acriticalentry naming an absent field MUST also be rejected (unknown_critical).[M-C9] v1 payload types define no critical fields; a v1 producer MUST NOT emit
critical. The verifier half, which the producer rule alone leaves unstated: a v1 verifier MUST reject any payload carrying acriticalmember at all (unknown_critical) — includingcritical: []. An empty array names no semantics, so [M-C8] on its own would let it through; but a v1 producer emits none, so its presence means the payload was produced by something that expected criticality handling this verifier does not have, and the safe answer to that is the one this whole mechanism exists to give. Fail closed on the shape, not only on the contents.
2.
Chunked Merkle file hashing — zddc-merkle-v1
Forced by constraint 1 (memo §6.3, §10.2): Web Crypto
digest is one-shot, multi-GB drawing sets must
verify streaming, and per-chunk inclusion proofs come free.
The construction is RFC 6962 §2.1 with the file's fixed-size
chunks as leaves.
Repo note (evidence, 2026-08-12). The memo justifies this partly by "
shared/hash.js sha256Filealready buffers whole files — a real latent bug." That is no longer true:shared/hash.jsnow carries an incremental pure-JSzddc.crypto.Sha256and streams files ≥ 4 MB through it at constant memory, pinned againstsubtle.digestintests/zddc.spec.js. The chunked Merkle construction is still mandatory — it buys streaming verification of a signed value, per-chunk inclusion proofs, and a leaf/interior domain separation a flat digest cannot express — but it is no longer fixing a memory bug. The memo has been corrected (commit14e1582, 2026-08-12, §6.3 and §10.2).
- [M-M1] The chunk size is fixed: C
= 1 048 576 bytes (1 MiB). A file of
sizebytes hasn = ceil(size / C)chunks; chunks0 … n−2are exactly C bytes, chunkn−1issize − (n−1)·Cbytes (1 … C). An empty file hasn = 0. - [M-M2] Domain-separated hashing
(second-preimage resistance — RFC 6962 §2.1 / RFC 9162 style):
- leaf hash:
L(i) = SHA-256(0x00 ‖ chunk_i) - interior hash:
N = SHA-256(0x01 ‖ left ‖ right)
- leaf hash:
- [M-M3] Tree shape per RFC 6962 §2.1: for
n = 0,root = SHA-256("")(the empty-tree hash,e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855); forn = 1,root = L(0); forn > 1, split atk= the largest power of two strictly less thann, androot = SHA-256(0x01 ‖ MTH(leaves 0…k−1) ‖ MTH(leaves k…n−1)). - [M-M4]
rootis encoded as 64 lowercase hex characters. A verifier MUST reject uppercase, wrong length, or non-hex (malformed).
Note the deliberate consequence of [M-M2]: even a
single-chunk file's root is
SHA-256(0x00 ‖ bytes), which differs from
the plain SHA-256 of the file. The manifest
root is never interchangeable with today's
sha256File output; a conforming verifier computes
the tree, always.
2.1 Chunk inclusion proofs
The RFC 6962 §2.1.1 audit path, verbatim, with chunk index in place of leaf index. Proof object:
{"size": 3145729, "index": 2, "path": ["<hex>", "<hex>"]}size: the file's byte length (determinesnand the tree shape).index: the chunk index being proven,0 ≤ index < n.path: bottom-up list of sibling hashes (lowercase hex), per RFC 6962 §2.1.1PATH(m, D[n]).[M-M5] Verification: compute
L(index)from the presented chunk bytes, fold the path per RFC 6962 §2.1.1 (at each level the accumulated hash is the left or right operand ofSHA-256(0x01 ‖ l ‖ r)according to the index's position in the subtree), and compare the result to the manifestrootfor that path. Any mismatch, a path of the wrong length for(size, index), orindex ≥ nMUST be rejected (bad_proof).A
rootthat is not a legal root encoding ismalformed, notbad_proof— [M-M4] governs it, and this is [M-E23] rule 2's shape-before-content line applied one door over: "this is not a root" is a stronger and earlier statement than "this proof does not fold to that root", and a value that is not 64 lowercase hex characters is not something a fold can be compared against at all. Stated because a proof door whose only vocabulary isbad_proofcannot say it, and one of the two reference implementations had exactly that door.Inclusion proofs are an offline verification convenience (prove one chunk of one file without the rest); they are never signed objects themselves — the signature covers the manifest, the proof merely connects bytes to it.
3. Manifest entries
Inside the transmittal (and
response) payload body:
"manifest": [
{"path": "drawings/PLT-100_B_IFR.pdf", "size": 4718592,
"root": "<64-hex>"},
{"path": "transmittal.yaml", "size": 812, "root": "<64-hex>", "enc": "zddc-hpke-v1"}
][M-M6]
manifestMUST be a non-empty array. Each entry MUST carrypath(string),size(integer per [M-C3], ≥ 0), androot(per [M-M4]);sizeMUST equal the byte length of the file whose chunks produceroot.Only part of that is decidable from a payload. The size/root agreement is a claim about file bytes, and a verifier holding only a statement does not have them: it MUST check the rule when it holds the container (streaming, per [M-M1]–[M-M4]) and MUST NOT report the rule as satisfied when it does not. The one fragment that IS payload-decidable, and which a verifier MUST always enforce, is the empty-file coupling:
size == 0if and only ifrootis the empty-tree hash — [M-M1] gives a zero-length filen = 0chunks and [M-M3] givesMTH({})exactly one value, so each implies the other with no bytes in evidence. Violation:malformed. (This is why the conformance suite lets apayloadvector carryfiles— seevectors/README.md§2.)[M-M7] Entries MUST be sorted by
pathascending in raw UTF-8 byte order (lexicographic, unsigned bytes) and MUST be unique bypath. Violation:malformed.[M-M8]
enc(optional string) is the encryption hook (memo §5b DECIDED: client-side encrypt-before-upload only; v1 defers the envelope spec). When present it names the encryption envelope format of the stored bytes;rootandsizethen cover the ciphertext (the stored/mirrored bytes — mirrors verify without reading, memo §5b). A v1 verifier MUST NOT attempt decryption and MUST treat the entry as an opaque file that verifies againstroot; it MUST NOT reject an unknownencvalue (the flag is informational at v1).
3.1 Path encoding — the archive-path contract
Manifest paths are relative to the transmittal
container root. The container's own location is given
by the payload's mailbox and name
fields plus the fixed federation path contract
<party>/{issued,received}/<mailbox>/<transmittal>/
— in which the <party> segment and the
issued/received slot are each side's own frame (memo §2b: each
side names the other in its own tree), so neither appears
inside the signed payload. What travels is what both sides
share: the mailbox, the transmittal name, and the in-container
paths.
- [M-M9] A
pathMUST: be valid UTF-8; be non-empty; use/as the only separator; not begin or end with/; contain no empty segment; and be at most 1024 bytes. - [M-M10] A
pathMUST NOT contain: a.or..segment; a backslash (\, 0x5C); NUL or any control character (bytes 0x00–0x1F, 0x7F); or a segment equal to.zddc.d(the reserved enclave never travels — memo §2e). Violation of [M-M9]/[M-M10]:bad_path. - [M-M11] Path comparison (sorting,
uniqueness, cross-referencing from
response.documents,derived_from.file) is byte-exact on the UTF-8 encoding. No Unicode normalization is applied by verifiers. Producers SHOULD emit NFC. - [M-M12]
name(the transmittal container name) andmailboxare each a single path segment obeying [M-M9]/[M-M10] (no/at all), at most 255 bytes. The default mailbox is the literaldefaultand MUST be written explicitly — a payload has exactly one byte form (seedecisions.mdD-7).
4. Size limits
- [M-M13] A verifier MUST reject a
canonical payload larger than 16 MiB (16 777 216 bytes)
(
too_large), and MUST accept any conforming payload up to that size (a manifest of at least 10 000 entries fits comfortably). Producers needing more issue multiple transmittals. - There is no limit on
sizebeyond [M-C3] (2^53 − 1 bytes ≈ 8 PiB — the practical ceiling is the filesystem's).
5. Algorithm identity & agility
zddc-merkle-v1 names SHA-256 and this exact
construction. There is no per-manifest hash-algorithm field:
the payloadType version is the algorithm
identifier (memo §6.8's "algorithm/version
identifiers in every record" — the version rides
application/vnd.zddc.transmittal+json; v=1, and a
future algorithm change is a version bump that old verifiers
refuse rather than mis-verify). The signature algorithm is
likewise never chosen by the artifact — it is bound to the
signer's anchor (keys.md
§4; memo §6.4).