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
Custody Conformance Vectors
Status: Draft

Status: APPROVED 2026-08-12. Known-answer test vectors for the ZDDC v1 custody layer — the thing memo §6.2 asks for so that "a third-party .html is provably conforming" (constraint 2). Nothing here is implemented in the codebase; these fixtures are the target Stage 4 builds against, in both implementations (the Go server and the browser tools).

node docs/spec/custody/vectors/gen.js            # regenerate in place
node docs/spec/custody/vectors/gen.js --out DIR  # generate elsewhere
node docs/spec/custody/vectors/gen.js --check    # drift guard, exit 1 on diff

1. The conformance contract

An implementation conforms if and only if it ACCEPTS every vector under a valid/ directory and REJECTS every vector under an invalid/ directory with the reason class the vector names — plus, for currency/, derives the named currency verdict.

Three clarifications that make that testable:

  1. Reason classes are checked, not just accept/reject. An implementation that rejects everything passes an accept/reject-only suite. The class must match (§4).
  2. A rejection must be attributable to the named requirement. Each vector lists the requirements it exercises; a runner that rejects envelope/invalid/03-payload-not-canonical for the wrong reason is not conforming, which is why the class is part of the contract.
  3. Vectors are additive. A future version may add vectors; it must never change an existing one's expected outcome without a payloadType version bump, because the vectors are the wire format.

2. Layout and file shapes

vectors/
├── keys.json          the five test keys (seeds, public halves, keyids)
├── contexts.json      anchor state + link context, referenced by name
├── merkle/            zddc-merkle-v1 file hashing and inclusion proofs
├── canonical/         RFC 8785 canonical form and the statement id
├── envelope/          the DSSE profile: PAE, signatures, key resolution
├── statements/        payload-level rules — the seven link types + the local record-revision and content-attestation
└── currency/          the derived currency verdict

Every vector file carries id, spec, kind, requirements, description, input, and expect; envelope/statement vectors also carry context. The kind decides how a runner reads input:

kind input expect
file_root pattern, size, optional bytes_b64 chunks, root
inclusion_proof pattern, size, index, root, path[] accept
root_encoding root (as presented on the wire) accept, reason
canonicalize json_text (the producer's input, possibly non-canonical) canonical_b64, canonical_utf8, statement_id
payload_bytes payload_b64 (bytes as received), payload_utf8 accept, reason
envelope envelope (the whole DSSE object) accept, reason, statement_id, pae_hex, optional tri_state
payload payload_type, payload_b64, optional ledger, optional files, optional envelope_signature_count accept, reason, statement_id
currency ledger (statements[] + subject) currency

File inputs are a pinned byte pattern

merkle/ inputs describe their bytes rather than embedding them (decisions.md D-21): pattern: "zddc-vector-p251" means byte i = i mod 251, for size bytes. Cases at or below 4096 bytes also carry bytes_b64 so the pattern can be cross-checked directly.

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 an implementation that ignored chunk order would pass the whole suite.

payload vectors are payload-only on purpose

statements/ vectors carry the canonical payload bytes and the payload type, not an envelope — they test the rules that apply after an envelope has verified, so a runner can exercise them without re-implementing signature checks. Some of them carry extra input, because some rules are not decidable from a payload alone; each such key names exactly what the verifier is being handed, and a rule whose input is absent is not evaluated rather than assumed to have passed:

  • ledger — what the verifier already holds on this link (chain_tip, statements[] with type/supersedes/re/manifest_paths). Rules like [M-S6] and [M-S14] are inherently cross-statement.
  • files — the container's bytes behind named manifest paths, as [{path, pattern, size}] using the same pinned pattern merkle/ uses (D-21). [M-M6]'s "size MUST equal the byte length of the file whose chunks produce root" is a claim about bytes, and no payload carries them: with no files, size: 813 against an 812-byte file's root is indistinguishable from a legal entry, since both describe a single-chunk file. A runner materializes the named bytes and streams them through the Merkle construction; a manifest path the vector does not describe is skipped.
  • envelope_signature_count — the succession vectors need to know how many signatures the (absent) envelope carried, since [M-S38] is a cross-check between payload and envelope.
  • record_bytes_b64 — the stored bytes of the revision a record-revision statement pins ([M-S62]): a hash-equality claim about bytes, decidable only with them in evidence, exactly as [M-M6]'s files.

3. Keys, contexts, and personas

keys.json holds seven Ed25519 keys derived from labelled ASCII seeds (zddc-test-vector-key:<label> padded to 32 bytes with .). They are published test keys. They authenticate nothing and MUST NOT be anchored by any deployment.

key role
acme-root Acme's pinned custody root
acme-dc Acme's operational signing key, certified to alice@example.com
acme-root2 Acme's successor root (the succession vectors)
owner-root the counterparty's pinned custody root
owner-dc the counterparty's operational key, certified to sam@example.com
server-op a server's record-signing operational key ([M-K31], the record-revision vectors)
personal alice@example.com's registered personal signing key ([M-K32], the personal-software vectors)

Plus one non-Ed25519 credential (the S4 addendum, D-31): the personal-webauthn vectors use a P-256 credential derived the same way — private scalar = SHA-256 of ASCII zddc-test-vector-webauthn:alice-yubikey, public half the 65-byte uncompressed point, registered to alice@example.com under rp_id zddc.example with a fixed 32-byte credential_id. It lives in gen.js and the two webauthn contexts, not in keys.json (which stays Ed25519-only by its own alg header). Because ECDSA signing is randomized, its assertion signatures are minted once (node gen.js --mint) and pinned in gen.js (WEBAUTHN_SIGS); every default run re-verifies each pin against the freshly composed assertion message, so the suite stays byte-deterministic ([M-R7]) without an unverified constant anywhere.

contexts.json holds sixteen anchor states plus seven record-signer contexts (record-local, record-local-none, record-local-kind-mismatch, record-local-personal, record-local-personal-no-email, record-local-webauthn, record-local-webauthn-no-rpid) for the local record-revision type — they carry record_signers ([M-K31]/[M-K32], keys.md §5.0) and deliberately no link and no anchors, which is itself part of what they pin: a local statement needs neither. Each carries as_of (the verifier's last trust-anchor update from the link — [M-K16]) and now (the verifier's clock, which is the evaluation instant when no receipt is held — [M-K19]). Both are fixed literals, so a runner reads no clock. Ten of the sixteen exist to refuse the same valid envelope on anchor-state grounds: expired, revoked-with-compromise-date, revoked-with-no-instant-at-all, stale, person-not-certified, person-not-in-allowlist, unsupported-alg, no-alg-at-all, fingerprint-mismatch, and an operational list holding nothing but a corrupt entry. Refusing one envelope ten ways is deliberate — it isolates the anchor layer from the signature layer, since the bytes are identical every time and only what the verifier knows has changed. Two more exist to accept it despite something odd in the state (acme-owner-junk-operational-entry, where a corrupt entry must not mask the valid one) or to refuse it on the link rather than on the key (acme-owner-one-ended-link, [M-S9]).

Two more carry the OPTIONAL container key ([M-K16] §5.0): where the container the statement was read from lives, in the verifier's own tree frame. It is the only frame an anchor's scope is meaningful in ([M-K14], D-25), and acme-owner-in-scope / acme-owner-out-of-scope differ in nothing else — same row, same bytes, one address inside project 100004/ and one outside it. Every other context omits container, which is a real answer and not a default: a verifier that has not been told does not evaluate the rule and must not report it as satisfied.

Personas are alice@example.com, sam@example.com, admin@example.com; parties are Acme and a generic counterparty named owner. The repo's pre-push PII guard applies to every file here.

4. Reason classes

Closed vocabulary (decisions.md D-20). These are for logs, local UI, and this suite — never for a network response, which answers a single undifferentiated refusal ([M-E22]).

class the stage that failed
malformed structure: not JSON, wrong type, missing/extra member, bad hex or base64, wrong signature count
not_canonical the bytes are legal JSON but not RFC 8785 canonical, or carry a non-integer number
unknown_critical a critical entry names semantics this verifier does not implement
too_large the canonical payload exceeds the 16 MiB ceiling
bad_path a manifest/documents/scope path violates the path encoding
bad_signature PAE recomputed correctly, signature does not verify
bad_pae the signature was made over a different PAE (type confusion) — a bad_signature a runner can attribute more precisely
unknown_key the key does not resolve, or does not resolve valid, in the anchor state
unsupported_type unknown payloadType, unknown version, or an anchor declaring an unimplemented algorithm
bad_proof an inclusion proof does not fold to the root, or is the wrong shape
bad_link header/linking violation: to cardinality, wrong link, chain-tip, re/thread, a root swap

Where a verifier could plausibly emit either of two classes for one input (bad_pae vs bad_signature), the vector names the more specific one and a runner MAY accept the more general — stated here, not left to interpretation.

5. Requirement traceability

Every MUST/MUST-NOT in the four artifact spec documents (manifest.md, envelope.md, keys.md, statements.md) is either exercised by a vector or listed below with why it cannot be.

Covered by vectors: 122 of 157 artifact requirement ids, across 233 committed vectors. The twelve [M-R*] ids in conformance.md are excluded from that denominator by construction: they are rules about how a runner behaves (read no clock, run offline, treat drift as a failure), not properties of any artifact a vector could present. They are pinned by tests/custody-vectors.spec.js and by the Stage 4 runners themselves.

Those three numbers are derived, not maintained. tests/custody-vectors.spec.js counts the tree and fails if the sentence above disagrees with it, so a vector added without its traceability entry — or a requirement added without a vector — shows up as a test failure rather than as a number nobody re-checked. The same test guards the counts in ../README.md. To see them by hand:

# ids defined by the artifact specs, vs ids named by any vector's "requirements"
grep -ho '\*\*\[M-[A-Z]*[0-9]*\]' docs/spec/custody/{manifest,envelope,keys,statements}.md \
  | sort -u | wc -l

Not vector-testable — and why

ids why not, and what pins it instead
M-K1–M-K6 (key file format, enclave placement, no origin storage, unreadable-key handling) Properties of a file on a holder's disk, not of any artifact that crosses the wire. Nothing a conformance vector can present would exercise them. Pinned instead by the Stage 4 implementation tests plus the shipped .zddc.d/ invariant sweeps (handler.ReservedSidecar, cache.cachePathFor, peer/key.go).
M-K8, M-K9, M-K11, M-K12, M-K30 (which registry holds what; the two keys are never interchanged; pin-the-root; public halves must travel) Configuration and deployment shape — including D-12's resolution, whose whole content is where a key lives and what it may be used for. The consequences are covered at verification time: envelope/invalid/28-person-not-in-party-allowlist and envelope/invalid/31-anchor-update-not-signed-by-root exercise what pinning the root means, and the succession-signature vectors exercise which key may succeed which. Pinned otherwise by the Stage 4 registry and ZDDC-Sig tests.
M-K15 (KEM present, unused) A prohibition on behaviour a v1 implementation does not have. The positive half — an enc-flagged manifest entry verifying normally — is envelope/valid/02-transmittal-representative.
M-K22 (thief-signed artifact predating revocation verifies forever) An honest limit, not a rule: the correct behaviour is acceptance. Nothing to reject.
M-K23, M-K25 (rotation/succession need no counterparty edit; row appends history) Operational sequences over time, not single-artifact checks. The formats they use are covered (envelope/valid/06, 09, 11).
M-K27 (no network fetch during verification) A negative property of a runner's I/O. Testable only by observing an implementation (run the suite with the network removed — recommended in conformance.md §2), never by a fixture.
M-K28, M-K29 (out-of-band TOFU; an anchored email is a self-claim) Human process and UI honesty. The machine half — an unanchored key is never auto-accepted — is envelope/invalid/12-unanchored-operational-key.
M-E10, the small-order-A and cofactored/cofactorless axes only (the rest of [M-E10] is covered, by envelope/invalid/32–35) Both axes need a signature that verifies under a key ZDDC could never anchor: the small-order case needs a small-order public key, and the cofactor case needs a signature satisfying [8S]B = [8]R + [8k]A but not [S]B = R + [k]A, which cannot be produced from a test seed. A vector presenting one would be rejected at [M-E12] key resolution long before reaching the verification equation, so it would test the anchor layer and silently claim to test the signature layer. Pinned instead by the two corpora vendored at ../corpora/ — ed25519-speccheck-cases.json (cases 0–1 for small-order A, 4–5 for the cofactor split) and wycheproof-ed25519_test.json — which [M-R12] makes a runner obligation, and which are committed rather than fetched so the whole suite stays offline ([M-R5]) and the corpus cannot change underneath the contract. The run, its versions and its per-axis results are recorded in decisions.md D-19; the provenance and pinned SHA-256 of each file are in ../corpora/README.md.
M-E22 (no failure oracle on the wire) A property of a deployment's response surface, not of a statement. Pinned by the Stage 4 handler tests, alongside the existing bare-401 posture in the peer middleware.
M-M13 (16 MiB ceiling) Committing a >16 MiB fixture is disproportionate in a repo of plain text. A runner MUST synthesize the case: pad any valid payload past the ceiling and assert too_large.
M-S16 (never interpret revision labels) A prohibition on inference. No artifact can demonstrate that an implementation didn't parse "Rev B"; currency/ covers the positive half — ordering comes from signed acts.
M-S19, M-S28, M-S36 (a receipt is not agreement; withdrawal and consent-withdrawal are prospective) Semantic honesty requirements about how a result is presented. Pinned by UI review, not by bytes.
M-S21 (the fair-exchange limit: a recipient may simply refuse to receipt) An honest limit, not a rule — there is no artifact a refusing counterparty produces. Named in the spec so no implementation implies a receipt is guaranteed.
M-S33 (ops apply in array order) Needs a multi-statement anchor history to be observable; the single-statement half is statements/valid/07–08. Candidate for a currency/-style anchor-state area if Stage 4 wants it.
M-S42 (novation is not succession) A modelling rule about which act to use. No single artifact is right or wrong. (M-S41 used to sit in this row and does not belong here: the format half — a rename riding a succession — is envelope/valid/10-succession-linked-rename, which names it.)
M-S43, M-S44, M-S45 (_ledger/ placement, filename convention, never skipped) On-disk layout. Deliberately not authority ([M-S44] itself says a verifier tolerates any filename), so a vector asserting a filename would contradict the requirement it tested. Pinned by the Stage 4 packaging tests: a transmittal .zip contains its _ledger/ and never .zddc.d/.
M-S63 (record-revision envelope placement in the enclave) On-disk layout, same reasoning as M-S43–M-S45 — the filename is presentation, never authority, so a vector asserting it would contradict the rule it tested. Pinned by the S2 producer tests: writeWithHistory lands the envelope at .zddc.d/sigs/<stem>/<revision>-<sha8>.dsse.json, and the enclave never travels ([M-K4], the shipped ReservedSidecar sweeps).
M-S66 (in-band carriage of link-level acts; the conservative fold) Needs a multi-envelope container _ledger/ plus a fold over carried copies to be observable — the M-S33 reasoning one layer up, and like it a candidate for an anchor-state area when the Stage 4 fold ships. The pieces ARE covered: every carried copy verifies under the ordinary anchor-update/succession vectors, and envelope/valid/18-content-attestation-anchored is exactly the resolution a carried certification enables. Pinned otherwise by the slice-1 fold tests.
M-S72 (content-attestation placement in the container _ledger/; the (path,root) join) On-disk layout plus a REPORTING rule — the join is explicitly never a verification precondition, so a vector asserting coverage would contradict the requirement it tested ([M-S43]–[M-S45]'s reasoning). Pinned by the slice-2 producer tests: the gesture lands the envelope in _ledger/, and the commit gesture surfaces — never gates — coverage.
M-S73 (confirmed_by is informational only) A prohibition on using a field: no artifact can demonstrate that a verifier didn't consult it (M-S16's reasoning). The typed half — a non-string confirmed_by refusing per [M-C11] — is the ordinary declared-field law those vectors already pin.
M-K32 (the users/<email>.yaml registry row and its self-service rule) Registry/deployment shape, the M-K8/M-K9 reasoning one registry over: where a key is registered and who may edit the row are properties of a server's tree and doors, not of any artifact. The consequence — the registered identity binding a personal statement's principal — IS covered, by the [M-S64] vectors (envelope/valid/15, envelope/invalid/44–45). Pinned otherwise by the S3 registry handler tests.

6. Drift guard

tests/custody-vectors.spec.js (Playwright project custody-vectors) runs gen.js --check and fails on any byte difference between the committed tree and a fresh generation, so a hand-edited vector cannot survive a test run. It additionally asserts the structural contract: every vector parses, names a known kind and a known reason class, and references only requirement ids that actually exist in the spec documents.

The spec adds no custody code to any server package — the guard is a Node script plus a test, exactly as Stage 3's documents-only scope requires.

Rendered from “Custody Conformance Vectors” ZDDC