Registry stack documentation: machine-readable Markdown.
Index of all pages: https://docs.registrystack.org/v/0.38.0/llms.txt
Full corpus: https://docs.registrystack.org/v/0.38.0/llms-full.txt

# Operator contract

> Supported deployment shape, requester authority and purpose duties, required configuration and secrets, and readiness, audit, and key obligations.

Status: Partially implemented Version 1 operator contract

This document defines the supported native deployment and operator duties for
the Evidence Gateway Version 1 `evidence` binary.

## Supported deployment

The supported native deployment has:

- one `evidence` process in one operator-controlled trust domain;
- one reviewed, immutable governed evidence bundle and one closed operator
  runtime file, both mounted read-only at startup;
- one reviewed OIDC access-token profile with exactly one trusted issuer and
  exact audience, token type, and algorithm allowlists;
- one configured principal claim with no `client_id`, `azp`, header, or request
  fallback;
- reviewed requester and subject-authority mappings for named requirement
  revisions, purposes, audiences, roles, selector profiles, and value origins;
- fixed, bounded HTTP JSON source requests with fixed or tagged selector/prior-fact-bound paths,
  fixed non-secret headers, client-side response projection, denied redirects,
  logical private-CA trust profiles, and generic Basic, static Authorization
  header, static API-key, or OAuth 2.0 client-credentials authentication through
  secret references, the last authenticating by client secret or by private-key
  JWT assertion;
- optional credential-free source access only for `assuranceProfile: local`
  at a canonical numeric-loopback HTTP origin with an explicit non-zero port;
- fixed reviewed SQL statements over regular, checkpointed, read-only SQLite
  extracts bound by logical profile, with publisher metadata, bundle-declared
  maximum age, exact parameter and result contracts, and row, cell,
  statement-step, elapsed-time, response-size, and concurrency bounds;
- one active ES256/P-256 service signing key whose `kid` is its RFC 7638
  thumbprint, explicit published and revoked key sets, flattened JWS JSON
  success responses, and public key discovery at
  `/.well-known/evidence/jwks.json`;
- a non-exportable, pinned-version Vault/OpenBao Transit key reached through a
  workload-local Unix-socket proxy for production and evidence-grade serving;
- keyed JSONL audit on storage whose durability the operator has explicitly
  established;
- production HTTPS exposure, dependency timeouts, per-source concurrency
  limits, per-principal rate controls, and bounded failed-selector attempts.
- an optional governed public provider advertisement, generated and sealed as
  `catalog.jsonld`, served unchanged at `GET /catalog.jsonld`, and containing
  no requester entitlement or trust decision.

Multiple evidence definitions may be enabled only when they share the same
operator, deployment lifecycle, audit boundary, and failure domain. Mutually
distrustful issuers or customers require separate processes and bundles. The
authentication profile admits exactly one token issuer and one set of claim
names, so a second issuer, or one issuer whose clients carry the same authority
under different claim names, requires a second deployment even when the
operators trust each other. Evidence Gateway Version 1 has no application database and
persists no selector, source, evidence, or response data. An external durable
audit service may own its own storage.

A gateway may provide publication, protocol integration, routing, and
additional rate controls. Evidence Gateway still validates its configured identity
context and independently enforces requirement, purpose, subject authority,
selector, audience, disclosure, signing, and audit rules. Unsigned headers or
caller request fields never substitute for authenticated authority.

Version 1 accepts bearer tokens only. A token carrying a proof-of-possession
confirmation claim is denied rather than accepted as an ordinary bearer, because
Evidence Gateway validates no sender proof and accepting one would discard the
constraint the authorization server issued the token under. An authorization
server that binds tokens to DPoP keys or client certificates must issue Evidence Gateway
clients unbound tokens.

## Governed bundle and operator runtime

The operator supplies one atomic bundle containing the approved YAML,
preparation scripts, extraction scripts, derivation scripts, schemas, codelists,
mappings, and fixtures. A separate closed `runtime.yaml` binds the bundle to
one listener, bundle directory, secret root, audit destination, signer
transport and pinned version, and local TLS trust files. It also records which
gated acquisition kinds this deployment enables, which is a decision the
deployment withholds by default rather than one it grants. The runtime file
cannot override service identity, trust domain,
authentication, authority, sources, request policy, scripts, disclosure, rate
limits, signing policy, or audit fail-closed behavior. The two content hashes
identify the exact loaded inputs but are not trust decisions. The operator
establishes trust through review, distribution controls, read-only mounts, and
process replacement for every revision.

Every bundle explicitly declares `assuranceProfile: local`, `production`, or
`evidence-grade`. Local is the only authoring profile and may omit a
requirement's fixture reference. It changes no other runtime trust boundary and
is carried in discovery, signed assertions, SD-JWT VC responses, audit, and
verification policy. Production and evidence-grade require one captured,
complete fixture suite per requirement under the existing bundle validator.
There is no fixture receipt, certification command, or second serving path.

There is no runtime upload, editor, approval API, hot reload, merge, mutation,
governed-field override, or fallback bundle/runtime file. Startup and readiness
fail if either input is incomplete, inconsistent, mutable, uncompilable, unsafe
in combination, or cannot bind every
allowed role, selector profile, value origin, authority path, and source
placement.

Before deployment, the operator must review the entire simultaneously enabled
bundle as one disclosure surface. This review includes threshold ladders,
overlapping categories, increasingly precise regions, jurisdiction variants,
coexisting revisions, differing requester entitlements, and relationship
combinations. Rate controls and after-the-fact audit analysis do not make an
unsafe bundle safe.

### Production candidate handoff

`evidencectl package` compiles an editable project and one explicit production
target into a new candidate directory. It is a create-only authoring command,
not an approval, promotion, deployment, key-generation, caller-registration,
or service-start command. It runs the real `evidence` binary through its
bundle-only validation entry point and evaluates every referenced fixture
without generating a temporary signing key or other validation secret. It then
atomically publishes one closed package with `SHA256SUMS` and an optional
`REVISION`. Runtime configuration remains in the deployment target. The package contains no production private key, credential, token,
local request, audit entry, or source response.

The operator reviews and transfers the exact package, records its package
digest, and independently provisions the signing key, audit HMAC key,
subject-binding HMAC key, and source credentials below the runtime's secret
root. Secret ownership and mode requirements remain unchanged: each referenced
secret is a regular owner-only file accepted by the eventual service identity.
The package and runtime must be non-writable to that identity. The
runtime is target-specific and remains outside the package. Signed assertions continue to carry only a configuration
revision as `configurationRevision`. That value is scoped to the one requirement
the assertion answers, not to the whole package digest. The public signing keys and
`signing.revokedKeyIds` are outside it: publishing, activating, retiring, or
revoking a key changes the package digest and the JWKS but no
`configurationRevision`. So is `authentication.revokedKeyIds`: revoking an
identity-provider key changes which caller tokens are accepted and the package
digest, but no `configurationRevision`.

Run the following grouped handoff after provisioning and whenever candidate
bytes, runtime bindings, trust files, or secrets change:

```sh
evidencectl doctor --runtime-config '<deployment-target>/runtime.yaml'
evidencectl test '<editable-project>' --target '<deployment-target>'
evidence serve --runtime-config '<deployment-target>/runtime.yaml'
```

`doctor` delegates the runtime-owned startup dependency preflight without
opening the public listener, sending an Evidence Gateway request, or appending an
application audit event. Audit initialization may briefly hold its operational
lock. The runtime remains the authority for startup. Route traffic only after
`/ready`. For one approved
synthetic deployment subject, retain the signed response, verify it against an
independently prepared `production` policy and trusted keys, and confirm that
its access and disclosure audit entries reached the audit destination.

Configure an HTTPS OIDC issuer independently of Evidence Gateway. Its client registration
must bind the approved resource and scopes; the governed bundle pins issuer,
JWKS URI, audience, allowed algorithms, token types, and claim mappings.
Inspect the installed package and target with `evidencectl artifact inspect <deployment-target>` and verify
an actual issuer-to-resource request at handoff. Inspection does not register a
client, decide authority, or issue a token. Maintained local tooling uses pinned
stock ThunderID.

Docker Compose remains a documented deployment adapter, never build output.
It mounts the approved package unchanged and read-only, supplies a
separate container runtime file and owner-readable secret mounts, gives only
the audit path persistent writable storage, binds Evidence Gateway privately, and
keeps public TLS and routing operator-controlled. The OIDC issuer retains its
public HTTPS issuer identity and JWKS URI: internal plain-HTTP service
names do not replace either value. Container images and their provenance are
operator responsibilities; Version 1 proves this journey with released bare
binaries, not generated containers or orchestrator manifests.

### Git-managed environments

Use one protected branch and complete named environment targets. The maintained
reference layout is under
[`reference/deployment-targets/`](https://github.com/registrystack/registry-stack/blob/v0.38.0/products/evidence/reference/deployment-targets):

```text
shared/
  evidence-project/
environments/
  local/
    evidence/{evidence.yaml,governance.yaml,source-keys/,secrets/}
  staging/
    evidence/{evidence.yaml,governance.yaml,source-keys/,secrets/}
    transit/{proxy-configs/,policies/}
  production/
    evidence/{evidence.yaml,governance.yaml,source-keys/,secrets/}
    transit/{proxy-configs/,policies/}
```

Shared Evidence Gateway questions, scripts, schemas, and fixtures are authored once.
Each environment target is nevertheless complete. It contains its own service
identity, issuer, endpoints, audiences, public keys, runtime paths, pinned
Transit versions, and logical secret references. There are no overlays,
environment branches, symlinks, runtime substitutions, or inherited defaults.
Promote a reviewed source revision, then build separate staging and production
candidates from their complete targets.

Git contains public JWKs and non-secret Transit proxy and policy configuration.
It never contains private JWKs, HMAC masters, provider tokens, auto-auth
credentials, access tokens, live responses, or real identifiers. A target
template uses conspicuous replacement values and is not deployable until those
values and public JWKs have been reviewed and replaced.

## Discovery of available evidence

Evidence Gateway Version 1 answers "what may this caller request?" with authenticated
`GET /v1/evidence-definitions`. Availability is requester-relative: the
definition must exist in the exact deployed bundle and exactly one authority
path must match the verified token, requirement, purpose, audience, complete
subject-role set, selector profiles, and value origins. The runtime never
publishes a global unauthenticated list of requester entitlements or invocable
definitions. Its separate public provider advertisement contains only closed
service facts for external indexing.

Discovery uses five separately trusted surfaces:

| Artifact | Purpose | What it does not do |
|---|---|---|
| RFC 9728 protected-resource metadata | Binds the exact configured public Evidence Gateway origin to one authorization-server issuer, the Evidence Gateway JWKS location, and header-only bearer transport. | It contains no requester-scoped definition or entitlement data and does not replace HTTPS or an out-of-band trust pin. |
| Generated Evidence Gateway OpenAPI | Describes `GET /v1/evidence-definitions`, `POST /v1/evidence`, `POST /v1/evidence/batch`, operational routes, envelopes, media types, and safe problems. | It contains no deployment definitions or entitlements. |
| Public provider advertisement | Serves the exact packaged `catalog.jsonld` bytes at `GET /catalog.jsonld`, with public service identity and one distinct binding for each exact Evidence Type and compatible response profile. | It contains no requester-specific request shape, entitlement, source configuration, credential, or trust decision. |
| Authenticated definition response | Lists the exact complete request shapes available to this verified token from the loaded package, each with the configuration revision an assertion for that one requirement carries. | It performs no provider access, does not grant authority, and is not a global catalog. |
| Static onboarding material | Gives an approved consumer token-acquisition instructions, human descriptions, legal context, endpoint trust, and verifier policy through the existing API catalog, developer portal, configuration repository, or bilateral process. | It is not accepted by the runtime and grants no authority. |
| Evidence Gateway JWKS | Publishes the active and retained public verification keys. | It is not a trust anchor and contains no definition or entitlement metadata. |

Each item in `definitions` is one complete invocable combination, not a
cartesian product for the client to assemble. It contains:

- one stable complete-definition handle, the requirement's own configuration
  revision, and its effective response formats;
- requirement and Evidence Type identifiers, under the document's effective
  audience, legal issuer, and technical provider;
- one allowed purpose;
- stable output handles, concept identifiers, required or optional status,
  value forms, and any list cardinality and uniqueness constraints;
- complete subject roles, cardinality, selector profile, and value origin; and
- safe selector field types and bounds. A controlled-code field exposes its
  governed scheme identifier and version, never the bundle file path or code
  values.

The endpoint omits a request shape unless its token-owned context or grant
selector values are present and valid. If no authority path matches, the
response has an empty `definitions` array. If multiple authority paths match
the same shape, that shape is omitted because `POST /v1/evidence` would deny it
as ambiguous. Discovery consumes the same per-principal request-rate budget as
evidence creation. It performs no source credential resolution, source call,
signing, or evidence-data audit write. The operation accepts no query
parameters or request body; callers cannot filter it into a definition oracle.

Human-readable titles, descriptions, legal references, examples, and support
contacts remain static onboarding documentation. The runtime response and that
documentation must not include source origins or identifiers, source paths,
response projections, scripts, adapter parameters, secret references,
internal requester-tag values, authority-profile identifiers, selector values,
codelist values, or unrelated definitions. Possessing discovery metadata does
not authorize its recipient; the identity provider must issue the configured
claims, and Evidence Gateway re-authenticates and re-authorizes every evidence request.
The public provider advertisement supports indexing and coarse service
matching only. Its `serviceId` identifies the native service, while each
derived `bindingId` keeps one Evidence Type and compatible response profile
correlated. A client still uses authenticated definition discovery to learn an
invocable request shape for its verified token.

The publication workflow is:

1. Review the complete bundle and its combined disclosure surface.
2. Run `evidence check` and every referenced fixture, and record the exact
   governed package digest.
3. Run the production `evidencectl package` flow, which generates and seals
   `catalog.jsonld`, then publish the generic OpenAPI, provider advertisement,
   and static onboarding material. Configure token issuance and verifier trust
   through the same governed process.
4. Obtain a token, call `GET /v1/evidence-definitions`, and bind each returned
   `configurationRevision` to the requirement it is published under. A relying
   party pins the requirements it consumes, not the deployment.
5. Construct requests only from one returned complete shape. Do not combine
   subjects, profiles, purposes, or fields across items.
6. On a relevant bundle or trust change, update onboarding material and
   coordinate rollout with the relying parties whose requirements changed
   revision. Clients observe a new revision through authenticated discovery,
   not by probing problem responses.

Version one does not implement a searchable, mutable, or federated catalog
inside Evidence Gateway, a registration editor, or a `describe` CLI command. An
external catalog may index the closed provider advertisement. `/catalog.jsonld`,
`/health`, `/ready`, `/openapi.json`, public problems, and JWKS never reveal
requester entitlements, enabled request definitions, or selector profiles.

## Requester authority and purpose

Authorization keys off the requester tags in the configured claim, not off the
requester principal. The principal is used only for rate accounting and audit
pseudonyms and never decides access. Two clients presenting the same tags hold
the same access, so differentiated access is expressed by issuing different
tags. An authority profile matches only when every one of its declared tags is
present, and exactly one authority path may match a request: zero paths and two
or more paths both deny. Startup validation does not detect two paths covering
the same requirement, purpose, and subject tuple, so the operator owns that
review.

The request declares its purpose and any purpose the matched grant does not
carry is rejected. Within the granted set the caller still chooses, so a
declared purpose is an authorized selection rather than an identity-provider
attestation. Where the purpose must be attributable to the token issuer, issue
a distinct requester tag per purpose and give each tag an authority profile
granting only that purpose. Purpose is then bound to a verified claim with no
change to Evidence Gateway.

Purpose is enforced rather than advisory in either arrangement. An unauthorized
purpose is denied before credential acquisition and source contact, purpose is
an input to every subject binding and audit pseudonym so one subject is not
linkable across purposes, and purpose is inside the signed payload where a
verifier rejects an assertion whose purpose does not match its expected policy.

Purpose does not narrow disclosure. A requirement returns the same concepts and
disclosure forms for every purpose that may invoke it. A purpose that justifies
only a coarser answer needs its own requirement and its own place in the
combined disclosure review.

Native rate controls are uniform. The configured request, burst, and
failed-selector limits are single values applied to every principal, and the
request-rate scope deliberately excludes purpose, audience, and requirement so
a caller cannot multiply its budget by varying them. Per-client quotas are a
gateway responsibility.

`POST /v1/evidence/batch` charges the request bucket once with cost equal to
its complete item count. The debit is atomic: capacity for all items is
reserved or the whole request returns `evidence.rate_limited` and charges nothing.
A bucket never holds more than `burstPerPrincipal` tokens, so a batch with more
items than the burst, or a holder-bound release presenting more holder keys
than the burst, can never be admitted however long the caller waits. Evidence Gateway
refuses it as `evidence.invalid_request`, with no `Retry-After`, and charges
nothing, rather than returning a rate limit that invites a retry which cannot
succeed. The caller receives the registered invalid-request body unchanged:
the frozen problem contract selects its detail by code alone, so the refusal
names neither the burst nor the batch size.

The largest cost a bundle admits is the larger of two numbers. A request batch
may carry up to sixteen items for any audience-scoped requirement, whatever a
source's own `batch.maximumItems` says, because items above that ceiling run
sequentially rather than being refused. A holder-bound release may carry up to
`holderBoundBatchMaxSize` holder keys when the bundle serves a holder-bound
requirement and enables `sd-jwt-vc-batch`. `evidence check` and `evidencectl
doctor` warn when `burstPerPrincipal` is below that cost, naming both numbers
and the key. It is a warning rather than a refusal: a burst below the batch
size is a deliberate way to cap how much one principal may ask for at once,
at the price of refusing every larger batch. The shipped reference and starter
configurations set `burstPerPrincipal: 16`; raise it at least that far unless
the cap is intended.
Authentication occurs once and all items use one evaluation instant. Every
item is validated and authorized before any credential is resolved or source
is contacted.

Rate limits are tracked per process, in in-process memory, never shared across
replicas. Running N instances behind a load balancer therefore multiplies
every configured limit by N; this matters most for the failed-selector budget,
since that budget is the selector-enumeration defense rather than merely a
throughput knob. A restart also resets every budget to full, because buckets
are keyed on an in-memory monotonic clock rather than persisted. Tracked keys
are bounded at 100,000; a new principal beyond that ceiling is refused with a
capacity error until entries age out of the prune window. Reaching it requires
100,000 distinct authenticated principals within the window, so treat it as a
capacity ceiling worth alerting on rather than a practical denial-of-service
vector.

The listener request timeout bounds admission, concurrency queueing, and body
collection. It is not a total evaluation deadline. Once a protected evaluation
starts, Evidence Gateway lets it finish under the separately bounded OIDC and source
operations so cancellation cannot bypass required audit or signed-response
release ordering.

## Response formats

The singular Evidence Gateway operation releases one stateless assertion.
`responseFormats` decides which serializations may carry it, and the closed values are `signed-jws`,
`unsigned-json`, and `sd-jwt-vc`. Both the immutable bundle and every authority
grant declare the list, both default to `[signed-jws]` alone, and both must
keep `signed-jws` enabled. Startup rejects a duplicate or unknown value and
rejects any list that drops the signed default.

The two lists are intersected and never unioned. A format is releasable only
where the bundle and the one complete matched grant both name it, so enabling a
format bundle-wide grants nothing by itself, and a grant cannot widen beyond the
bundle. Requesting a format outside the intersection is refused with the
ordinary `evidence.denied` problem before credential acquisition and source
access, and the refusal does not reveal which layer withheld it. An `Accept`
that names no known format at all, or that is duplicated, combined,
parameterized, or weighted, returns `format.unsupported` with HTTP
406, also before source access.

```yaml
# the immutable bundle: the ceiling
responseFormats: [signed-jws, sd-jwt-vc]

# the grant: the actual authority, never wider than the bundle
- requirement: urn:example:requirement:adult-status:v1
  purpose: eligibility
  audienceFrom: authenticated-requester
  responseFormats: [signed-jws, sd-jwt-vc]
```

The requester selects among enabled formats with an exact `Accept`:
`application/jose+json` (or a missing `Accept`, or `*/*`) for the signed
default, `application/vnd.registrystack.evidence-unsigned+json` for the visibly
unsigned envelope, `application/dc+sd-jwt` for the SD-JWT VC. Selection never
changes evaluation, disclosure, or audit obligations. Each release records its
own `responseProtection` in the disclosure-release audit event, with the closed
values `signed`, `unsigned`, and `sd-jwt-vc`; `signingKeyId` is present for the
two cryptographically protected modes and forbidden for unsigned output.

Enabling `sd-jwt-vc` adds a serialization, not a credential lifecycle. There is
no issuance session, holder binding ceremony, status list, revocation, or
presentation verification, and `/.well-known/jwt-vc-issuer` publishes no
per-requester or per-requirement information. The `vct` claim is the
requirement's declared `isConformantTo` identifier, so the credential type is a
governed bundle decision rather than a client choice. The subject identifier
stays the audience-scoped pseudonym, so the same person requested for a
different audience yields a different identifier and the credential is not a
general-purpose multi-verifier credential.

A request may carry an optional `holderKey`, which is echoed into the `cnf`
claim and is meaningful only for the SD-JWT VC format. Only a public EC P-256
JWK is accepted; an unacceptable key is rejected as a malformed request
alongside the nonce check, before authentication, credential acquisition, and
source access. The key never reaches authorization, selectors, Rhai, sources,
audit, or the signed-JWS payload. Evidence Gateway issues no key-binding JWT, requires
none, and verifies none, so `cnf` is an unverified caller-supplied
convenience for whatever presentation layer the operator runs elsewhere.

Signing failure remains fail-closed for every protected format. A deployment
that cannot sign returns a safe transient failure and never downgrades an
SD-JWT VC request to unsigned output or to the signed default.
[The SD-JWT VC demo](../sd-jwt-vc-demo/) exercises this whole path locally.

The multi-subject request-batch route has a separate exact media type,
`application/vnd.registrystack.evidence.request-batch+json`, and does not
participate in the singular response-format intersection. It accepts one to
sixteen ordered audience-scoped subject sets under one requirement and purpose,
with a canonical pairwise-distinct nonce per item. Its only available result is
a flattened signed JWS. Every condition the singular evaluation contract
exposes as unavailable may appear as `evidence_not_available`; mixed and
all-unavailable envelopes are successful `200` responses. Any other failure
aborts the outer request with the existing safe Problem Details and no partial
release. The exact envelope is capped at 1 MiB.

This is not the holder-bound issuance batch. Holder-bound batching stays on
`POST /v1/evidence`, receives several holder keys for one subject evaluation,
and uses `application/vnd.registrystack.evidence.batch+json`. The request-batch
route accepts no holder keys and cannot emit SD-JWT VC or its issuance
container.

## Secrets and keys

Source credentials and local-authoring private signing material are supplied
only through the supported secret-reference mechanism. Production and
evidence-grade private signing material remains inside Vault/OpenBao Transit
and is reached through a workload-local Unix-socket proxy. Provider tokens and
auto-auth credentials stay in the proxy boundary and never enter Evidence Gateway.
Secret material does not appear in bundle YAML values, Rhai, command arguments,
environment dumps, logs, audit, errors, snapshots, or generated contracts.
Private JWK parsing uses an explicit ES256/P-256 allowlist. Missing or failed
signing is fail-closed and never releases an unsigned success response.

Replacing a source credential file at the same secret reference does not
invalidate an unexpired OAuth token. Provision provider overlap, replace
owner-only files, drain and restart every affected Evidence Gateway process when
immediate cache replacement is needed, verify a real synthetic source request,
and retire the predecessor under the provider's token-validity policy. See
[Source credential rotation](../source-credential-rotation/)
for the supported sequence and the distinct emergency-revocation boundary.

The operator commits one active public JWK and zero or more additionally
published public JWKs. Every key is exact ES256/P-256 public material and its
43-character `kid` is derived as its RFC 7638 thumbprint, never configured
separately. Active and published identifiers are disjoint from
`revokedKeyIds`. The JWKS contains only the active and published keys. During
planned rotation, retain the predecessor for at least the maximum assertion
validity plus allowed clock skew. Emergency revocation removes it immediately,
and denylisting takes precedence over a cached key set. The JWKS is discovery,
not a trust anchor. Verifiers obtain
the provider identity and JWKS location through governed configuration, pin
that trust, allowlist the expected algorithm, and resolve `kid` only within the
trusted key set. They never follow a message-provided remote key URL.

A valid signature proves that the technical provider controlling the key signed
the exact payload. It does not prove the source fact is true, confer legal
notarization, create a qualified electronic signature, or create a holder
credential. Governance establishes the provider's authority to act for the
named legal issuer.

### Service signing-key rotation

Planned rotation is an overlap, switch, drain sequence:

1. Create the next non-exportable Transit key version and export only its
   public key.
2. Commit that exact JWK under `public-keys/<thumbprint>.jwk.json` and add its
   path to `publishedPublicJwkFiles`.
3. Deploy and restart every replica so all of them publish both keys.
4. Wait at least the relying clients' maximum metadata-cache interval before
   activating the next key. The progressive client caps that interval at 600
   seconds. This ensures a client that refreshed immediately before publication
   can learn the next key before Evidence Gateway signs with it.
5. Keep the named Transit key's minimum signing version low enough for both
   pinned application versions. The ordinary Vault/OpenBao ACL grants the
   named key path, not a request-body key version.
6. Move the next path to `activePublicJwkFile`, keep the predecessor in
   `publishedPublicJwkFiles`, pin `signer.keyVersion` to the next version, and
   deploy and restart.
7. After `maximumAssertionValiditySeconds + verifierClockSkewSeconds`, remove
   the predecessor public key and raise the Transit key's minimum signing
   version, or otherwise disable the predecessor provider-side.

The full overlap therefore accounts for both metadata-cache propagation before
the switch and the maximum assertion lifetime plus clock skew after the switch.

Emergency rotation has no overlap guarantee. First disable provider signing
authority for the compromised version. Then remove its public JWK, add its
thumbprint to `revokedKeyIds`, activate a replacement or leave the service
unavailable, and restart every issuer and verifier that consumes the key set.
If the compromised key issued access tokens, add its key identifier to Evidence Gateway
authentication `revokedKeyIds` in the same incident rollout. This
shortens availability when necessary and is intentionally stronger than the
ordinary validity window.

## Source and selector controls

An optional governed `sourceConnections` map names shared HTTP workload
connections. Each authored source's `connection` reference is resolved at build
time into concrete endpoint, authentication, TLS-profile and concurrency fields;
startup rejects mismatches. The connection owns those settings and aggregate
resource limits. Each source retains its operation-specific request, timeout,
projection and response bounds. Existing inline sources remain supported.

Only the same explicit name in one process shares a transport pool, an
admission semaphore and an OAuth token cache with single-flight refresh.
Independent connection names and inline sources retain separate resources even
when their credential bytes are equal. No facts or authorization decisions are
shared. Waiting is bounded and cancellation releases capacity. Multiple
processes do not share a distributed concurrency budget.

The bundle-only check and evaluate seams that Evidencectl drives run before a
`runtime.yaml` exists, so they compile a connected source without those shared
resources and give each source its own instead. The pool is built from the
runtime document's outbound TLS settings and its captured CA bytes, which a
bundle alone does not carry. Nothing on that path dispatches an HTTP request:
it materializes request parts and asserts them, and executes only a statement
source, against a fixture extract. Shared client identity, private CA trust,
the token cache, and admission are therefore not among the facts a bundle-only
run proves. `evidence check` and `evidence fixture` read `runtime.yaml`, build
the resources, and refuse a connected source that cannot get them.

Each subject role admits only named selector profiles from the trusted bundle.
Each profile has one exact deployment-defined scalar field set, byte and
aggregate bounds, permitted value origin, and fixed source placement.
Alternative sufficient inputs and additional disambiguators are separate named
profiles. A national identifier is optional and possession of any selector
value never creates authority.

The authoritative provider owns record lookup. Evidence Gateway accepts only
`match`, `no_match`, or `ambiguous`; only `match` carries facts. Evidence Gateway does
not fetch broad candidates, follow pages, score candidates, choose a provider
record, or expose counts, records, confidence, near-match hints, or per-field
diagnostics. A reviewed deterministic derivation may compare its explicitly
declared authorized selector fields with complete facts from one uniquely
resolved authoritative record. When count plus one minimized result is unavailable, the fixed
request may retrieve at most two minimally projected results solely to detect
ambiguity.

Every source declares its acquisition posture. A single requirement inherits
its source posture. A requirement that acquires from more than one source
takes the weakest posture among them:

| Posture | Operator claim |
|---|---|
| `source-derived` | Full acquisition and disclosure minimization |
| `field-projected` | Strong acquisition and disclosure minimization |
| `record-transformed` | Disclosure minimization only |

The operator must not describe a `record-transformed` integration as full
lifecycle minimization. Rust applies every source's extended JSON Pointer
projection after bounded JSON parsing and before extraction, but the posture
describes the pre-projection wire response. The fixed request mock must prove
provider-specific field selection where claimed. A provider whose wire response
cannot be closed at that boundary must use `record-transformed`, even when
local projection and Rhai emit only narrow facts.

A statement returning one aggregate is `source-derived` on the same terms as an
API returning that aggregate, because the narrow fact is what crossed the source
boundary. A later derivation may map that fact to the asserted concept without
changing the acquisition posture.

Bundle-fixed headers cannot set authentication, routing, cookies, framing,
forwarding, proxy, or tracing fields. Tagged path placeholders occupy complete
segments and Rust expands them directly from already authorized selectors or,
only for a fixed fetch, a scalar property in the validated search FactSet.
Scripts render only query pairs and one JSON body and cannot select the binding
origin.

Each requirement declares exactly one acquisition kind. `single` and
`search-then-fetch` are the frozen Version 1 forms; `search-then-fetch` fixes
both source identifiers at startup, performs the fetch only after a unique
schema-valid search match, and has a hard two-call ceiling.

`search-then-fetch-set` is a gated kind added after that surface froze. It
widens the fixed fetch into two to four declared members, executed in the order
the bundle declares them, each receiving only the `factInputs` allowlist it
declares out of the validated search FactSet. Its ceiling is one plus the
member count, fixed by the bundle before any request is made, and it requires a
`maximumAcquisitionMilliseconds` between one and thirty seconds. Two gates open
it, and both are required: the bundle names the kind under
`acquisitionCapabilities`, and this file names it under
`acquisitionCapabilities` as well. Absent means enabled nothing, so a
deployment that never made this decision keeps serving exactly what it served
before. A bundle using the kind without the deployment's entry is refused
before the listener binds; `evidencectl doctor` names this file and the entry
to add.

No acquisition kind is a workflow surface: neither a response nor Rhai may
choose a source, origin, method, credentials, retry, or further call.

The optional `source-batch` capability reduces physical HTTP calls for the
multi-subject request-batch route. It is independently named in the bundle and
`runtime.yaml`, and the selected fixed-path `http-json` source must also carry a
`batch` block with `maximumItems`, batch preparation and extraction scripts, a
response schema, and a projection. A block without either gate fails startup.
An omitted block, a different transport, a path template, any multi-stage
acquisition, or an outer item count above the source ceiling selects ordinary
sequential execution in request order before I/O. Once an optimized attempt
starts, it never retries as sequential fanout.

The one optimized call reuses the ordinary method, origin, fixed path,
authentication, headers, TLS, redirect denial, timeout, maximum response bytes,
concurrency semaphore, and preparation limits. `prepare_batch` sees only opaque
integer slots paired with minimized selectors and closed parameters.
`extract_batch` sees only the validated projection, parameters, and slot list.
It must return an exact slot bijection over ordinary lookup results. Missing,
duplicate, extra, negative, or out-of-range slots abort the whole request.

A source may name a logical TLS trust profile, and so may `authentication` for
the connection that fetches the access-token issuer's key set. `runtime.yaml`
binds each name to one bounded PEM CA file, trusted beside the system roots for
the connections of the source or issuer that names it and no other. Hostname
and fixed-origin verification remain mandatory; there is no insecure or
trust-all mode. Version 1 ignores `HTTP_PROXY`,
`HTTPS_PROXY`, `ALL_PROXY`, and `NO_PROXY` and has no application-level proxy.

## Audit and operational data

After successful authentication, the configured audit sink must durably accept
a minimal authorization-refusal event before a generic `403` is returned. It
must durably accept one access-attempt event before each actual evidence-data
source read and the disclosure-release event after signing and before response
release. Any failure blocks the applicable action and returns a generic `503`
when an HTTP response remains possible.

Authorized-material audit events contain reviewed identifiers and decision
categories, never raw selector values, per-field selector hashes, source
values, prior facts, intermediate lookup identifiers, Supported Values,
credentials, tokens, or raw subject identifiers. When correlation is required,
one keyed, domain-separated, versioned pseudonym covers the complete canonical
role, selector-profile identifier, ordered field names, and selector value
bundle. It must not be globally stable across purposes or audiences.

After successful authentication, every authorization refusal writes one
standalone minimal native event before the generic `403` is returned. The event
contains only the operation and event identifiers, assurance profile, bundle
revision, scoped requester pseudonym, optional actor pseudonym, closed
`not-authorized` decision and safe error category, timestamp, and duration. It
omits the requested requirement, purpose, subjects, unmatched authority,
selector information, response protection, source, and evaluation material.
The requester and actor pseudonym scope binds the operator trust domain,
requested purpose, and authenticated audience, while those scope inputs remain
omitted from the event. This prevents a new cross-purpose or cross-audience
identifier. Request-rate accounting remains separately scoped to the principal,
so varying purpose cannot multiply or evade the request budget.
The audit sink must durably accept that event. If it cannot, Evidence Gateway returns
the generic `503` instead of the `403`. Authentication, malformed-request, and
invalid-selector failures remain operational-only and create no native audit
event.

Operational logs contain route templates, the public `trace_id`, the
server-minted audit operation identifier, duration,
status category, the public problem code, and safe internal error categories
only. The internal category is narrower than the public problem code but is
drawn from the same kind of fixed, closed set of service-chosen strings. It
names the internal step that failed, never what that step saw, and it carries
no counts. A record that was not found and a record that matched more than once
share one category, so the category is never a way to tell them apart. A
missing required fact and an inconsistent derivation input do keep separate
categories: separating those two is what lets an operator repair a deployment,
and the public problem code reports both as the same shape regardless. A
request that raises no failure logs a fixed placeholder in its place. Request
bodies, selector profile identifiers and values, source requests and responses,
authority grants, Rhai inputs, credentials, tokens, and disclosed values are
excluded from logs, metrics, traces, snapshots, panics, and errors. The public
trace identifier is a bounded correlation value, not an audit identity, and
caller `tracestate` is never echoed.

Audit and operational logging are separate channels and operators must not
confuse them. The audit log is the accountability record: durable, complete,
and it has no severity levels and no way to turn records off; tamper evidence
comes from the append-only storage it is shipped to.
Every authorized evidence evaluation writes one access-attempt event durable
before each actual source read and the disclosure-release or terminal event
required by its outcome. Every authenticated authorization refusal writes one
minimal denial event before its response. Those gates are pinned by frozen
Version 1 security invariants and are not configurable. The `tracing` channel
is the operational and diagnostic record: it has levels, it is buffered and
lossy, and it is cheap. The rule for operators and integrators is:
accountability facts belong in the audit log and never only in tracing, and
operational noise belongs in tracing and never in the audit log. If an
adopter needs more detail than the frozen audit record carries, which some
regulators require, the correct shape is a separate operational log keyed by
the audit entry's `eventId`, not a verbosity setting on the audit log.

The refusal event is written under the distinct
`registry.evidence.audit.authorization-refusal/v2` entry schema, in the same
envelope and destination as `registry.evidence.audit/v2`. A semantic reader
selects the record shape by the envelope's `schema` member. Readers written for
the chained `/v1` records do not read `/v2` entries, so operators must update
semantic audit readers and the service together before routing traffic to the
changed runtime.

The request-batch route adds
`registry.evidence.audit.request-batch/v2` entries to that same destination. One access
event precedes every physical source call and names the bounded zero-based item
indices it carries. `itemGroups` partition those indices by identical authority
object and ordered pseudonymized subject set, so different grants and authority
kinds remain accountable without recording selectors. One terminal release
covers all item groups and every ordered outcome. It carries an evidence id per
available item and a signing key id only when at least one assertion was signed;
an all-unavailable release carries neither signing key use nor evidence ids.

Any other failure after authorization produces one value-free terminal failure
and no partial release. Request nonces, raw selectors, facts, source bodies,
response bodies, JWS protected headers, payloads, signatures, and signing
material are forbidden from every batch-native event. The service serializes
and size-checks the complete envelope, durably appends the release, and returns
the same bytes. Operators must update semantic audit readers to recognize all
three entry schemas before deploying the request-batch runtime.

The serving process writes those records as line-delimited JSON on standard
error, one per served request, and `EVIDENCE_LOG` selects verbosity with a
default of `info`. Offline commands print their own result and emit no
operational records. Every response, including responses to unrouted paths,
carries a W3C `traceparent` header. Evidence Gateway reuses a valid inbound trace
identifier or mints one, and returns it as `traceId` in a problem body. It
never exposes the server-minted audit operation identifier and never echoes
caller `tracestate`.

Telemetry is off by default. Setting `metricsListener` in `runtime.yaml` serves
`GET /metrics` in Prometheus text format on a second private binding, which must
differ from the evidence listener binding and is subject to the same
loopback-or-private-address rule. The evidence listener never serves `/metrics`,
and the metrics listener never serves evidence. Series carry only the registered
route template, request method, status category, and reviewed problem code, or
for the source series a governed source identifier from the bundle, so series
cardinality is bounded by the deployed contract and cannot grow with caller
input. A path that matches no route is counted as `unmatched` and a
method outside the served set as `other`, so a caller cannot write a label
value. Operators should still reach this listener only from their own network,
since request rates per route are operational information. The series and
labels it publishes are in [Metrics reference](#metrics-reference).

The operator owns audit shipping, retention beyond `audit.retainDays`, backup,
restore, access control, and key rotation for the selected destination. A
deployment profile may require more reviewed metadata or retention, but it
cannot silently weaken the native privacy contract.

The audit master is expanded through HKDF into the key that computes
identifier pseudonyms. The subject-binding master is a separate secret
reference and must resolve to different bytes, so an audit pseudonym oracle
never becomes a subject-binding oracle, and the operator ceremony stays at two
independent masters.

Exactly one Evidence Gateway process may write a given audit file. The file
destination takes an exclusive OS advisory lock on `<audit.path>.lock` at
startup; a second process pointed at the same path fails at startup with a
sink-locked error rather than interleaving its writes with the first. Run more
than one replica by giving each its own `audit.path`, for example on a
per-replica volume, or by choosing `destination: stdout` and letting the
platform's log collector gather every replica's stream. Use a readiness probe
and restart-on-failure to recover from a crashed writer.

Appends and readiness probes check the pinned identity and modification
fingerprint of the active file and the lock file without rescanning the
growing file. Any external write, truncation, or replacement of either makes
readiness and every later append fail closed. A failed durable write stops the
writer for the life of the process: every later audited request is refused
with the generic `503` until the process restarts.

## Audit destinations, rotation, and retention

Every audit entry is one JSON line with exactly six members:

| Member | Meaning |
|---|---|
| `schema` | The entry schema: `registry.evidence.audit/v2`, `registry.evidence.audit.request-batch/v2`, or `registry.evidence.audit.authorization-refusal/v2`. |
| `eventId` | A random UUID the writer mints for this entry. |
| `time` | The writer's RFC 3339 UTC timestamp for the append. |
| `phase` | `request` for an access attempt, written before the source read it authorizes; `response` for every terminal record: a release, a denial, or a failure. |
| `correlation` | The server-minted operation identifier, equal to `record.operation`, shared by every entry of one operation. |
| `record` | The closed, minimized Evidence Gateway record described above. |

Entries are not chained. Each one stands alone, so a reader can validate any
line without the lines before it.

`audit.destination: file`, the default, appends to `audit.path` and returns
from an append only after the entry's bytes are synced to disk; appends that
arrive during one durable write share the next one. When an append would push
the active file past `audit.rotateBytes` (100 MiB by default), the writer
renames it to `<audit.path>.<sequence>`, where `<sequence>` is an ascending,
zero-padded, eight-digit number, and opens a fresh active file at
`audit.path`, online and with no operator action. When the writer opens and
each time it rotates, it deletes sealed files last modified more than
`audit.retainDays` ago (90 by default); it never deletes the active file.
The next sequence is recorded in `<audit.path>.seq` at every start and before
every rotation, so numbering continues across restarts even after retention or
a shipper removed every sealed file. It restarts only when `<audit.path>.seq`
is lost with them, as in a fresh or restored directory, and two replicas'
streams use the same names, so archives must still not be keyed on the sealed
file name alone. An entry larger than 1 MiB is refused, and the request it
belongs to fails closed.

A crash, a kill, or a full disk can leave the active file ending in part of an
entry. At the next start the writer copies the bytes after the last complete
line to the owner-only (`0600`) side file `<audit.path>.torn`, syncs it,
truncates the active file to its last complete line, and logs the side file's
path and byte count, never the bytes, at error level. An entry is acknowledged
only after the write holding it is synced, so the torn bytes belong to no
acknowledged entry and no acknowledged entry is lost. Inspect the side file and
archive it with the sealed files, then remove it: a later torn line finds the
side file holding other bytes and is refused until it is moved, because the
writer never overwrites it. A configured `audit.path` whose file name ends in
`.lock`, `.seq`, `.seq.tmp`, `.torn`, or a sealed-segment `.<sequence>` is
refused, because a stream at the shorter name owns that file.

Never rename, edit, or truncate the active file while the service runs; the
writer treats that as tampering and stops. Copying a sealed file is safe at any
time, since the writer reads no sealed file after it is sealed.

`audit.destination: stdout` writes each entry as one line on standard output
and flushes it. The serving process writes its operational records to
standard error whatever the destination is, so the stream carries audit entries
alone, each with its `schema` member. Durability, rotation, and retention then belong to the collector, and
an entry is accepted once the line is written to the stream. `evidence check
--require-audit-under` refuses a `stdout` destination, because there is no
local file to contain, and `evidencectl audit show` has nothing local to read.

Stop the service with SIGTERM, which is what a service manager and a container
runtime both send, or with Ctrl-C for an interactive process. The server stops
accepting connections, finishes the evaluations already admitted, completes
their audit writes, and exits successfully; `listener.shutdownGraceMilliseconds`
is the operational target for that drain rather than a cancellation boundary.
Exiting is also what releases the lock on `<audit.path>.lock`.

### Ship audit to append-only storage

Tamper evidence belongs to where audit entries end up, not to the file the
service writes. Ship sealed files, or the `stdout` stream, to storage the
Evidence Gateway host cannot rewrite: object storage with an object lock, a
write-once archive, or a log pipeline whose retention the Evidence Gateway operator
account cannot change. Ship each sealed file before `audit.retainDays` deletes
it, and confirm that the entries reached the store as part of backup, restore,
and incident procedures. Restore audit history into that store, never back
into the live audit directory.

### Audit key rotation

Every pseudonym carries the governed `audit.hashKeyVersion` in its
`hmac-sha256:v<version>:` prefix, so entries written under different key
material stay distinguishable in one log and rotation needs no fresh audit
path. The runtime cannot tell a replacement master from the one it replaces:
replacing the master bytes without incrementing `hashKeyVersion` silently
changes every pseudonym under an unchanged prefix. Rotate the audit master
only this way:

1. Generate a fresh independent audit master into a new secret file.
2. Build a governed bundle revision whose `audit.hashKeyRef` names it and
   whose `audit.hashKeyVersion` is one higher, and record the change, the
   bundle revision, and both versions in the change record.
3. Run `evidence check --require-runtime-dependencies` and the full handoff
   checks, restart the service, and route traffic only after readiness
   succeeds.
4. Keep the previous master under its governed secret controls for as long as
   pseudonyms from its epoch must be recomputable for an investigation.

## Listener placement

The Evidence Gateway API listener defaults to `networkExposure: private-address`,
which accepts only a numeric loopback, RFC 1918 private IPv4, or RFC 4193
unique-local IPv6 binding. A container deployment may explicitly declare
`networkExposure: container-private` and bind `0.0.0.0` or `::`. That mode is
an operator assertion about the container network and upstream TLS boundary;
it does not enable public serving. Concrete public addresses, hostnames, and
multicast addresses remain invalid. The optional metrics listener does not
inherit this exception and remains private-address-only.

## Metrics reference

This section describes what a configured `metricsListener` serves. It is
operator material: the public evidence contract and the generated OpenAPI
document do not describe it, and a deployment that leaves `metricsListener`
absent serves none of it.

```yaml
metricsListener:
  bind: 127.0.0.1:9090
```

`bind` is a `host:port` socket address whose host is a numeric loopback, RFC
1918 private IPv4, or RFC 4193 unique-local IPv6 address. Hostnames and
unspecified, multicast, and public addresses are rejected at startup, as are
port `0` and an address that repeats the evidence listener binding. Both listeners bind before either
serves, so a rejected telemetry binding fails startup rather than leaving a
service that reports healthy while publishing nothing. The two share one
lifecycle: the telemetry listener cannot outlive a failed evidence listener.

The listener serves `GET /metrics` and answers every other path with `404`,
including the evidence routes. The exposition is Prometheus text format,
declared as `Content-Type: text/plain; version=0.0.4`. Two request-boundary
series are published:

| Series | Type | Meaning |
|---|---|---|
| `evidence_http_requests_total` | counter | Requests served at the evidence boundary |
| `evidence_http_request_duration_seconds` | histogram | Duration of those requests |

The histogram publishes `_bucket`, `_sum`, and `_count`. Its upper bounds in
seconds are `0.005`, `0.01`, `0.025`, `0.05`, `0.1`, `0.25`, `0.5`, `1.0`,
`5.0`, and `+Inf`. They are fixed by the build and are not configurable.

Both series carry the same four labels, and each is drawn from a closed set
fixed by the deployed contract rather than by anything a caller sends:

| Label | Values |
|---|---|
| `route` | A registered route template, otherwise `unmatched` |
| `method` | `GET`, `POST`, `HEAD`, `OPTIONS`, otherwise `other` |
| `status` | `success`, `client_error`, `server_error` |
| `error` | A reviewed problem code, otherwise `none` |

The registered route templates are `/v1/evidence`, `/v1/evidence/batch`,
`/v1/evidence-definitions`, `/catalog.jsonld`, `/health`, `/ready`,
`/openapi.json`, `/.well-known/evidence/jwks.json`, and
`/.well-known/jwt-vc-issuer`. The
reviewed problem codes are the closed public set: `evidence.invalid_request`,
`request.selector_invalid`, `auth.invalid_credential`, `evidence.denied`,
`resource.not_found`, `format.unsupported`, `evidence.unavailable`,
`evidence.rate_limited`, `source.unavailable`, and `service.unavailable`.

`status` is the outcome class and never the exact status code, because the
exact status of a denial belongs to the closed public problem contract rather
than to operational telemetry. `error` carries the same reviewed problem code
the caller received, which makes a denial rate observable without making the
reason for any one request observable.

Because both label sets are closed, series cardinality is bounded by the route
table and the problem-code set regardless of traffic, and the registry needs no
eviction. A caller cannot create a series or write a label value: a path
matching no route is counted as `unmatched` and the requested path is never
recorded anywhere in the exposition.

An abbreviated exposition:

```text
# HELP evidence_http_requests_total Requests served by the Evidence boundary.
# TYPE evidence_http_requests_total counter
evidence_http_requests_total{route="/health",method="GET",status="success",error="none"} 2
evidence_http_requests_total{route="/v1/evidence-definitions",method="GET",status="client_error",error="auth.invalid_credential"} 1
# HELP evidence_http_request_duration_seconds Request duration at the Evidence boundary.
# TYPE evidence_http_request_duration_seconds histogram
evidence_http_request_duration_seconds_bucket{route="/health",method="GET",status="success",error="none",le="0.005"} 2
evidence_http_request_duration_seconds_sum{route="/health",method="GET",status="success",error="none"} 0.000241
evidence_http_request_duration_seconds_count{route="/health",method="GET",status="success",error="none"} 2
```

The registry lives in process memory. A restart resets both series to zero,
which a `rate` or `increase` query handles under the ordinary counter-reset
rule. Version 1 neither persists counters nor pushes them anywhere.

The telemetry listener performs no authentication of its own. The private
binding and the operator's own network are the only access controls, so the
operator must not route it through a public ingress or a shared scrape network.
Request rates per route and per problem code are operational information about
the registry even though no individual request is described.

The accepted address range is therefore a floor, not a boundary. Startup
rejects the mistake that actually exposes telemetry, a public or unspecified
`bind` host, but an accepted RFC 1918 or unique-local address only means the
endpoint is unreachable from the public internet. On a flat pod network or a
shared VPC every workload already holds such an address, so binding one there
makes the endpoint scrapable by every neighbouring workload. `127.0.0.1` with
a same-pod or same-host collector is the shape that keeps the operator
boundary the operator intended; any wider binding must be closed by a network
policy, and the operator owns that control.
`evidence_http_requests_total` and `evidence_http_request_duration_seconds`
describe the HTTP boundary only. Apart from the source shape-drift counter
below, Version 1 publishes no source-call, signing, or credential-acquisition
series. A slow or failing upstream source is visible as evidence-request
duration and as the problem code the boundary returned; signing, audit-writer,
and source-credential health are reported by `/ready` rather than by telemetry.

One unlabeled gauge is published on the same listener. It carries none of the
four request-boundary labels, since it reports a process-wide fact rather than
a per-request outcome, and it is resampled immediately before every scrape:

| Series | Type | Meaning |
|---|---|---|
| `evidence_rate_limiter_tracked_keys` | gauge | Pseudonym keys currently tracked by the rate limiter |

Operators should alert on `evidence_rate_limiter_tracked_keys` approaching the
100,000-key ceiling described under
[requester authority and purpose](#requester-authority-and-purpose), since a
deployment at that ceiling refuses new principals with a capacity error rather
than degrading gracefully.

Evidence Gateway publishes no audit series. Audit writer health is reported by
`/ready`, and disk use in the audit directory is bounded by
`audit.rotateBytes` and `audit.retainDays` and belongs to host monitoring.

One source counter is published, with one series per source the governed
bundle declares, each present from startup at `0`:

| Series | Type | Meaning |
|---|---|---|
| `evidence_source_shape_drift_total` | counter | HTTP source responses whose shape departed from the declared projection |

Its only label, `source`, is a source identifier from the governed bundle. The
set is fixed when the process starts, so neither traffic nor a source can add a
series. A response is counted once however many members drifted, and every
drifted response is counted, including those whose WARN record the interval
below suppressed. Alert on any increase: a source that renamed, dropped, or
retyped a member the projection selects fails or degrades every request that
reads it until the bundle is updated.

```text
# HELP evidence_source_shape_drift_total Source responses whose shape departed from the declared projection.
# TYPE evidence_source_shape_drift_total counter
evidence_source_shape_drift_total{source="civil-register"} 0
```

### Source diagnostics

A source failure reaches the caller as `source.unavailable` or
`evidence.unavailable`, which names no member and no cause, and the served
request's `category` field names only the failure class. Three `WARN` records
on the `registry_evidence::source` target say where the fault lies. They are
written to the same stream as the operational records and carry no operation
or trace identifier. Each names a governed source identifier, JSON pointers,
and closed reasons, and none carries a response value, a member name the bundle
did not declare, a selector, a subject, or a credential. The projection record
writes `*` for an array index, so it does not reveal how many items a response
held; the response-shape record points into the projected tree and may name an
index there.

| Message | Fields | Meaning |
|---|---|---|
| `the source response does not match its declared projection` | `source`, `violations`, `total_violations`, `suppressed` | The response drifted from the projection: a selected container is missing or is not the declared object or array, or a selected leaf is missing beside a member the projection does not select, which is what a rename leaves. `violations` lists up to five distinct findings such as `/records/*/region is absent beside an undeclared member`, and `total_violations` counts them all. |
| `the projected source response does not match its declared response shape` | `source`, `schema`, `violations`, `total_violations` | The projected response failed its `responseSchema`, for example an enumerated value outside the declared set or a member of the wrong type. Each violation pairs the member's pointer with the schema rule's pointer. |
| `source answered 404 with an undeclared shape` | `source`, `unresolved_problem`, `suppressed` | A 404 was not the source's declared unresolved outcome. `unresolved_problem` is `not declared` when the source declares no `unresolvedProblem`, or `not matched` when this response was not exactly the declared tuple. |

The caller's answer to an undeclared 404 stays `source.unavailable`. A distinct
code would tell the caller that the subject is absent at the source, which the
operator never declared as disclosable; `unresolvedProblem` is how an operator
declares it. The record is how an operator learns that a source answers "not
found" in a shape the bundle does not declare yet, or that a source rejected a
request it no longer understands, such as a field selection naming a member the
source removed.

The projection and 404 records are rate-limited: each is written at most once
per 60 seconds per source, and `suppressed` counts the events of the same kind
for that source that were not written since the previous record. A 404 that is
exactly the declared `unresolvedProblem` is an ordinary outcome and writes
nothing.

## Startup and readiness

Before production exposure, the operator runs:

```sh
evidence check --require-runtime-dependencies
evidence evaluate --fixture "<path>"
```

`evaluate` also accepts `--explain`, which prints the stages each fixture case
reached beside the unchanged result. It is offline-only, reports member names,
counts, identifiers, and declared forms rather than any value, and alters no
outcome, exit code, or message. Adding `--explain-format json` renders the same
trace as one JSON document, which then is the whole of standard output: the
summary line's verdict and evaluated-case count move inside the document rather
than trailing it, and the exit code and the operator message on standard error
are unchanged. See the fixture reference for what it prints.

Every command that reads a deployment takes `--runtime-config <absolute-path>`
after the subcommand; the maintained container image passes
`/etc/registry-evidence/runtime.yaml`. That file supplies the absolute
`package.root`. The earlier `--runtime` flag and `REGISTRY_EVIDENCE_RUNTIME`
variable are refused with the replacement named, so a stale invocation fails
instead of silently reading another file. Command-line or environment values
cannot override governed bundle fields. The runtime file, bundle directory, and every captured artifact must
be non-writable to the service process. Evidence Gateway Version 1 supports Unix targets
only because its secret and audit invariants require owner, mode, no-follow,
link-count, and open-file identity checks. A read-only mount is preferred;
directories use no write bits and files use no write bits. Fixture
paths are normalized, bundle-relative `fixtures/*.yaml` paths referenced by
exactly one requirement. A fixture path may be absent only under the explicit
local assurance profile.

The reference file-secret provider reads only regular, non-symlink files below
the configured `secretProviders.file.root`. The secret root is operator-only and
each secret file must be owned by the service identity with exact mode `0400` or
`0600`. Read-only container secret mounts commonly present `0400`; `0600` remains
valid when the operator stages owner-writable material before the service starts.
Audit and subject-binding secret files contain independently generated raw key
bytes, must each be at least 32 bytes, must use distinct references, and must
resolve to distinct bytes. They are not decoded as base64 by the file provider.
Source credentials retain their provider-defined lexical form. Local signing
material is an ES256 P-256 private JWK whose public projection exactly matches
`signing.activePublicJwkFile`. Production and evidence-grade runtime
configuration instead names a Transit Unix socket, mount, key name, pinned
nonzero version, and bounded timeout. Transit metadata must report
`ecdsa-p256`, signing enabled, `derived=false`, `exportable=false`, and
`allow_plaintext_backup=false`, and its public key must exactly match the
governed active public JWK. Only active and published non-revoked public keys
appear at the JWKS endpoint. A file audit destination must be on storage whose
append durability, permissions, capacity, backup, and restore the operator
owns, and the operator ships its sealed files to append-only storage.

`evidence check` validates and compiles the complete bundle, and resolves and
validates the mounted audit, subject-binding, and signer exactly as startup
does, including the asynchronous provider sign-and-verify test, without
opening the audit destination. A deployment whose secret or provider material
startup would refuse, including a signer whose public key differs from
`signing.activePublicJwkFile`, fails check. Source credentials are not
resolved by check; readiness owns them. Fixture evaluation
covers positive, negative, boundary, missing-data, source-failure,
existence-disclosure, and anti-reconstruction behavior without a running
source.
 `evidence check --require-runtime-dependencies` is the pre-routing container
form. In addition to what `evidence check` verifies, it opens and verifies the
audit writer, requires the signer self-test, resolves source credentials
without sending an evidence-data request, and requires the configured
access-token JWKS endpoint to provide a usable key set. This fail-closed
preflight does not change normal
serving readiness, which retains its bounded issuer-outage behavior.

Adding `--require-audit-under <absolute-directory>` proves one further
property: that the file destination `audit.path` resolves at or below a directory the
deployment declares persistent. Evidence Gateway canonicalizes the declared root and
the deepest existing ancestor of the configured destination before comparing,
so a destination outside the root, and a symlink inside the root that leads to
ephemeral storage, both fail closed. The option requires
`--require-runtime-dependencies` and relaxes nothing: the audit writer still
has to open and lock. A `stdout` destination fails the option, since it has no
local file to contain. Whether the declared root is durable storage is
the deployment's responsibility, not Evidence Gateway's; Evidence Gateway resolves its own
configured destination and never inspects mounts. Failures name which side
failed and no path.

Adding `--without-audit-lock` checks a candidate staged beside the running
instance it will replace, which shares its audit path and so holds the
single-writer lock by design. The option requires
`--require-runtime-dependencies`. The audit boundary is proved without taking
that lock: the audit destination settings and hash key, an owner-controlled
directory the service user can write, and an existing active file and lock
companion that are owner-only, singly linked, and writable, with an active file
whose final entry is complete or ends in a torn line `serve` would move to its
side file. Every other dependency is proved as without the
option, and no audit entry is appended. It does not detect a second writer, so
`serve` still refuses to start while another instance holds the lock; without
the option, a held lock refuses the check with `another process holds the
single-writer lock beside the audit file`.

For `assuranceProfile: local`, a supervised issuer may use the exact canonical
issuer origin `http://127.0.0.1:<non-zero-port>` only when `jwksSource.uri` is the
same origin plus `/.well-known/jwks.json` or `/oauth2/jwks`. Production and evidence-grade, and
every other authentication location, remain HTTPS-only.

`disclosureGuard.families` is a trusted bundle-review attestation, not a
domain-semantic classifier. The runtime rejects two simultaneously enabled
requirements with the same declared family. It cannot infer that differently
labelled families are semantically equivalent without adding forbidden domain
policy to the generic core. Operators must therefore review the complete
bundle for threshold ladders, overlapping partitions, relationship graphs, and
equivalent definitions before assigning distinct family identifiers. The
anti-reconstruction fixtures record that reviewed decision.

`observed_at` is supplied by the runtime and normalized to UTC. Rust derives
`legal_local_date` and `legal_local_time` from the requirement's optional IANA
`observationTimezone`; omission uses UTC. Requirements whose result depends on
local legal time should declare the timezone explicitly and include fixtures
on both sides of relevant date, time, daylight-saving, and offset boundaries.

The operator starts the reviewed revision with:

```sh
evidence serve
```

Startup confirms that the immutable bundle compiled, runtime ownership and
every local path/trust binding validated, mounted secret files and signer
metadata parsed, the active public key matched, the signer completed its
sign-and-verify test, and the audit writer opened. Readiness
rechecks the subject-binding key, signing provider, pinned audit writer, and every source
credential. Basic, static Authorization header, and static API-key credentials
are checked locally. OAuth client-credentials readiness performs its bounded token
bootstrap against the configured token endpoint.
An explicit local source with `authentication.kind: none` has no credential
check or bootstrap and sends no authentication header. Production and
evidence-grade bundles reject that source kind at startup.
Neither startup nor readiness sends an evidence-data request or probes a source
data endpoint. Readiness
fails when a required local runtime or bundle input, selector binding,
credential, CA binding, audit dependency, or signing dependency is absent,
mutable, or invalid.

A statement source holds no credential, so readiness has nothing to bootstrap
for one. How old its mounted extract is still does not decide readiness. An
extract past its source's `maximumExtractAgeSeconds` refuses every evaluation
that reads it, with `source.unavailable` at the boundary and the
`source-extract-stale` audit category, while `/ready` stays `200` and the
requirements on other sources keep being served. Every replica may mount the
same file, so removing all of them from rotation would turn one stale source
into a full service outage. The deployment preflight is
`evidence check --require-runtime-dependencies`: it refuses an extract that is
already stale before traffic is routed. A later
transition to stale remains visible through the safe startup diagnostic and
audit category. Version 1 operator conformance additionally requires every
stale-extract fault to identify only the governed source or extract profile,
never the publisher's `extractId`, filesystem path, or another metadata value.
Alert on that safe diagnostic and audit category, not on readiness. The cure is
to publish a fresh extract and restart. Startup itself does not refuse an
already-stale extract because a restart racing a republish would otherwise
crashloop.

The access-token issuer's `jwksSource.uri` is retrieved once at startup and again on
each readiness check, subject to the verifier cache lifecycle and a short
suppression interval after a failure. Both report and neither refuses: a
`jwksSource.uri` that cannot be used is named in the log at startup rather than
discovered one rejected request at a time, but the issuer is a shared
dependency this deployment does not own, so an issuer outage does not withhold
its readiness or prevent it from starting. A key set already retrieved keeps
being accepted for a bounded allowance past its cache lifetime while the issuer
is unreachable, so a brief issuer outage does not turn into total rejection
here; once that allowance runs out, every request is rejected with the same
closed `401` a bad token receives, and the reason appears only in this
deployment's log.

The native operations are:

```text
GET /v1/evidence-definitions
POST /v1/evidence
POST /v1/evidence/batch
GET /.well-known/oauth-protected-resource
GET /health
GET /openapi.json
GET /ready
GET /.well-known/evidence/jwks.json
GET /.well-known/jwt-vc-issuer
```

`GET /openapi.json` publishes the generated public contract as
`application/json`. It carries no credential requirement because the
served bytes are the released generated artifact: the same document shipped in
`products/evidence/generated/`, independent of the deployed bundle.

`GET /.well-known/oauth-protected-resource` publishes the closed RFC 9728
resource document for `service.publicOrigin`. It names that exact origin, one
authorization-server issuer, the Evidence Gateway JWKS URI, and header-only Bearer
transport. It is cacheable for at most ten minutes with a strong ETag and
supports exact `If-None-Match` revalidation. Protected-route `401` responses
link it through `WWW-Authenticate`. The document is public routing metadata,
not an entitlement catalog or a trust decision by itself.

A successful `GET /v1/evidence-definitions` response uses `application/json`
and the closed requester-scoped definition schema. It requires the same strict
Bearer authentication profile and per-principal request budget as evidence
creation.

A successful `POST /v1/evidence` response uses `application/jose+json` and the
flattened JWS JSON Serialization unless the requester selected another enabled
format under [response formats](#response-formats). No public or
cross-requester catalog is supported.

A successful `POST /v1/evidence/batch` response uses only
`application/vnd.registrystack.evidence.request-batch+json`. The closed
`registry.evidence-request-batch/v1` envelope preserves request order and has
one `evidence` or `evidence_not_available` result per item. A request must send
that exact `Accept`; missing, wildcard, singular, parameterized, combined, or
weighted values return the existing `format.unsupported` problem
before source access.

`GET /.well-known/jwt-vc-issuer` is unauthenticated discovery for the SD-JWT VC
format. It publishes the exact configured provider identity as `issuer` and
that origin plus `/.well-known/evidence/jwks.json` as `jwks_uri`, and nothing
else. It does not inline the key set. Outside local assurance, enabling the
format requires `service.providerId` to be a stable HTTPS origin. Metadata is served
whether or not any grant enables the credential format, it never reveals which
requesters or requirements do, and it is discovery rather than a trust anchor
on exactly the terms in [secrets and keys](#secrets-and-keys).
No-match and ambiguous outcomes are publicly indistinguishable by default.
An HTTP source may additionally declare one exact unresolved Problem Details
tuple. Only the closed exact response becomes public evidence unavailable at a
singular or search stage; its neutral audit decision is `unresolved`, not
`no-match`, because the upstream result may be hidden or ambiguous. The problem
body, type, code, detail, and trace are never recorded. A mismatch and an
unresolved fetch after unique search are dependency failures.
Source, signing, and dependency failures use stable safe problem codes and do
not reflect protected inputs. Signing failure returns a safe transient failure.

Every authorization refusal after successful authentication collapses to one
generic problem with code `evidence.denied` and HTTP 403 and reveals no layer
detail: a principal outside the bundle audience, a requirement no matched grant
permits, an authority the grant does not carry, and an unsigned-envelope request
the bundle or grant does not allow all return the same body. This is deliberate;
the response is not an oracle for which check failed. Because the wire response
is intentionally uninformative, operators debug a 403 from trusted local state,
not from the response. Confirm, in order: the Bearer principal is in the
deployed bundle's audience; a grant matches the requested requirement, purpose,
and subject roles; the grant carries the claimed authority; and, only for an
unsigned request, both the bundle and that grant permit
`application/vnd.registrystack.evidence-unsigned+json`. Before returning that
problem, the audit writer durably records the minimal refusal event under
its server-minted operation identifier. It proves that the authenticated requester
was refused without recording which request field or authority check failed.
The caller never sees the event. If the audit append fails, Evidence Gateway returns
the generic `service.unavailable` problem with HTTP 503 instead. Authentication,
malformed-request, and invalid-selector failures are operational-only and do not
create this event.

## Measured throughput

This end-to-end measurement drives the real router over real sockets. Every
request runs token verification, rate limiting, Rhai request preparation, one
outbound source call, Rhai extraction, evidence construction, in-process ES256
signing, and both durable file audit appends. It does not model an external
Transit deployment's latency or availability.

The test copies `products/evidence/fixtures/acceptance/all-definitions` into a
temporary deployment and drives its adult-status request. It uses a local
signer, a preconfigured test authenticator, and the actual file-backed audit
writer with group commit, writing a temporary `audit.jsonl`. No audit sink is
injected. The fixed upstream JSON and raised fixture limits below make this a
controlled local journey, not a production workload.

These figures come from a shared development machine and are directional,
not deployment capacity claims. Unrelated compilation overlapped this run;
they do not establish a performance change from earlier measurements.

| Measurement | Value |
|---|---|
| Sustained rate | 5,461 requests/second |
| Audit appends | 10,923 appends/second (two per request, independently rounded) |
| Latency p50 / p95 / p99 | 20.07 / 44.82 / 64.75 ms |
| Non-2xx responses and failures | 0 |
| Offered concurrency | 128 requests in flight, 128 principals |
| Window | 10 s measured, after a 3 s unmeasured warm-up |
| Host | Apple M5 Max, 18 logical cores, macOS 26.4.1, optimized build |
| Host load averages, start / end (1, 5, 15 min) | 31.11, 28.39, 23.68 / 45.46, 32.14, 25.17 |
| Unrelated compiler processes, start / end | 1 Cargo and 17 rustc / 1 Cargo and 8 rustc |
| Source revision | `8821b3a67` |
| Date | 2026-09-25 |

Reproduce with:

```bash
cargo test --locked --release -p registry-evidence --lib -- \
  --ignored --nocapture sustained_load_holds_one_thousand_requests_per_second
```

The release test was compiled before the timed window. The check passed and
verified one access-attempt and one disclosure-release audit record for every
released assertion. Absolute rate and latency on this shared host remain
directional observations, not acceptance gates for the audit simplification.

The harness serves the upstream source from a minimal in-process handler
returning one constant JSON body. It measures that handler's standalone
ceiling in the same run, with the same client, worker count, header set, and
window. The source sustained 130,722 requests/second with no failures, 23.9
times the Evidence Gateway rate. Below five times the Evidence Gateway rate, the check reports
an inconclusive result because the source harness may be the bottleneck.

Latency is a closed-loop consequence of the offered concurrency. A deployment
with fewer requests in flight sees a different latency and rate. The audit
writer commits in groups, so concurrent appends can share a write and fsync;
this measurement does not represent the per-record cost at low concurrency.

The harness raises four defaults only in its temporary fixture bundle: the
per-principal rate limits, `maximumConcurrentRequests`, each source's outbound
`concurrencyLimit`, and the audit file's `rotateBytes`. Those values are
measurement scaffolding, not a recommended deployment posture. Keep the
shipped defaults and tune from observed traffic.

## Capacity planning

The rate in the Measured throughput section is one host with one constant
source. Sizing a real deployment is a matter of finding which ceiling binds
first, and for most
deployments it is not Evidence Gateway.

Outbound source concurrency binds first whenever the provider is slower than
the in-process handler used for measurement. Each source's `concurrencyLimit`
is the number of requests Evidence Gateway will have outstanding to that source at
once, so sustained throughput through it is about `concurrencyLimit` divided by
the source's round-trip latency. A `concurrencyLimit` of 8 against a provider
answering in 20 ms sustains roughly 400 requests/second, and Evidence Gateway being
capable of thousands changes nothing about that. The field accepts 1 to 256. Inline sources state it explicitly.
A named connection owns one aggregate `concurrencyLimit` across its operations,
with a conservative default of 4; a resolved source repeats that exact value.
Size the connection limit against the provider's tolerance for the whole
workload, and include every Evidence Gateway process when estimating provider load.
Raising it moves load onto the provider, so raise it against the provider's own
documented or agreed limit, not against Evidence Gateway's spare capacity.

`listener.maximumConcurrentRequests` is the admission ceiling, from 1 to 4096.
It is a semaphore over evaluations already accepted, not a connection limit and
not an instant refusal: a request arriving with every slot taken waits for one
within whatever remains of `listener.requestTimeoutMilliseconds`, and receives
a `503` problem response only if the budget runs out first. Two sizing errors
follow from that. Set well below the source concurrency, it leaves provider
capacity unused, since Evidence Gateway will not have enough evaluations in flight to
keep the source busy. Set far above what the sources can absorb, it does not
add throughput; it converts overload into queueing, which the caller sees as
rising latency and then as timeouts. Size it near the total concurrency the
configured sources can actually sustain, and treat `requestTimeoutMilliseconds`
as the decision about how long a caller should wait before being turned away.

Two ceilings are not configured fields. Worker threads follow the host's
available parallelism, so vertical scaling changes the ceiling that CPU-bound
work, signing and Rhai evaluation, imposes; the runtime document does not carry
a thread count. Offered concurrency is not yours at all: it is what callers
send. The levers here bound what is admitted and what is dispatched onward,
never how much arrives.

Throughput below expectations is therefore diagnosed by finding the binding
ceiling before changing anything, and the `error` label on
`evidence_http_requests_total` separates the three rejections: `evidence.rate_limited`
is the per-principal limiter, `service.unavailable` is the request timeout
budget running out, which under load is normally a request that never got an
admission slot, and `source.unavailable` is the source failing rather than
merely being slow. A saturated but healthy source produces
none of those. It appears only as `evidence_http_request_duration_seconds`
rising while the request count stays flat, because Evidence Gateway is waiting on the
provider and reporting success when the answer arrives; confirming that
diagnosis needs source latency observed at the provider, which is why the
`concurrencyLimit` arithmetic is worth doing before traffic rather than
after. Because the audit sink commits in groups, a deployment held to few
requests in flight also pays a higher per-record audit cost than the table in
the Measured throughput section, which is a consequence of the low concurrency
rather than a separate problem to tune.

## Verification and release limit

A relying party or operator re-verifies a stored signed response offline with
`evidence verify --jws <file> --jwks <file> --policy <file> [--at <rfc3339-utc>]`.
The pinned JWKS file is the complete trust set and the policy document carries
every expectation from independent trusted state: the retained request nonce,
the expected assurance profile, role-bound subject bindings, output contract,
and explicit `revokedKeyIds` denylist under
[`contracts/verification-policy.schema.yaml`](https://github.com/registrystack/registry-stack/blob/v0.38.0/products/evidence/contracts/verification-policy.schema.yaml).
A denied identifier fails before a key is selected even if the pinned file
still contains it.
The command performs no network access, reports cryptographic authenticity
separately from current validity, and exits 0 only when both hold; an
authentic but expired response exits 3. Every failed policy comparison reports
one generic class so verification is not an oracle.

Operators must verify a candidate revision with the applicable phase and final
commands in [AGENTS.md](https://github.com/registrystack/registry-stack/blob/v0.38.0/products/evidence/AGENTS.md). Public-demo source tests are optional,
ignored, read-only, local, and non-gating. They may run only after deterministic
mocks pass and only with approved synthetic selectors and securely stored
credentials under [the source-testing contract](../source-testing/).

Evidence Gateway Version 1 is releasable only when all four coequal acceptance
definitions pass the complete offline and HTTP path, all Definition of Done
rows are green on one revision, generated contracts reproduce exactly, and the
security acceptance matrix is reviewed. Implementation stops at that boundary.
Future profiles require a separately approved concept and plan.