Evidence tooling, ingestion and security¶
This page covers the pacto evidence command group — how a remote environment
mints keys, signs a Pacto EvidenceSet, reports it and how a platform verifies
and ingests it — the HTTP surface the ingestion host serves, plus the security
invariants the protocol upholds. For the wire format itself, see the
external evidence protocol.
The commands split cleanly by side of the trust boundary:
| Command | Side | Purpose |
|---|---|---|
pacto evidence keygen |
producer | Mint an Ed25519 signing keypair. |
pacto evidence sign |
producer | Wrap an EvidenceSet in a signed envelope. |
pacto evidence send |
producer | Report a signed envelope outbound to an ingestion endpoint. |
pacto evidence verify |
either | Verify a signed envelope against a trust store. |
pacto evidence serve |
platform | Run the ingestion endpoint that accepts, verifies and evaluates envelopes. |
Minting a keypair¶
This writes two files into --out:
<keyId>.key— the base64 32-byte Ed25519 seed, written0600(owner read and write only). This is secret material. Keep it in the environment that produces evidence; never commit it, never ship it to the platform.<producer>__<keyId>.pub— the base64 public key, written0644. The filename binds the key to that producer in the trust store, so the platform authorizes it only for evidence signed as that producer. This is the file you hand to the platform operator; you do not need to know or apply the filename convention by hand —--producerwrites it for you.
With no --producer, the key is bound to a producer named after the key id and the
file is a bare <keyId>.pub (the single-producer default). With no --key-id, the
key id defaults to a short fingerprint of the public key. Sign with the same
--producer and --key-id you minted the key with. sign will not stop you
naming a different producer, but the platform will: verification and ingestion
reject the envelope as producer id is not authorized for this key
(401 unauthorized_producer).
Signing an EvidenceSet¶
pacto evidence sign evidence.json \
--key ./keys/edge-eu-west-2026.key \
--key-id edge-eu-west-2026 \
--producer edge-eu-west \
--sequence 1 \
--ttl 24h > envelope.json
sign reads evidence.json — an EvidenceSet in its native shape (top-level
keys Subject, ContractRef, Source, ObservedAt, Observations; unknown
keys are rejected) — validates it, wraps it in an envelope and prints the signed
envelope JSON. Key flags:
--key(required) the private-key file,--key-idthe trust-store key id it maps to,--producerthe environment id.--sequencethe producer-scoped monotonic counter (default0). Every report must carry a strictly greater value than the producer's last accepted one, or ingestion rejects it as a replay (409) — re-signing the same evidence under a fresh envelope id does not help. A producer that reports more than once has to persist this counter and increment it across runs.--producer-versionan optional producer or collector version, carried on the wire asproducer.version.--ttlthe validity window (default 24h;0disables expiry).--idand--issued-at(RFC3339) pin those fields for a fully deterministic envelope; otherwiseissued-atdefaults to now andidto a hash over the protocol version,--producer,--sequenceand the evidence.
The signed envelope is a wire artifact, so sign always emits exact JSON
regardless of --output-format.
Reporting an envelope¶
send POSTs the signed envelope to POST /api/evidence/v1/envelopes at the
target host. This is the only outbound call a producer makes. The host verifies
and evaluates the envelope and returns 202 Accepted on success or a status that
names the failure category (see the protocol status codes).
Verifying an envelope¶
verify decodes an envelope and checks its signature, freshness, producer
authorization and trust against --trust, which is either a single public-key
file or a directory of <producerId>__<keyId>.pub files (a bare <keyId>.pub
binds the producer to the key id). Each key is authorized for exactly one
producer, so a trusted key cannot sign as another. It exits non-zero when
verification fails, so it drops straight into CI.
The output reports the envelope id, producer and key id on success, or the
sanitized failure reason on failure.
Structured trust configuration¶
The bare-.pub mode binds a key only to a producer; it cannot express the
per-key subject and contract-repository allowlists the verification and
ingestion layers already enforce. To configure those, point --trust at a
versioned YAML trust config instead (a file ending in .yaml/.yml):
apiVersion: pacto.dev/evidence-trust/v1
keys:
- keyId: edge-eu-west-2026
producerId: edge-eu-west
publicKeyFile: edge-eu-west__edge-eu-west-2026.pub # a bare filename in this config's directory
allowedSubjects: # path.Match globs; empty = any subject
- payments-*
allowedContractRepos: # bare registry/repo prefixes; empty = any repo
- registry.example.com/acme/contracts
Loading validates the schema version, the identifier grammar, duplicate key ids,
contradictory producer bindings for one key file, missing/traversing key files
and malformed subject/repository patterns. Because publicKeyFile resolves relative to
the config's directory, a Kubernetes Secret mounted as a directory containing the
config plus its .pub files can be pointed at directly. The bare-.pub mode
remains supported but stays deliberately limited to producer binding — it does not
enforce scopes it cannot express.
Running the ingestion endpoint¶
pacto evidence serve \
--trust ./trust \
--subject oci://registry.example.com/acme/payments-api@sha256:1a2b…
serve starts the ingestion host: it loads the trust store from --trust,
resolves every --subject (repeatable, at least one required) in the contract
registry, then evaluates each accepted envelope against the contract its
ContractRef resolves to and publishes the result to that registry as an OCI 1.1
referrer of the subject. It holds nothing locally — there is no store directory,
bucket or data volume. It listens on 127.0.0.1:<--port> (default 8686) or on
--listen-address host:port, advertises the producer ids configured with
--producer at GET /api/evidence/v1/producers and reports readiness at
GET /api/evidence/v1/ready (503 until every subject resolves and answers
native Referrers discovery). Registry access uses Pacto's normal OCI credential
resolution — there is no evidence-specific login. TLS termination is the host's
responsibility — run serve behind a TLS-terminating proxy or gateway. Signature
verification is always on and cannot be disabled.
A registry on localhost, a loopback address or a private IP range is reached over
plain HTTP automatically. Any other registry served over plain HTTP — an
in-cluster registry Service name, for example — must be named in the
PACTO_INSECURE_REGISTRIES environment variable, a comma-separated list of
host:port values. It is scoped to exactly the hosts you list and never
downgrades an HTTPS registry. Without it, subject resolution fails and serve
never becomes ready.
For registry requirements, permissions, retention and failure semantics, see evidence in the registry; for how the server is deployed, see deployment.
The ingestion endpoints¶
The ingestion host mounts five endpoints under /api/evidence/v1.
| Method + path | Purpose | Success |
|---|---|---|
POST /api/evidence/v1/envelopes |
Accept, verify, de-duplicate, evaluate and store one envelope. | 202 Accepted with { id, compliance, findings, acceptedAt }, where findings is a count, not the findings themselves |
GET /api/evidence/v1/health |
Liveness. Independent of the registry. | 200 OK with { "status": "ok" } |
GET /api/evidence/v1/ready |
Readiness. 503 until every configured subject resolves and answers native Referrers discovery. |
200 OK with { "status": "ready" } |
GET /api/evidence/v1/producers |
List the trusted producer ids the host advertises. | 200 OK with { "producers": [ … ] } |
GET /api/evidence/v1/targets |
The latest accepted report per target — a target being one producer reporting on one subject — for a read-only HTTP evidence source. | 200 OK with the versioned targets DTO (below). |
The latest report per target¶
The /targets response is a versioned, self-describing DTO so a consumer can
reconstruct a faithful operational target — not a lossy summary — and can tell a
degraded store from a healthy one:
{
"schemaVersion": "pacto.dev/evidence-source/v2",
"generatedAt": "2026-07-29T12:00:00Z",
"health": { "status": "ready", "subjects": 3, "failedSubjects": 0, "invalidArtifacts": 0 },
"truncated": false,
"targets": [
{
"subject": "payments",
"service": "payments-api",
"domain": "registry.example.com/acme",
"digest": "sha256:…",
"producer": "prod-eu",
"producerKeyId": "edge-eu-west-2026",
"compliance": "Compliant",
"coverage": { "evaluated": 3, "required": 5 },
"findings": [ … ],
"contractRef": "oci://registry.example.com/acme/payments@sha256:…",
"evidenceAt": "2026-07-29T11:00:00Z",
"acceptedAt": "2026-07-29T11:05:00Z"
}
]
}
Each target carries its findings — the findings key is omitted entirely when
there are none — the immutable contractRef (so it links to a concrete
revision), both the evidence and accept timestamps, and producer provenance.
service, domain and digest are the resolved logical identity, read from
the contract contractRef resolved to, so a consumer attaches the target to the
right domain-qualified service and revision instead of inferring one from
subject; domain is everything in the resolved reference before its final path
segment, such as registry.example.com/acme.
Completeness and health¶
schemaVersion is the compatibility contract: a
consumer that does not recognise it treats the source as unavailable rather than
misreading it. health.status is ready (every configured subject read
completely, so an empty target list is authoritative) or partial (evidence
exists that could not be read, so absence no longer is), with the counts behind
that verdict beside it. When nothing could be read at all, /targets does not
return this DTO — it answers 503 with { "code": "registry_unavailable" }, so
a consumer must treat any non-200 as an unavailable source rather than an empty
one. health and truncated let a consumer mark the source partial — keeping
the usable targets while surfacing that the contribution is incomplete — instead
of presenting a full-looking graph. Both the target count and the per-target
findings count are bounded; truncated is set when either bound trims the
response.
POST status codes¶
POST status codes map the accept outcome without leaking any secret material:
A non-2xx response is a JSON object { "code": <stable-code>, "message": <generic> }.
The code is stable and safe to branch on; the message is generic and never
contains the underlying error text (resolver, storage or internal detail). Detailed
errors are logged server-side only. The codes and their statuses:
| Status | Code | When |
|---|---|---|
202 Accepted |
— | Verified, evaluated and stored. |
400 Bad Request |
invalid_envelope |
The body could not be read or the envelope could not be decoded. |
401 Unauthorized |
unauthorized_producer |
Signature/trust/freshness failure, or the key is not authorized for this producer or subject. |
409 Conflict |
replay |
A duplicate id or an out-of-sequence sequence. |
422 Unprocessable Entity |
contract_ref_rejected |
The contract ref is not an approved immutable digest reference. |
422 Unprocessable Entity |
invalid_evidence |
The EvidenceSet is invalid. |
502 Bad Gateway |
contract_resolution_failed |
The referenced contract could not be resolved (upstream). |
503 Service Unavailable |
store_not_ready |
The server has not yet resolved and enumerated its configured subjects. |
503 Service Unavailable |
registry_unavailable / registry_incomplete |
The accepted history could not be read, or could not be read completely, so the replay check could not run. |
503 Service Unavailable |
store_degraded |
The record could not be published to the registry. |
500 Internal Server Error |
internal_error |
An unexpected failure. |
After a successful accept, the host can trigger a snapshot refresh so the new target appears in the graph immediately.
Security invariants¶
The protocol and its tooling hold a small set of non-negotiable invariants.
- No secret material in logs or errors. Verification failures return typed sentinel errors whose messages never contain private keys, public keys or raw signature bytes. The ingestion API echoes only a failure category, never the offending material.
- Sanitized, categorised failures. Every accept outcome maps to a stable
HTTP status —
400for a body that could not be decoded,401for signature, trust or freshness failures,409for replay,422for an unevaluable payload or a rejected contract reference — with a generic message. A caller learns what class of thing went wrong, never internal detail it could exploit. - Bounded payloads. The ingestion endpoint reads the request body through an
io.LimitReaderone byte past the 1 MiB cap, so an oversized payload is refused before it is parsed; decoding then rejects any envelope carrying more than 10,000 observations. Neither a hostile nor a runaway producer can exhaust memory at the boundary. - Mandatory verification. An unsigned envelope, an unknown key, an unsupported algorithm or a bad signature is always rejected. Transport security (TLS) is the host's job and is additive; it never substitutes for signature verification.
- Replay protection. A duplicate id or a non-increasing per-producer sequence is rejected, so re-sent or reordered reports never regress a target to older state. It is enforced inside the serialized commit, over a history re-enumerated from the registry every time, so it survives process and pod restarts and applies globally across subjects — a producer's sequence is the producer's, not the contract's (see evidence in the registry).
- Reads fail honest, writes fail closed. A store that could not be read
completely is reported as
partial; a store that could not be read at all is refused outright with503 registry_unavailablerather than served as an authoritative empty result. Ingestion refuses while the accepted history cannot be fully reconstructed — a replay check over a partial history is not a replay check. - Single active writer. Exactly one, enforced operationally (one replica plus
the
Recreaterollout strategy) rather than with a distributed lock, because the registry offers no compare-and-set two writers could agree on. - Registry write access is evidence write access. The producer signature is verified once, at ingestion; the read path does not re-verify it. Anyone who can push to a configured contract repository can add a record Pacto serves, so scope that credential as tightly as the right to publish a contract.
- Evidence is never deleted by Pacto. It is the audit trail, and Pacto only ever pushes. Because the artifacts are deliberately untagged, a registry garbage-collection policy that prunes untagged manifests will remove them — exclude referrers of contract manifests from any such policy.
- Trust store is a read-only mount. In the operator-managed deployment the
trust store is a Secret of
<producerId>__<keyId>.pubkeys mounted read-only (evidence.trust.existingSecret), each key bound to exactly one producer so a trusted key cannot sign as another. Distributing it stays an out-of-band operator responsibility; the server never fetches keys. - Private keys are
0600.keygenwrites seeds owner-only. Treat the.keyfile as a secret; only the.pubcrosses the trust boundary to the platform.
See also¶
- External evidence protocol — the wire format, canonical bytes, freshness and the ingestion API
- Operational graph — how ingested evidence becomes an operational target
- CLI reference — the full command surface