Guided tour¶
Six things people actually need from a fleet they did not build, answered against one fixture, in the order the questions arrive. Each story is one or two commands and what its output means.
Stories 1 to 5 are terminal commands whose transcripts were produced by running
the command shown above them. make gen-demo-transcripts regenerates them and
make docs-check compares them against the committed tree, so a change in what
the CLI prints fails a gate rather than quietly making this page fiction. Story 6
shows output copied in by hand, because no generator covers a server that holds
stdin open. Every command in the tour runs in
tests/acceptance/local/demo-arc.sh, which asserts on the bytes.
Before you start¶
Everything here is offline except pacto dashboard. No cluster, no registry, no
running service and no network — the fixture is committed to the repository, so
it works on a plane. The dashboard probes for a kubeconfig and for OCI
repositories while it boots; it runs without either.
You need the Pacto CLI on your PATH, and a clone, because
the fixture lives in it:
Every command below is written to be pasted from the repository root.
To drive a real stack instead — an OCI registry with published revisions, an Evidence Server ingesting a signed envelope and the dashboard on top — see the Docker Compose demo, which needs no clone and no CLI. To point your own MCP client at one of these bundles, see Connecting to a bundle.
The fleet¶
Sixteen services are in the snapshot. These eight carry the stories:
| Service | Owner | Its part |
|---|---|---|
payments-service |
payments-team | six revisions; the change stories 3 and 4 analyse |
orders-service |
commerce-team | declares a dependency on payments, and is observed calling it |
api-gateway |
platform-foundations | declares the same dependency, and has an expired readiness assessment |
auth-service |
identity-team | deployed, but never observed — the Unknown target |
fraud-service |
payments-team | evidenced and conformant — the Compliant target |
audit-log |
platform-foundations-security | calls payments and declares nothing — the shadow consumer |
frontend |
frontend-team | reaches payments transitively, through the gateway |
pacto-demo |
platform-foundations | one more hop out, so the blast radius has depth |
A contract says what a service exposes, what it needs and how it behaves — never
how it is deployed. Here is the shape of the one story 3 refuses,
payments-service at 2.0.1:
pactoVersion: '2.0'
service:
name: payments-service
version: 2.0.1
owner: { team: team/payments }
interfaces:
- { name: http, type: openapi, ref: interfaces/openapi.json, visibility: internal }
- { name: events, type: asyncapi, ref: interfaces/events.json, visibility: internal }
capabilities:
- type: health
binding: { type: http, interface: http, path: /healthz }
configurations:
- { name: platform, ref: 'oci://ghcr.io/trianalab/pacto/platform-app-config', required: true }
policies:
- { name: http-security, ref: 'oci://ghcr.io/trianalab/pacto/platform-http-policy' }
dependencies:
- { name: postgresql, ref: 'oci://ghcr.io/trianalab/pacto/postgresql', required: true, compatibility: ^16.0.0 }
- { name: fraud-service, ref: 'oci://ghcr.io/trianalab/pacto/fraud-service', required: true, compatibility: ^1.0.0 }
workload: service
state:
type: stateful
persistence: { scope: shared, durability: persistent }
dataCriticality: high
metadata:
tier: domain
criticality: high
summary: Payment Intents API with mandatory fraud detection - BREAKING from v1.x
charges API
Nothing above says how the service is deployed, and metadata is not decoration:
the http-security policy this contract references requires it, which is why
abridging it any further would fail validation. The
contract reference has every section.
Story 1 — "I inherited this fleet and I do not know what is in it"¶
Enumerate everything from contracts alone, then find out what state it is in.
16 of 16 service(s):
api-gateway NotEvaluated owner=platform-foundations revs=3 targets=0
audit-log NotEvaluated owner=platform-foundations-security revs=1 targets=0
auth-service NotEvaluated owner=identity-team revs=1 targets=0
email-provider NotEvaluated owner=- revs=1 targets=0
fraud-service NotEvaluated owner=payments-team revs=1 targets=0
frontend NotEvaluated owner=frontend-team revs=1 targets=0
notification-worker NotEvaluated owner=commerce-team revs=1 targets=0
orders-service NotEvaluated owner=commerce-team revs=3 targets=0
pacto-demo NotEvaluated owner=platform-foundations revs=1 targets=0
payments-service NotEvaluated owner=payments-team revs=6 targets=0
platform-app-config NotEvaluated owner=platform-foundations revs=1 targets=0
platform-http-policy NotEvaluated owner=platform-foundations revs=1 targets=0
platform-worker-config NotEvaluated owner=platform-foundations revs=1 targets=0
postgresql NotEvaluated owner=infra-data revs=1 targets=0
redis NotEvaluated owner=infra-data revs=1 targets=0
stripe-api NotEvaluated owner=external/stripe revs=1 targets=0
Sixteen services, each with its owner, how many contract revisions it has
published and how many deployed targets are running it. NotEvaluated is not a
verdict: no evidence source is wired up, so nothing has been evaluated against
anything.
--target-state folds in what a platform observed about the running deployments.
The fixture is one file, and it models what an evidence pipeline would ingest —
three of its targets, abridged:
schemaVersion: pacto.dev/fleet-targets/v1
targets:
- { scope: production-eu, kind: kubernetes-workload, name: payments/payments-service,
service: payments-service, compliance: Compliant,
coverage: { evaluated: 6, required: 6 }, evidenceAt: 2026-07-29T09:40:00Z }
- scope: production-eu
kind: kubernetes-workload
name: commerce/orders-service
service: orders-service
compliance: NonCompliant
coverage: { evaluated: 5, required: 5 }
evidenceAt: 2026-07-29T09:40:00Z
findings:
- code: STATELESS_PERSISTENT_CONFLICT
severity: error
category: RuntimeDrift
message: declared stateless but the observed workload mounts a persistent volume
- { scope: production-eu, kind: kubernetes-workload, name: identity/auth-service,
service: auth-service, compliance: Unknown,
coverage: { evaluated: 1, required: 4 } }
16 of 16 service(s):
api-gateway NotEvaluated owner=platform-foundations revs=3 targets=0
audit-log NotEvaluated owner=platform-foundations-security revs=1 targets=0
auth-service Unknown owner=identity-team revs=1 targets=1
email-provider NotEvaluated owner=- revs=1 targets=0
fraud-service Compliant owner=payments-team revs=1 targets=1
frontend NotEvaluated owner=frontend-team revs=1 targets=0
notification-worker NotEvaluated owner=commerce-team revs=1 targets=0
orders-service NonCompliant owner=commerce-team revs=3 targets=1
pacto-demo NotEvaluated owner=platform-foundations revs=1 targets=0
payments-service Compliant owner=payments-team revs=6 targets=1
platform-app-config NotEvaluated owner=platform-foundations revs=1 targets=0
platform-http-policy NotEvaluated owner=platform-foundations revs=1 targets=0
platform-worker-config NotEvaluated owner=platform-foundations revs=1 targets=0
postgresql NotEvaluated owner=infra-data revs=1 targets=0
redis NotEvaluated owner=infra-data revs=1 targets=0
stripe-api NotEvaluated owner=external/stripe revs=1 targets=0
Four states in one screen. Compliant is a verdict backed by evidence,
NonCompliant is a confirmed contradiction, Unknown is evidence that never
arrived and NotEvaluated means nobody is deployed there to evaluate. The
distinction that matters is between the middle two: one is a problem with the
service, the other is a problem with your ability to see it.
Story 2 — "Something says Unknown and I need to know whether that is bad"¶
Get the reason behind a verdict, so absence of evidence never reads as a pass.
pacto fleet explain identity/auth-service --local examples/demo/bundles --target-state examples/demo/fleet-targets.yaml --freshness 24h
target production-eu/kubernetes-workload/identity%2Fauth-service: Unknown
[EVIDENCE_MISSING] no evidence has been observed for this target
Service: api-gateway@1.2.0
Owner: platform-foundations
Pacto Version: 2.0
Workload: service
State:
Type: stateless
Persistence: local/ephemeral
Data Criticality: low
Interfaces (1):
- http (openapi: interfaces/openapi.json, public)
Dependencies (3):
- auth-service: oci://ghcr.io/trianalab/pacto/auth-service (^1.0.0, required)
- orders-service: oci://ghcr.io/trianalab/pacto/orders-service (^1.0.0, required)
- payments-service: oci://ghcr.io/trianalab/pacto/payments-service (^1.0.0, required)
Readiness:
Score: 0
Gate: FAIL (score 0 / minScore 75)
Earned Weight: 0
Total Weight: 150
Expires: 2025-01-01 (EXPIRED)
Status: 5 done, 0 partial, 0 not-done, 0 deferred
Checks:
- gateway-dashboard observability done weight=30 earned=0 evidence=https://grafana.acme.com/d/api-gateway
- rate-limiting infrastructure done weight=25 earned=0 evidence=ENG-1234
- security-review security done weight=40 earned=0 evidence=SEC-2045
- load-test testing done weight=35 earned=0 evidence=https://k6.acme.com/api-gateway-load-2024-12
- runbook documentation done weight=20 earned=0 evidence=docs/runbooks/api-gateway.md
Targets can be addressed by their unique name or by their canonical key, which
escapes slashes as %2F.
EVIDENCE_MISSING is a fact about the observer, not about the service. The
second command is the same lesson from the other side: api-gateway declares
five readiness claims, every one marked done with evidence attached, and the
assessment earns zero because it expired on 2025-01-01. An assertion nobody has
re-checked is not a passing check. Pacto fails closed on both.
A confirmed violation reads differently:
pacto fleet get --target commerce/orders-service --local examples/demo/bundles --target-state examples/demo/fleet-targets.yaml
Target: production-eu/kubernetes-workload/commerce%2Forders-service
Service: orders-service
Compliance: NonCompliant
Revision: orders-service@sha256:abf75f569260779a932066089e72cf0c2e6c8ac61a22fcf849cced91aaa75736 (inferred)
Stale: false
Coverage: 5/5 evaluated
finding [error] STATELESS_PERSISTENT_CONFLICT: declared stateless but the observed workload mounts a persistent volume
Coverage: 5/5 evaluated is what separates this from the Unknown above: every
check ran, so the verdict is a conclusion rather than a gap. The finding names
the exact contradiction — the contract declares the workload stateless, the
observation found a persistent volume mounted. One fact disagreeing with one
declaration, not a score.
Story 3 — "I am about to ship a change that might break someone"¶
Classify a change from two contracts, with nothing running, and gate CI on it.
pacto diff examples/demo/bundles/payments-service/v1.2.1 examples/demo/bundles/payments-service/v2.0.1
Classification: BREAKING
Changes (39):
[NON_BREAKING] service.version (modified): service.version modified [1.2.1 -> 2.0.1]
[POTENTIAL_BREAKING] capabilities (removed): capabilities removed [- extension (payments.trianalab.com/refund)]
[NON_BREAKING] capabilities (added): capabilities added [+ extension (payments.trianalab.com/dispute)]
[POTENTIAL_BREAKING] dependencies.required (modified): dependencies.required modified [fraud-service: required=false -> fraud-service: required=true]
[BREAKING] asyncapi.channels[payment.completed] (removed): channel payment.completed removed [- payment.completed]
[BREAKING] asyncapi.channels[payment.failed] (removed): channel payment.failed removed [- payment.failed]
[POTENTIAL_BREAKING] asyncapi.channels[payment.refunded].publish.message.payload.properties.charge_id (removed): asyncapi.channels[payment.refunded].publish.message.payload.properties.charge_id removed [- map[type:string]]
[POTENTIAL_BREAKING] asyncapi.channels[payment.refunded].publish.message.payload.properties.payment_intent_id (added): asyncapi.channels[payment.refunded].publish.message.payload.properties.payment_intent_id added [+ map[type:string]]
[BREAKING] asyncapi.channels[payment.refunded].publish.message.payload.required[payment_intent_id] (added): asyncapi.channels[payment.refunded].publish.message.payload.required payment_intent_id added [+ payment_intent_id]
[BREAKING] asyncapi.channels[payment.refunded].publish.message.payload.required[charge_id] (removed): asyncapi.channels[payment.refunded].publish.message.payload.required charge_id removed [- charge_id]
[NON_BREAKING] asyncapi.channels[payment.intent.created] (added): channel payment.intent.created added [+ payment.intent.created]
[NON_BREAKING] asyncapi.channels[payment.intent.failed] (added): channel payment.intent.failed added [+ payment.intent.failed]
[NON_BREAKING] asyncapi.channels[payment.intent.succeeded] (added): channel payment.intent.succeeded added [+ payment.intent.succeeded]
[BREAKING] openapi.paths[/charges] (removed): API path /charges removed [- /charges]
[BREAKING] openapi.paths[/charges/{id}] (removed): API path /charges/{id} removed [- /charges/{id}]
[POTENTIAL_BREAKING] openapi.paths[/refunds].methods[POST].request-body.content.application/json.schema.properties.charge_id (removed): openapi.paths[/refunds].methods[POST].request-body.content.application/json.schema.properties.charge_id removed [- map[type:string]]
[POTENTIAL_BREAKING] openapi.paths[/refunds].methods[POST].request-body.content.application/json.schema.properties.payment_intent_id (added): openapi.paths[/refunds].methods[POST].request-body.content.application/json.schema.properties.payment_intent_id added [+ map[type:string]]
[BREAKING] openapi.paths[/refunds].methods[POST].request-body.content.application/json.schema.required[payment_intent_id] (added): openapi.paths[/refunds].methods[POST].request-body.content.application/json.schema.required payment_intent_id added [+ payment_intent_id]
[NON_BREAKING] openapi.paths[/refunds].methods[POST].request-body.content.application/json.schema.required[charge_id] (removed): openapi.paths[/refunds].methods[POST].request-body.content.application/json.schema.required charge_id removed [- charge_id]
[POTENTIAL_BREAKING] openapi.paths[/refunds].methods[POST].responses[404].description (modified): openapi.paths[/refunds].methods[POST].responses[404].description changed [Charge not found -> Payment intent not found]
[POTENTIAL_BREAKING] openapi.paths[/webhooks/stripe].methods[POST].request-body (removed): POST /webhooks/stripe request body removed [- POST /webhooks/stripe]
[POTENTIAL_BREAKING] openapi.paths[/webhooks/stripe].methods[POST].responses[400].description (modified): openapi.paths[/webhooks/stripe].methods[POST].responses[400].description changed [Invalid webhook signature -> Invalid signature]
[NON_BREAKING] openapi.paths[/payment-intents] (added): API path /payment-intents added [+ /payment-intents]
[NON_BREAKING] openapi.paths[/payment-intents/{id}] (added): API path /payment-intents/{id} added [+ /payment-intents/{id}]
[NON_BREAKING] openapi.paths[/payment-intents/{id}/cancel] (added): API path /payment-intents/{id}/cancel added [+ /payment-intents/{id}/cancel]
[NON_BREAKING] openapi.paths[/payment-intents/{id}/confirm] (added): API path /payment-intents/{id}/confirm added [+ /payment-intents/{id}/confirm]
[POTENTIAL_BREAKING] schema.properties.ENABLE_REFUNDS (removed): schema.properties.ENABLE_REFUNDS removed [- map[default:true description:Feature flag to enable/disable refund processing type:boolean]]
[POTENTIAL_BREAKING] schema.properties.FRAUD_CHECK_ENABLED.default (modified): schema.properties.FRAUD_CHECK_ENABLED.default changed [false -> true]
[POTENTIAL_BREAKING] schema.properties.FRAUD_CHECK_ENABLED.description (modified): schema.properties.FRAUD_CHECK_ENABLED.description changed [Enable fraud detection checks before processing payments -> Fraud detection is now enabled by default and required]
[POTENTIAL_BREAKING] schema.properties.STRIPE_API_KEY (removed): schema.properties.STRIPE_API_KEY removed [- map[description:Stripe API secret key for payment processing type:string]]
[POTENTIAL_BREAKING] schema.properties.STRIPE_SECRET_KEY (added): schema.properties.STRIPE_SECRET_KEY added [+ map[description:Stripe secret key (renamed from STRIPE_API_KEY) type:string]]
[POTENTIAL_BREAKING] schema.properties.WEBHOOK_SECRET.description (modified): schema.properties.WEBHOOK_SECRET.description changed [Stripe webhook signing secret for signature verification -> Stripe webhook signing secret (now required)]
[BREAKING] schema.required[STRIPE_SECRET_KEY] (added): schema.required STRIPE_SECRET_KEY added [+ STRIPE_SECRET_KEY]
[BREAKING] schema.required[WEBHOOK_SECRET] (added): schema.required WEBHOOK_SECRET added [+ WEBHOOK_SECRET]
[BREAKING] schema.required[STRIPE_API_KEY] (removed): schema.required STRIPE_API_KEY removed [- STRIPE_API_KEY]
[NON_BREAKING] configurations[app].values.ENABLE_REFUNDS (removed): configurations[app].values.ENABLE_REFUNDS removed [- true]
[NON_BREAKING] configurations[app].values.FRAUD_CHECK_ENABLED (modified): configurations[app].values.FRAUD_CHECK_ENABLED changed [false -> true]
[NON_BREAKING] configurations[app].values.STRIPE_API_KEY (removed): configurations[app].values.STRIPE_API_KEY removed [- ${STRIPE_API_KEY}]
[NON_BREAKING] configurations[app].values.STRIPE_SECRET_KEY (added): configurations[app].values.STRIPE_SECRET_KEY added [+ ${STRIPE_SECRET_KEY}]
SBOM changes (1):
~ stripe-go version: 1.0.0 -> 2.0.0
breaking changes detected
Thirty-nine changes, one verdict, and a non-zero exit so CI can gate on it. The interesting part is the spread: two API paths removed, two event channels withdrawn, a required request field swapped for a differently-named one, two configuration keys becoming required, a capability dropped, an optional dependency becoming mandatory and one SBOM package version moving. All of it is one contract compared with another — no running service was consulted. The classification rules are a published table, not a heuristic.
The event surface is in that list because Pacto compares AsyncAPI content, not
just the ref: payment.completed and payment.failed are gone outright, and
payment.refunded swapped charge_id for payment_intent_id in both its
payload properties and its required set. What Pacto does not compare is
a published table too,
because a coverage gap you can read is worth more than one you infer from a clean
result.
Not every release is a break. The same service, one pair earlier:
pacto diff examples/demo/bundles/payments-service/v1.0.0 examples/demo/bundles/payments-service/v1.1.0
Classification: POTENTIAL_BREAKING
Changes (6):
[NON_BREAKING] service.version (modified): service.version modified [1.0.0 -> 1.1.0]
[NON_BREAKING] asyncapi.channels[payment.failed] (added): channel payment.failed added [+ payment.failed]
[POTENTIAL_BREAKING] openapi.paths[/refunds].methods[POST].request-body.content.application/json.schema.properties.amount.description (modified): openapi.paths[/refunds].methods[POST].request-body.content.application/json.schema.properties.amount.description changed [Refund amount in cents (partial refund if less than charge) -> Refund amount in cents]
[NON_BREAKING] openapi.paths[/webhooks/stripe] (added): API path /webhooks/stripe added [+ /webhooks/stripe]
[POTENTIAL_BREAKING] schema.properties.WEBHOOK_SECRET (added): schema.properties.WEBHOOK_SECRET added [+ map[description:Stripe webhook signing secret for signature verification type:string]]
[NON_BREAKING] configurations[app].values.WEBHOOK_SECRET (added): configurations[app].values.WEBHOOK_SECRET added [+ ${STRIPE_WEBHOOK_SECRET}]
POTENTIAL_BREAKING, not BREAKING: a new endpoint, a new event channel and a
new optional configuration property break nobody by themselves, but adding a
property to a schema can still surprise a consumer that validates strictly. Three
classifications exist because two would force every additive change into one of
the wrong ones.
Story 4 — "It does break. Who do I have to tell?"¶
Turn a classification into a list of consumers, each graded by how it is known.
pacto impact examples/demo/bundles/payments-service/v1.2.1 examples/demo/bundles/payments-service/v2.0.1 --local examples/demo/bundles
Impact: payments-service 1.2.1 -> 2.0.1
Classification: BREAKING
Breaking changes: 10
Potentially breaking changes: 15
Affected consumers (4):
api-gateway direct confidence=contractual compat=incompatible owner=platform-foundations
frontend transitive confidence=inferred compat=unknown owner=frontend-team
orders-service direct confidence=contractual compat=incompatible owner=commerce-team
pacto-demo transitive confidence=inferred compat=unknown owner=platform-foundations
Four consumers. confidence=contractual means the consumer declared this
dependency in its own contract; confidence=inferred means it was reached
transitively through one that did. compat=incompatible is a second, independent
judgement: the consumer's declared version range does not admit 2.0.1. A consumer
graded unknown is not safe — it is unassessed.
Contracts only find consumers that wrote one down. Add observed traffic:
pacto impact examples/demo/bundles/payments-service/v1.2.1 examples/demo/bundles/payments-service/v2.0.1 --local examples/demo/bundles --traces examples/demo/traces.json
Impact: payments-service 1.2.1 -> 2.0.1
Classification: BREAKING
Breaking changes: 10
Potentially breaking changes: 15
Affected consumers (5):
api-gateway direct confidence=contractual compat=incompatible owner=platform-foundations
audit-log direct confidence=observed compat=unknown owner=platform-foundations-security
frontend transitive confidence=inferred compat=unknown owner=frontend-team
orders-service direct confidence=corroborated compat=incompatible owner=commerce-team
pacto-demo transitive confidence=inferred compat=unknown owner=platform-foundations
Five now. audit-log calls payments-service in production and declares nothing
about it, so no amount of reading contracts would ever have found it — it arrives
as confidence=observed. And orders-service, which both declared the dependency
and was seen using it, is upgraded to confidence=corroborated. Declaration and
observation are separate evidence, and Pacto keeps them separate instead of
averaging them into a number.
Then ask where the change actually lands:
pacto impact examples/demo/bundles/payments-service/v1.2.1 examples/demo/bundles/payments-service/v2.0.1 --local examples/demo/bundles --traces examples/demo/traces.json --target-state examples/demo/fleet-targets.yaml
Impact: payments-service 1.2.1 -> 2.0.1
Classification: BREAKING
Breaking changes: 10
Potentially breaking changes: 15
Affected consumers (5):
api-gateway direct confidence=contractual compat=incompatible owner=platform-foundations
audit-log direct confidence=observed compat=unknown owner=platform-foundations-security
frontend transitive confidence=inferred compat=unknown owner=frontend-team
orders-service direct confidence=corroborated compat=incompatible owner=commerce-team
pacto-demo transitive confidence=inferred compat=unknown owner=platform-foundations
Active targets (2): [production-eu/kubernetes-workload/commerce%2Forders-service production-eu/kubernetes-workload/payments%2Fpayments-service]
breaking changes affect active consumers
The consumer list is identical. What is new is the last two lines. Two deployed
targets sit in the path of this change — orders-service, one of the five
consumers, and payments-service itself — so this is not hypothetical, and the
command exits non-zero. Story 3 refused a change for what it is. This refuses it
for where it lands.
The last two stories -- what observed traffic says about the declared graph, and serving the fleet to an agent -- are in Guided tour: traffic and agents.