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
The Signed Transmittal Manifest — zddc-merkle-v1 + canonical payload form
Status: Draft

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 is malformed, 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's encoding/json substitutes, and TextDecoder('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 n with |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, Z suffix — YYYY-MM-DDTHH:MM:SSZ ts ([M-S4]), not_before and not_after ([M-S30]), created ([M-K1])
    calendar date YYYY-MM-DD effective ([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, not not_canonical: the bytes are perfectly canonical JSON, it is the value vocabulary that is wrong, and not_canonical is reserved for a serialization that a re-serializer would spell differently ([M-C2], [M-C3]). The check is recursive, because not_before / not_after / compromise_date live inside anchor-update ops rather than at the top level.

    This rule judges FORM, and only a value that is already a string. A ts that 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-empting bad_signature with malformed from a check that could have waited hides a forgery behind a shrug. The class is malformed either way — only when it is decided differs.

    Classification is by explicit name because the obvious shortcut is wrong: compromise_date ends in _date and is a calendar date, so a *_date ⇒ instant rule 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 with

    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.

    so such a payload has no canonical form at all — which is exactly what not_canonical names, the same answer [M-C3] gives a number that has no single spelling. The class is not malformed: the bytes are legal JSON and legal UTF-8 (a lone surrogate can only reach a payload as a \uXXXX escape — its raw encoding is refused by [M-C1] before this rule is reached).

    This is the one place where a JCS implementation and JSON.stringify must differ. ECMAScript's "well-formed JSON.stringify" escapes a lone surrogate as \udXXX and round-trips it happily, so a browser verifier built on JSON.stringify accepts what RFC 8785 requires it to refuse — the check has to be explicit. A Go verifier built on encoding/json fails 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 critical below. 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 from or to[], and prev / re / thread bad_link — these name a party or a linked statement, i.e. link plumbing
    ts malformed — the time vocabulary, whose class [M-C6] already fixes
    every other declared field, in any body and in a manifest entry malformed

    Why 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.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": {}.

    It is also, empirically, the rule neither implementation had: one type-checked withdrawal.reason and derived_from[].verbatim while 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). A critical entry 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 a critical member at all (unknown_critical) — including critical: []. 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 sha256File already buffers whole files — a real latent bug." That is no longer true: shared/hash.js now carries an incremental pure-JS zddc.crypto.Sha256 and streams files ≥ 4 MB through it at constant memory, pinned against subtle.digest in tests/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 (commit 14e1582, 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 size bytes has n = ceil(size / C) chunks; chunks 0 … n−2 are exactly C bytes, chunk n−1 is size − (n−1)·C bytes (1 … C). An empty file has n = 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)
  • [M-M3] Tree shape per RFC 6962 §2.1: for n = 0, root = SHA-256("") (the empty-tree hash, e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855); for n = 1, root = L(0); for n > 1, split at k = the largest power of two strictly less than n, and root = SHA-256(0x01 ‖ MTH(leaves 0…k−1) ‖ MTH(leaves k…n−1)).
  • [M-M4] root is 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 (determines n and 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.1 PATH(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 of SHA-256(0x01 ‖ l ‖ r) according to the index's position in the subtree), and compare the result to the manifest root for that path. Any mismatch, a path of the wrong length for (size, index), or index ≥ n MUST be rejected (bad_proof).

    A root that is not a legal root encoding is malformed, not bad_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 is bad_proof cannot 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] manifest MUST be a non-empty array. Each entry MUST carry path (string), size (integer per [M-C3], ≥ 0), and root (per [M-M4]); size MUST equal the byte length of the file whose chunks produce root.

    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 == 0 if and only if root is the empty-tree hash — [M-M1] gives a zero-length file n = 0 chunks and [M-M3] gives MTH({}) exactly one value, so each implies the other with no bytes in evidence. Violation: malformed. (This is why the conformance suite lets a payload vector carry files — see vectors/README.md §2.)

  • [M-M7] Entries MUST be sorted by path ascending in raw UTF-8 byte order (lexicographic, unsigned bytes) and MUST be unique by path. 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; root and size then 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 against root; it MUST NOT reject an unknown enc value (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 path MUST: 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 path MUST 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) and mailbox are each a single path segment obeying [M-M9]/[M-M10] (no / at all), at most 255 bytes. The default mailbox is the literal default and MUST be written explicitly — a payload has exactly one byte form (see decisions.md D-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 size beyond [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).

Rendered from “The Signed Transmittal Manifest — zddc-merkle-v1 + canonical payload form” ZDDC