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 diff1. The conformance contract
An implementation conforms if and only if it ACCEPTS every vector under a
valid/directory and REJECTS every vector under aninvalid/directory with the reason class the vector names — plus, forcurrency/, derives the named currency verdict.
Three clarifications that make that testable:
- Reason classes are checked, not just accept/reject. An implementation that rejects everything passes an accept/reject-only suite. The class must match (§4).
- A rejection must be attributable to the named
requirement. Each vector lists the
requirementsit exercises; a runner that rejectsenvelope/invalid/03-payload-not-canonicalfor the wrong reason is not conforming, which is why the class is part of the contract. - Vectors are additive. A future version
may add vectors; it must never change an existing one's
expected outcome without a
payloadTypeversion 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[]withtype/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 patternmerkle/uses (D-21). [M-M6]'s "sizeMUST equal the byte length of the file whose chunks produceroot" is a claim about bytes, and no payload carries them: with nofiles,size: 813against 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 arecord-revisionstatement pins ([M-S62]): a hash-equality claim about bytes, decidable only with them in evidence, exactly as [M-M6]'sfiles.
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 -lNot 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.