registry-relay can return W3C Verifiable Credentials (VCs), signed as
compact JWS, for two response families:
GET /v1/datasets/{dataset_id}/aggregates/{aggregate_id}->AggregateResultGET /v1/datasets/{dataset_id}/entities/{entity}/records/{id}->EntityRecord
Evidence verification is owned by Registry Notary. Relay publishes evidence-offering discovery metadata, but does not host claim or evidence verification execution endpoints.
The feature is opt-in twice over: by the operator (config flag) and by the caller (Accept header). When either says no, responses remain plain JSON.
These credentials are W3C VCDM 2.0 VC-JWT with a Registry Relay
JSON-LD context. They are not W3C PROV-O: no PROV graph is embedded
in issued VC-JWTs, and no PROV-O terms appear in the payload. The
feature is named provenance in configuration for compatibility; the
correct public description is "signed response credentials" or "response
credentials". The 2026-06-11 evidence-contracts decision record (D8)
records this posture.
The provenance config key is retained for compatibility; it governs
response-credential issuer configuration (DID, signing key, claim
validity windows, and accepted media types). Renaming it would be a
breaking change.
This document describes the runtime contract: configuration, wire shapes, endpoints, audit events, and key management.
Consumers of registry-relay increasingly need to relay government data to
downstream parties (cross-ministry workflows, EU-level dataspaces,
audit reviewers). Plain JSON gives them no cryptographic way to prove
"this came from registry-relay at time T under DID D". A VC-JWT does:
issuer DID, signing key, claim type, subject URI, and validity window
are all signed under one envelope that any verifier with the issuer's
DID Document can check.
The current encoding is W3C VCDM 2.0 + JWT binding rather than COSE or SD-JWT-VC. The runtime contract here is stable regardless of future encoding evolution.
Add a provenance: block to your config (see
config/example.yaml for the canonical
template). Minimum gateway-mode shape:
provenance:
enabled: true
schema_base_url: https://data.example.gov/schemas
context_base_url: https://data.example.gov/contexts
claim_validity:
aggregate_result: 1h
entity_record: 24h
issuer:
mode: gateway
did: did:web:data.example.gov
verification_method_id: did:web:data.example.gov#issuance
signer:
kind: software
jwk_env: REGISTRY_RELAY_PROVENANCE_JWK
signing_algorithm: EdDSAThe private JWK material comes from a local secret provider, never inline
YAML. kind: software reads the JSON-encoded private JWK from the named
environment variable:
{"kty":"OKP","crv":"Ed25519","d":"<base64url>","x":"<base64url>","alg":"EdDSA"}Use 1Password, AWS Secrets Manager, or your platform's secret store to inject it. Do not echo, log, or commit this value.
For simple local deployments that prefer file-mounted secrets, use
kind: file_watch:
signer:
kind: file_watch
path: /run/secrets/registry-relay/provenance-active.jwk
signing_algorithm: EdDSAThe file must contain the same JSON private JWK shape. Relay re-reads the file on signer use; a valid replacement for the same public key identity becomes active without process restart, while a malformed or different-key replacement degrades key readiness and keeps the last good signer available.
For local smoke tests, generate a throwaway Ed25519 JWK into ignored build output and inject it into the environment:
mkdir -p target/provenance
node -e 'const crypto=require("node:crypto"); const {privateKey}=crypto.generateKeyPairSync("ed25519"); process.stdout.write(JSON.stringify({...privateKey.export({format:"jwk"}), alg:"EdDSA"}));' \
> target/provenance/ed25519-private.jwk
export REGISTRY_RELAY_PROVENANCE_JWK="$(cat target/provenance/ed25519-private.jwk)"This command is only for local testing. In production, mint the key in the platform secret-management workflow and inject the env var without writing private material to disk.
When enabled: false (or the block is omitted entirely), the gateway
behaves as a plain JSON service.
Treat the signing key as a production credential with the same handling standard as an API root key:
- Inject
REGISTRY_RELAY_PROVENANCE_JWKfrom the platform secret store at process start. Do not place it in the YAML config, image layers, shell history, crash reports, issue trackers, or deployment logs. - Run the gateway under a dedicated service account with least privilege on the secret, config, source-data, cache, and audit paths.
- Disable process core dumps and memory diagnostics that could capture environment variables or heap contents holding signer material.
- Restrict interactive shell access on hosts that can read the signer env var. Prefer short-lived break-glass sessions with recorded access.
- Keep source-data mounts read-only and keep audit sinks append-only from the gateway process where the platform supports it.
- Alert on startup failures with
provenance.config.*,provenance.signer_unavailable, andprovenance.issuance_failed. - Exercise key rotation in a staging deployment before production:
issue a VC with the old key, rotate, confirm the old
kidremains in/.well-known/did.json, verify the old VC, and confirm the old key disappears only after the longest validity window has elapsed. - Publish the
/schemas/...and/contexts/...URLs behind the same externally reachable base URL used in issued credentials, otherwise downstream verifiers cannot resolve the contract named incredentialSchema.idand@context.
gateway mode: the gateway holds the signing key, publishes its DID
Document at /.well-known/did.json, and self-issues VCs under that
DID.
delegated mode: the gateway signs under a ministry's DID. The
ministry hosts its own DID Document at <ministry-did>/.well-known/did.json
and references the gateway's signing key as one of its
verificationMethod entries. In delegated mode the gateway does NOT
serve /.well-known/did.json; the ministry owns that surface.
Both modes use the same signer: shape. Switching modes requires a
config change and a process restart.
flowchart TB
V["Verifier with a DID Web resolver"]
subgraph GW["gateway mode"]
direction TB
GS["Gateway signs the VC under its own DID"] --> GD["Gateway serves /.well-known/did.json"]
end
subgraph DG["delegated mode"]
direction TB
DS["Gateway signs the VC under the ministry DID"] --> DD["Ministry serves /.well-known/did.json<br/>gateway returns 404 for did.json"]
end
SC["Gateway serves /schemas and /contexts in both modes"]
V -. "fetch DID Document" .-> GD
V -. "fetch DID Document" .-> DD
V -. "fetch schema and context" .-> SC
The two issuer modes differ only in who hosts the DID Document. In gateway mode the gateway signs under its own DID and serves the document; in delegated mode the gateway signs under the ministry DID and the ministry serves the document. The gateway serves schemas and contexts in both modes.
The handler returns a signed VC only when the caller asks for one:
GET /v1/datasets/social_registry/entities/individual/records/ind-123 HTTP/1.1
Accept: application/vc+jwtWithout that header (or when the header lists only types the operator
did not configure), the response stays plain JSON with the normal
content-type and body. Cache validators (ETag, If-None-Match,
304 Not Modified) still apply on the plain branch; they are
intentionally bypassed when a signed VC is issued, because each VC has
its own iat and jti.
The accepted media types are configurable
(provenance.accepted_media_types); the default is:
accepted_media_types:
- application/vc+jwt
- application/jwtThe response carries Content-Type: application/vc+jwt regardless of
which alias the caller used.
The response body is a compact JWS: base64url(header).base64url(payload).base64url(signature).
JOSE header:
{"alg":"EdDSA","typ":"vc+jwt","cty":"vc","kid":"did:web:data.example.gov#issuance"}Payload (top-level VCDM 2.0; no nested vc claim):
{
"@context": ["https://www.w3.org/ns/credentials/v2",
"https://data.example.gov/contexts/provenance/v1.jsonld"],
"type": ["VerifiableCredential", "EntityRecord"],
"id": "urn:uuid:01J5K8M0...",
"issuer": "did:web:data.example.gov",
"validFrom": "2026-05-16T09:30:00Z",
"validUntil": "2026-05-16T09:35:00Z",
"credentialSchema": {
"id": "https://data.example.gov/schemas/entity-record/v1.json",
"type": "JsonSchema"
},
"credentialSubject": {
"id": "<subject-uri>",
"dataset": "social_registry",
"entity": "individual",
"fields": { "id": "ind-123" }
},
"iss": "did:web:data.example.gov",
"sub": "<subject-uri>",
"iat": 1747387800,
"nbf": 1747387800,
"exp": 1747388100,
"jti": "urn:uuid:01J5K8M0..."
}type[1] is one of AggregateResult or EntityRecord.
Subject URIs follow
<catalog.base_url>/v1/datasets/<dataset>/entities/<entity>/records/<id>
for entity claims and
<catalog.base_url>/v1/datasets/<dataset>/aggregates/<aggregate_id>
for aggregates.
When the binary is built with the optional publicschema-cel Cargo
feature, an entity can declare a PublicSchema.org mapping for its
entity-record VC. The plain JSON API stays unchanged. Only callers that
request Accept: application/vc+jwt receive the mapped PublicSchema
credential.
entities:
- name: individual
table: individuals_table
fields:
- name: id
from: individual_id
- name: first_name
- name: last_name
- name: dob
- name: sex_code
access: { ... }
api: { default_limit: 100, max_limit: 1000 }
publicschema:
target: Person
mapping_path: mappings/individual-person.publicschema.yaml
schema_validation_path: ../publicschema.org/dist/schemas/Person.schema.jsonmapping_path points to a PublicSchema CEL mapping document. Its source
record is the projected entity JSON, not the private storage row, so
mapping rules should refer to public field names such as /id or
/first_name. At startup the gateway compiles every declared mapping;
an unreadable or invalid mapping fails startup.
During evaluation the gateway passes a CEL context object with
ctx.subject_uri, ctx.dataset, and ctx.entity. Mapping files should
use ctx.subject_uri for /id; the gateway rejects issuance if the
mapped credentialSubject.id differs from the canonical entity URI.
This keeps the VC sub and subject identifier anchored to the gateway
route even when mappings are reused across deployments.
schema_validation_path is optional but recommended. When present, the
gateway compiles the local JSON Schema at startup and validates every
mapped credentialSubject before signing. Validation failures abort VC
issuance with provenance.issuance_failed, so a bad mapping cannot
produce a signed credential.
By default the issued VC uses:
type[1]: the configuredtarget, for examplePerson@context[1]:https://publicschema.org/ctx/draft.jsonldcredentialSchema.id:https://publicschema.org/schemas/{target}.schema.json
Operators may override those defaults with context_url, schema_url,
and credential_type under the same publicschema: block.
The mapper dependency uses the local Crosswalk crate at
../crosswalk/crates/crosswalk-core in Cargo.toml, matching the
workspace checkout used for release builds.
Profile overrides do not bypass provenance audit: PublicSchema issuance
still attaches the provenance.vc.issued block, with claim_type
recording the overridden VC type such as Person.
Build and verify the optional path with:
cargo test --features publicschema-cel --test publicschema_cel_featureA binary built without publicschema-cel rejects configs that declare
entities[].publicschema, using
publicschema.config.feature_disabled. This prevents accidental
fallback to the native EntityRecord VC when the operator expected a
PublicSchema credential.
When provenance is enabled in gateway mode, the data plane serves
three additional endpoints, all unauthenticated and content-cacheable:
GET /.well-known/did.jsonreturns the gateway's DID Document. It lists every active and retiredverificationMethodso existing VCs signed under a rotated-out key still verify.GET /schemas/{claim_type}/{version}returns the JSON Schema (draft 2020-12) describing thecredentialSubjectshape for that claim type. Paths:aggregate-result/v1.jsonandentity-record/v1.json.GET /contexts/{vocab}/{version}returns the JSON-LD context referenced from VC@context.
In delegated mode the gateway does NOT serve /.well-known/did.json;
the ministry hosts it. The /schemas and /contexts routes still
serve from the gateway because the schema URIs in issued VCs point at
the gateway base URL.
When a VC is issued, the audit envelope for the request grows a
provenance block alongside the regular fields:
{"ts":"2026-05-16T09:30:00.123Z","request_id":"01J5K8...","path":"/v1/datasets/social_registry/entities/individual/records/ind-123","status_code":200,"provenance":{"event":"provenance.vc.issued","iss":"did:web:data.example.gov","kid":"did:web:data.example.gov#issuance","jti":"urn:uuid:01J5K8M0...","claim_type":"EntityRecord","subject":"https://data.example.gov/v1/datasets/social_registry/entities/individual/records/ind-123","validity":{"iat":1747387800,"nbf":1747387800,"exp":1747388100}}}The claim_type field tracks type[1] of the VC. kid matches the
JOSE kid header. jti matches the VC's id and JWT jti. The
record never contains the private JWK or the compact JWS body.
Plain-JSON responses (no Accept opt-in, or provenance.enabled: false)
omit the provenance block entirely.
The signing key is referenced indirectly: the config names either an env
var (software) or a local JWK file (file_watch). To rotate:
- Mint a new Ed25519 keypair. V1 production signing supports local
EdDSA only; P-256 (
ES256) is reserved for a future signer backend. - Add the new public JWK to the DID Document under a new
verificationMethodid (gateway mode: edit the source the DID Document handler reads; delegated mode: coordinate with the ministry). - Move the previously active key to
provenance.issuer.retired_keysso the DID Document keeps publishing it until every VC it signed has expired (cutoff =retired_after+ the longestclaim_validitywindow). - Update
verification_method_idto the new id. - Update the private JWK material. For
software, update the env var. Forfile_watch, stage the new JWK file that the new config points to. A running file-watch signer accepts only same-public-key refreshes; same-id different-key replacements are rejected to preserve old VC verification. - Roll the gateway when using local-file startup config. With governed signed
config apply, Relay can live-apply this change when provenance was already
enabled, the issuer identity and route-affecting settings are unchanged, the
new local signer material is ready, and the old key is published in
retired_keys. - Once the retirement cutoff has passed, drop the entry from
retired_keys. With governed signed config apply, use change classsigning_key_cleanup; Relay rejects cleanup beforeretired_after + max(claim_validity) + 5m. With local-file startup config, remove the entry on the next rolling deploy.
Never check a private JWK into git, into config, or into a container image. Never log it, never include it in error messages, and never embed it in a PR description.
Gateway-mode rotation config should keep the previous public key in
retired_keys until every credential signed by it has expired:
provenance:
enabled: true
schema_base_url: https://data.example.gov/schemas
context_base_url: https://data.example.gov/contexts
claim_validity:
aggregate_result: 1h
entity_record: 24h
issuer:
mode: gateway
did: did:web:data.example.gov
verification_method_id: did:web:data.example.gov#issuance-2026-06
signer:
kind: software
jwk_env: REGISTRY_RELAY_PROVENANCE_JWK
signing_algorithm: EdDSA
retired_keys:
- verification_method_id: did:web:data.example.gov#issuance-2026-05
jwk_env: REGISTRY_RELAY_RETIRED_2026_05_PUBLIC_JWK
retired_after: "2026-06-01T00:00:00Z"REGISTRY_RELAY_RETIRED_2026_05_PUBLIC_JWK must contain only the
public JWK. If an operator accidentally supplies a full keypair, the
gateway strips d before publishing the DID Document, but secret-store
policy should still keep retired private keys out of public config.
Delegated mode signs under the ministry DID while the gateway continues to host schemas and contexts. The ministry, not the gateway, must host the DID Document:
provenance:
enabled: true
schema_base_url: https://relay.example.gov/schemas
context_base_url: https://relay.example.gov/contexts
claim_validity:
aggregate_result: 1h
entity_record: 24h
issuer:
mode: delegated
ministry_did: did:web:ministry.example.gov
verification_method_id: did:web:ministry.example.gov#registry-relay
signer:
kind: software
jwk_env: REGISTRY_RELAY_PROVENANCE_JWK
signing_algorithm: EdDSABefore enabling delegated mode in production:
- Confirm
https://ministry.example.gov/.well-known/did.jsoncontainsverificationMethod[].id: did:web:ministry.example.gov#registry-relay. - Confirm that method's
publicKeyJwk.xmatches the gateway signing key's public key and does not containd. - Confirm the gateway returns
404 provenance.did_document_unavailableforGET /.well-known/did.json. - Issue a VC from the gateway and verify it with the ministry-hosted DID Document plus the gateway-hosted schema.
V1 production deployments support only the local software Ed25519 path:
signer:
kind: software
jwk_env: REGISTRY_RELAY_PROVENANCE_JWK
signing_algorithm: EdDSAThe software signer, public JWK export, DID validation, and SD-JWT
holder-proof helpers are delegated to the shared registry-platform
crypto and SD-JWT crates. This keeps Relay's provenance behavior aligned
with the platform verifier rules, including aud, exp > iat, maximum
300-second holder-proof lifetime, bound evaluation/profile/disclosure
claims, sorted _sd digests, and jti == credential_id issuance
parity.
signer.kind: kms is reserved for future remote signing backends and
is rejected by config validation today. V1 supports software and
file_watch local Ed25519 signing. The internal signer trait is kept
narrow so an AWS KMS, GCP KMS, HSM, or out-of-process signer can be
added later without changing the VC-JWT envelope, DID Web behavior, or
issuer-mode model.
The production acceptance bar for any future remote signer backend is:
- The gateway never receives or logs private key material.
- Startup can resolve the configured key id to a public JWK suitable
for
/.well-known/did.json. - Runtime signing returns compact JWS output with the same JOSE header and VCDM 2.0 payload shape as the software Ed25519 path.
- Key-disabled, access-denied, throttling, and regional outage failures
map to
provenance.signer_unavailablewithout leaking request payloads or secret identifiers beyond operator-safe key ids. - Integration tests verify issued VCs with a third-party JOSE library using only the DID-published public JWK.
Any standard JOSE library plus a DID Web resolver can verify these VCs. Minimum verification recipe:
- Split the compact JWS, decode the header.
- Resolve
header.kidvia the DID Web resolution rules: fetchhttps://<host>/.well-known/did.json, find the matchingverificationMethod, extract its public JWK. - Verify the signature over
base64url(header).base64url(payload)using the JWK's algorithm. - Decode the payload, then check:
issmatches the issuer DID (and the issuer DID resolves to the same DID Document that supplied the verifying key);nbf <= now < exp;credentialSchema.idmatches the schema you expected for this claim type;type[1]matches the expected claim family;- the
credentialSubjectshape conforms to that schema.
Treat any failure as a hard reject.
The repository includes an operator-facing verifier that performs this flow using only public artifacts:
node scripts/verify_vc_jwt.mjs \
--jwt-file target/provenance/vc.jwt \
--did-document target/provenance/did.json \
--issuer did:web:data.example.gov \
--claim-type EntityRecord \
--schema-id https://data.example.gov/schemas/entity-record/v1.json \
--schema target/provenance/entity-record.schema.jsonThe verifier accepts local paths, file:// URLs, and http(s) URLs
for DID Documents and schemas. For deterministic fixture checks, pass
--now <unix-or-rfc3339>.
Use this checklist after every production deployment that uses the local software Ed25519 signer:
- Start the gateway with
REGISTRY_RELAY_PROVENANCE_JWKinjected from the secret store, not from a shell prompt or config file. Confirm startup succeeds and readiness is green:
curl -fsS "https://data.example.gov/ready"- Fetch the public contract artifacts from the same externally reachable host named by issued credentials:
mkdir -p target/provenance
curl -fsS \
"https://data.example.gov/.well-known/did.json" \
-o target/provenance/did.json
curl -fsS \
"https://data.example.gov/schemas/entity-record/v1.json" \
-o target/provenance/entity-record.schema.json- Issue one entity-record VC with the lowest-privilege row-read API key:
curl -fsS \
-H "Authorization: Bearer ${ROW_READ_API_KEY}" \
-H "Accept: application/vc+jwt" \
"https://data.example.gov/v1/datasets/social_registry/entities/individual/records/ind-123" \
-o target/provenance/vc.jwt- Verify the VC with the repository verifier using only public artifacts:
node scripts/verify_vc_jwt.mjs \
--jwt-file target/provenance/vc.jwt \
--did-document target/provenance/did.json \
--issuer did:web:data.example.gov \
--claim-type EntityRecord \
--schema-id https://data.example.gov/schemas/entity-record/v1.json \
--schema target/provenance/entity-record.schema.jsonFor delegated mode, replace --did-document with the ministry-hosted
DID Document and keep --schema pointed at the gateway-hosted schema:
curl -fsS \
"https://ministry.example.gov/.well-known/did.json" \
-o target/provenance/ministry.did.json
node scripts/verify_vc_jwt.mjs \
--jwt-file target/provenance/vc.jwt \
--did-document target/provenance/ministry.did.json \
--issuer did:web:ministry.example.gov \
--claim-type EntityRecord \
--schema-id https://relay.example.gov/schemas/entity-record/v1.json \
--schema target/provenance/entity-record.schema.json- For rotation smoke, run the same issuance and verifier steps once
before rotation and save the old VC. After rolling the new
verification_method_idand new private JWK, fetch/.well-known/did.jsonagain, confirm it publishes both old and new verification methods, issue a new VC, and verify both JWT files. The old VC must verify through the retired public key until the longest configuredclaim_validitywindow has elapsed. After that window, remove the retired key and repeat the DID fetch to confirm the oldkidis no longer published.
tests/fixtures/vc/entity-record-v1/ and
tests/fixtures/vc/aggregate-result-v1/ contain static VC-JWTs, decoded
payloads, DID Documents, and JSON Schemas. They are signed outside
registry-relay and verified by tests/vc_external_verifier.rs through
the Node verifier. Add a new fixture directory whenever the public VC wire
contract changes or a new claim type/version is introduced.