This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
This repo provides language-agnostic compliance test vectors for the did:webvh specification. It targets v1.0 of the spec.
The approach is a multi-implementation cross-resolution pattern:
- Human-readable YAML scripts in
vectors/<scenario>/script.yamldescribe the intent of each test scenario. - Each implementation reads the scripts and produces its own signed artifacts — committed under
vectors/<scenario>/<impl>/. - Each implementation generates its DID logs and resolves every other implementation's committed logs, then writes
implementations/<impl>/status.md(anddiffs.txt) showing both DID Creation results and cross-resolution results. - Divergences surface as differences in
status.mdacross implementations — these are candidates for Working Group discussion and spec clarification.
There is no single "reference" implementation. The spec and Working Group are the arbiter of correctness.
vectors/ # Committed artifacts, organized by scenario then implementation
<scenario-name>/
script.yaml # YAML DSL script describing the scenario
ts/
did.jsonl # DID log generated by the TS implementation
resolutionResult.json # resolution result at HEAD
resolutionResult.<n>.json # resolution result at version N (optional)
did-witness.json # witness proofs (optional)
python/
did.jsonl
resolutionResult*.json
rust/
did.jsonl
resolutionResult*.json
java/
did.jsonl
resolutionResult*.json
java-eecc/
did.jsonl
resolutionResult*.json
implementations/
ts/
Dockerfile # Docker build for the TS harness
docker-entrypoint.sh # (entrypoint baked into image, not this file)
src/ # TypeScript generator + resolver
generator.ts # reads vectors/<scenario>/script.yaml, writes vectors/<scenario>/ts/
cryptography.ts
interfaces.ts
status.md # generated, do not edit by hand
diffs.txt # diff details for any DIFF results
config.yaml # implementation metadata (home_repo, version, commit)
python/
Dockerfile
docker-entrypoint.sh
generate.py # generates vectors/<scenario>/python/ (skips negative scenarios)
test_vectors.py # pytest: resolves all impl logs with Python resolver
test_generate.py # pytest: verifies Python generates same log as TS
status.md
diffs.txt
config.yaml
rust/
Dockerfile
docker-entrypoint.sh
src/main.rs # resolves all impl logs, writes implementations/rust/status.md
src/generate.rs # generates vectors/<scenario>/rust/
status.md
diffs.txt
config.yaml
java/
Dockerfile
docker-entrypoint.sh
src/.../GenerateVectors.java # reads scripts, writes vectors/<scenario>/java/
src/.../TestVectors.java # resolves all impl logs, writes implementations/java/status.md
status.md
diffs.txt
config.yaml
java-eecc/
Dockerfile
docker-entrypoint.sh
src/.../GenerateVectors.java
src/.../TestVectors.java
status.md
diffs.txt
config.yaml
scripts/
run # build + run a single implementation via Docker
run-all # two-pass run of all implementations
package.json
# Run all implementations (two-pass: generate then cross-resolve with fresh vectors)
scripts/run-all
# Run a subset
scripts/run-all ts python
# Run a single implementation at main branch of its library
scripts/run ts
scripts/run python
scripts/run rust
scripts/run java
scripts/run java-eecc
# Run against a PR branch (using default repo)
scripts/run ts feat/my-pr
# Run against a fork + branch
scripts/run ts https://github.com/me/didwebvh-ts:feat/my-prEach scripts/run call builds (or rebuilds from cache) a Docker image, runs generate + cross-resolution, and appends a provenance footer to status.md with the exact library commit used.
# TS — install dependencies (corepack activates the pinned pnpm version)
corepack enable
pnpm install
# Regenerate all TS vectors from scripts + run TS cross-resolution (writes ts/status.md)
pnpm run generate
# Regenerate a single TS vector + its cross-resolution status (by scenario name)
pnpm run generate basic-create
# Verify committed TS vectors match what the generator would produce (CI check)
pnpm run verify
# Regenerate all negative test vector artifacts (ts/ only)
pnpm run generate-negative
# Python — generate vectors (skips negative scenarios automatically)
cd implementations/python && source .venv/bin/activate && python generate.py
# Python — cross-resolution + negative tests
cd implementations/python && source .venv/bin/activate && pytest test_vectors.py
# Rust — generate vectors
cd implementations/rust && cargo run --bin generate-vectors
# Rust — cross-resolution + negative tests (writes rust/status.md)
cd implementations/rust && cargo run --bin test-vectors
# Java — generate vectors
cd implementations/java && mvn compile exec:java@generate-vectors
# Java — cross-resolution + negative tests (writes java/status.md)
cd implementations/java && mvn compile exec:java
# Java-EECC — generate vectors
cd implementations/java-eecc && mvn compile exec:java@generate-vectors
# Java-EECC — cross-resolution + negative tests
cd implementations/java-eecc && mvn compile exec:java"Test if any XFAILs are fixed for the <impl> implementation"
Use <impl> = ts, python, rust, java, or java-eecc.
Each harness contains guards — pre-flight checks that skip or mark a test as XFAIL before the resolver runs. They exist because a known library bug or spec incompatibility would otherwise cause a confusing FAIL. When a library is upgraded, some guards may no longer be needed.
When you give Claude this prompt it will:
- Identify all active XFAIL guards for that implementation (see locations below).
- Temporarily comment them out one at a time (or all at once if independent).
- Re-run the harness with
scripts/run <impl>and read the updatedstatus.md.- The harness source (where guards live) is mounted from the host, so guard edits take effect immediately — no Docker rebuild needed.
- If a new library version is what's expected to fix the XFAIL, first run
scripts/run <impl> <branch>to build with the new library, then re-edit the guard and run again.
- Report which XFAILs became PASS (guard can be permanently deleted) and which became FAIL or DIFF (library still broken — guard must stay).
- Apply whichever permanent changes you approve.
| Implementation | Guard type | Location |
|---|---|---|
| Java | Pre-flight functions (logHasEmptyNextKeyHashes, logHasEmptyWitness) + runtime pattern list (isKnownTsCompatError) |
implementations/java/src/main/java/org/didwebvh/compliance/TestVectors.java |
| Java EECC | Same pattern as Java | implementations/java-eecc/src/main/java/org/didwebvh/compliance/TestVectors.java |
| Python | Pre-flight functions (e.g. _log_has_empty_next_key_hashes) called with pytest.xfail() |
implementations/python/test_vectors.py |
| Rust | Pre-flight pattern list checked before resolution | implementations/rust/src/main.rs |
| TS | Pattern list in runNegativeResolutionTest / cross-resolution loop |
implementations/ts/src/generator.ts |
Python's pytest.xfail() is unconditional — it checks log content, not resolver behaviour. Even after a Python library fix, the guard will still fire if the TS log still contains the offending content (e.g. nextKeyHashes: []). The correct action when a Python fix lands is to delete the guard entirely rather than just commenting it out, then rerun to confirm PASS.
With Docker (preferred): just pass the branch to scripts/run — the image always clones fresh from the specified branch, so there is nothing to manually update:
scripts/run python https://github.com/decentralized-identity/didwebvh-py:my-fix-branchWithout Docker: update the library version in the implementation's build file and config.yaml, then recompile/rebuild before running the XFAIL test:
| Implementation | Build file | Key field |
|---|---|---|
| Java | implementations/java/pom.xml |
<version> under didwebvh-core dependency |
| Java EECC | implementations/java-eecc/pom.xml |
<version> under webvh dependency |
| Python | implementations/python/ |
pip install --upgrade did-webvh then update config.yaml |
| Rust | implementations/rust/Cargo.toml |
add rev = "<commit>" to pin, or remove to track HEAD |
| TS | package.json |
version of didwebvh-ts |
TS-specific: the test-suite's own harness (this repo) runs on Node.js + pnpm + tsx, aligned with didwebvh-ts PR #156 (merged 2026-07-14), which moved the library itself off bun onto the same toolchain (packageManager: pnpm@11.10.0, requires Node.js ≥22.13; corepack fetches the pinned pnpm version automatically). If package.json's didwebvh-ts dependency points at a local checkout (file:../didwebvh-ts), that checkout must be rebuilt before new library code takes effect — installing in the test suite does not rebuild a file: dependency. Rebuild with:
cd ../didwebvh-ts # or wherever the local checkout lives
corepack enable
pnpm install --frozen-lockfile
pnpm run buildthen relink and regenerate in this repo:
rm -rf node_modules/didwebvh-ts && pnpm install
pnpm run generate && pnpm run verifyOlder commits/branches of didwebvh-ts (before PR #156) still build with bun install && bun run build — check for pnpm-lock.yaml vs bun.lock in the checkout to tell which toolchain applies. The Docker path (implementations/ts/Dockerfile) detects this automatically (it keeps a bun install available purely as a fallback for building those older library commits/branches) and needs no manual intervention either way.
Each implementation directory contains a config.yaml with:
name: ts
home_repo: https://github.com/decentralized-identity/didwebvh-ts
version: "1.0.0" # version/tag of the implementation being tested
commit: "abc1234" # commit hash (optional)This metadata is stamped into the generated status.md files so a PR tells you exactly what version produced the artifacts.
Each script is a YAML file. Example:
description: "Create a DID and resolve at genesis"
spec_ref: "https://identity.foundation/didwebvh/v1.0/#creating-a-did"
keys:
- id: key-0
type: ed25519
# 32-byte hex seed — deterministic, never random
seed: "0000000000000000000000000000000000000000000000000000000000000001"
steps:
- op: create
domain: example.com
signer: key-0
params:
updateKeys: ["key-0"]
- op: resolve
# omitting 'versionId' means resolve at HEAD
expect: resolutionResult.json| op | description |
|---|---|
create |
Create the DID log (first entry) |
update |
Append an update entry |
deactivate |
Append a deactivation entry |
resolve |
Assert resolution output matches the named expect file |
These map directly to did:webvh DID log entry parameters:
| param | type | notes |
|---|---|---|
updateKeys |
string[] | multikey identifiers of keys authorised for future updates |
nextKeyHashes |
string[] | pre-rotated key hashes (pre-rotation) |
witness |
object | {threshold, witnesses:[{id,weight}]} |
portable |
boolean | whether the DID is portable |
context |
string[] | additional @context entries |
alsoKnownAs |
string[] | alsoKnownAs entries |
services |
object[] | service endpoint entries |
verificationMethods |
object[] | additional verification methods |
ed25519— seeded with a 32-byte hexseed
Additional key types (e.g. mldsa44) may be added later as experimental extensions.
A newline-delimited JSON file. Each line is a complete DID log entry as defined by the did:webvh v1.0 spec. The file is the minimal valid input to any conforming resolver.
Follows the DID Resolution response envelope:
{
"didDocument": { ... },
"didDocumentMetadata": { ... },
"didResolutionMetadata": { "contentType": "application/did+ld+json" }
}- Create
vectors/<scenario-name>/script.yamlfollowing the DSL format above. - Run
pnpm run generate <scenario-name>. - Inspect the generated files in
vectors/<scenario-name>/ts/. - Commit both the script and the generated artifacts together.
All happy-path scenarios listed below are implemented and committed to vectors/.
| Scenario | Description |
|---|---|
basic-create |
Minimal create + resolve at HEAD |
basic-update |
Create + single update + resolve |
key-rotation |
Create + update rotating the update key |
pre-rotation |
Create with nextKeyHashes commitment |
pre-rotation-consume |
Create with nextKeyHashes; update signed by the pre-rotated key |
deactivate |
Create + deactivate + resolve (deactivated state) |
portable |
Create with portable: true |
portable-move |
Create portable DID; update migrating to a new domain |
witness-threshold |
Create with a witness list; witness proof provided on resolve |
witness-update |
Create with 2-of-2 witness config; update reducing to 1-of-1 |
multi-update |
Create + two updates; resolve at v1, v2, and HEAD |
multiple-update-keys |
Create with two updateKeys; update signed by the second key |
services |
Create with service endpoints; update adding a second service |
16 negative scenarios are implemented in vectors/negative-*/. Each has a negative: true flag in its script.yaml. Artifacts are generated by implementations/ts/src/negative-generator.ts (run via pnpm run generate-negative) and committed under vectors/negative-<scenario>/ts/. The resolutionResult.json for negative cases expresses the expected error:
{ "didDocument": null, "didDocumentMetadata": {}, "didResolutionMetadata": { "error": "invalidDid" } }All five harnesses resolve negative vectors and report results in their status.md under ## Negative Resolution. PASS = resolver correctly rejected the invalid log; FAIL = resolver accepted it (conformance bug); SKIP = URL-only test (empty did.jsonl).
Five scenarios are URL-only (empty did.jsonl) — they test DID URL parsing, not log content. Each harness resolves these by calling its library's URL parser directly on the DID string from the script's resolve-did op, rather than running the log resolver:
| Harness | URL parse call |
|---|---|
| Rust | WebVHURL::parse_did_url(did) |
| Java | DidWebVhUrl.parse(did) |
| Java EECC | DidUrlTransformer.toLogUrl(did) |
| Python | resolve_did(did, local_history=log) with empty log |
| TS | resolveDID(did) with fetch intercepted |
Each implementation lives under implementations/<lang>/ with its own harness code and a config.yaml pointing to its home repo and version. When an implementer runs their harness:
- It generates DID logs for each happy-path scenario (from
vectors/<scenario>/script.yaml) and writes them tovectors/<scenario>/<lang>/. - It resolves every other implementation's committed
did.jsonlfiles and records the outcome. - It resolves every negative test vector's
ts/did.jsonl(or tests the DID URL for URL-only vectors) and records PASS/FAIL. - It writes
implementations/<lang>/status.mdwith three sections: DID Creation, Negative Resolution, and Cross-Resolution. - The implementer opens a PR to commit their latest artifacts and status files.
A GitHub Action on merge aggregates all status.md files into a top-level summary matrix.
Divergences are expected and valuable. When two implementations disagree, the diff is surfaced in status.md and becomes a Working Group discussion item about the spec — there is no single reference implementation that auto-wins.
Both sides of each comparison are serialized through a JSON Canonicalization Scheme (JCS) library before comparing. This eliminates false failures caused by differences in key ordering or whitespace that are semantically irrelevant.
- TypeScript: implemented — uses
json-canonicalize(already a dep;canonicalizewas already imported for hashing) - Python: implemented — uses
jsoncanon(already a transitive dep ofdid-webvh; implements RFC 8785) - Rust: implemented — uses
serde_jcs(serde_jcs::to_vec)
The comparison pattern in each harness should be:
assert jcs(actual_resolution_result) == jcs(expected_resolution_result)This applies to the full resolution result object, including didDocument, didDocumentMetadata, and didResolutionMetadata.
TODO: Scripts should support referencing a fully-specified DID Document file rather than relying solely on the generated-from-params template. The motivation is to verify that a did:webvh implementation faithfully preserves whatever the DID Controller places in the DIDDoc — it must not silently reorder, strip, or transform controller-supplied content.
Proposed DSL addition on create / update steps:
- op: create
domain: example.com
signer: key-0
didDoc: diddoc-fixture.json # path relative to the script file
params:
updateKeys: ["key-0"] # did:webvh envelope params still applied as normalWhen didDoc is present the generator uses it as the DID Document body instead of constructing one from params. The did:webvh-required fields (e.g. id, @context) are merged or overridden from the fixture so the log entry is still spec-compliant, but any additional controller-supplied content is carried through verbatim.
The corresponding resolutionResult.json will then contain that exact content, and a conforming implementation must return it unchanged. This gives test coverage of the pass-through guarantee: the protocol layer must not alter DID Controller content.
This was prototyped and verified working. Key findings:
- The
didwebvh-tslibrary'screateDIDbuilds the DIDDoc internally viacreateDIDDoc— there is no API parameter to inject a pre-built doc. The generator must bypasscreateDIDentirely for this case. - The correct approach is a
createDIDWithCustomDochelper in the generator that mirrors the library's log-entry construction:- Build
initialLogEntrywithstate: customDoc(file contents, with{SCID}as placeholder throughout) scid = deriveHashSync(initialLogEntry)— the existingderiveHashSyncin the generator produces the correct value;createSCIDin the library is a no-op (returns the hash unchanged)prelimEntry = JSON.parse(JSON.stringify(initialLogEntry).replaceAll('{SCID}', scid))— substitutes SCID everywhere in the DIDDoc and parametersversionId = "1-" + deriveHashSync(prelimEntry)— compute final entry hash- Sign with
Ed25519Signer; proofverificationMethodwill bedid:key:<pubkey>#<pubkey>(the update-signer key, independent of what's in the DIDDoc body)
- Build
- The
paramsblock (updateKeys,nextKeyHashes,witness,portable, etc.) still comes from the script'sparams:field and is applied as normal — only the DIDDocstatecomes from the file. - Services appearing in resolved DIDDocs (e.g.
#files,#whois) are spec-correct implicit services added by the resolver per the did:webvh spec, not by the generator. They will appear inresolutionResult.jsonconsistently and are not a bug. - The
diddoc:field name (lowercase, matching YAML conventions) is preferred overdidDoc:for consistency with the rest of the DSL. updatesteps with a custom DIDDoc require a different substitution strategy (SCID is already known from the log), and were not prototyped. Implement separately.
Scenario to add once this is implemented:
custom-diddoc— create with an explicit DIDDoc fixture containing extra service endpoints, verification methods, and non-standard properties; resolve and assert the document is returned unmodified.
The 17 current negative scenarios cover a broad set of violations but candidates for future addition include:
| Scenario | Violated rule |
|---|---|
migrate-non-portable |
Domain change without portable: true |
update-after-deactivate |
Any update after a deactivation entry |
double-deactivate |
Second deactivation entry on an already-deactivated DID |
pre-rotation-wrong-key |
Update whose signing key hash does not appear in the previous entry's nextKeyHashes |
The negative generator (implementations/ts/src/negative-generator.ts) already supports the DSL ops needed for most of these: create, update, migrate, corrupt, sign-witness-proof, and resolve-did. The PermissiveVerifier bypass is already in place for steps that the library's own validation would otherwise block.
implementations/python/test-suite/ contains hand-authored invalid logs (invalid-json, invalid-method, invalid-scid, missing-proof, missing-witness) and a resolver-suite.json test manifest. These are currently consumed only by the Python harness (test_resolver_suite.py). The vectors/negative-*/ approach supersedes this for new work — the old suite is not a priority to migrate.
- Keys are always derived deterministically from the
seedfield — the generator never callscrypto.getRandomValues(). This guarantees that running the generator twice on the same script produces bit-for-bit identical artifacts. - The generator imports the same
createDID/updateDID/deactivateDID/resolveDIDfunctions from thedidwebvh-tslibrary (or a pinned version of it). It is intentionally thin — it only handles key management and script parsing; all DID logic lives in the library. - Because artifacts are committed, a PR that changes the generator or the library and also changes the committed artifacts is self-documenting: the diff shows exactly what changed in the generated output.