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.
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.
You need the Pacto CLI on your PATH and a clone:
Every command below is written to be pasted from the repository root.
To drive a real stack instead, see the Docker Compose demo, which needs no clone and no CLI.
The tour shows runtime conformance (checking what a service really does on the network) as a recorded finding only: story 2 shows one the kernel saw. To watch it live, use the Compose demo or the node agent on a Kubernetes cluster.
The fleet¶
Sixteen services are in the snapshot. Five 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 |
audit-log |
platform-foundations-security | calls payments and declares nothing — the shadow consumer |
A contract says what a service exposes, what it needs and how it behaves — never how it is deployed. 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.
--target-state folds in what a platform observed about the running deployments.
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
- code: INTERFACE_ABSENT
severity: error
category: RuntimeDrift
message: nothing listens on the declared http interface
evidenceRefs: [{ source: k8s, collector: k8s-observer+ebpf }]
- { 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.
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
EVIDENCE_MISSING is a fact about the observer, not about the service.
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. 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
finding [error] INTERFACE_ABSENT: nothing listens on the declared http interface (seen by k8s-observer+ebpf)
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. Each finding names
one exact contradiction. The contract declares the workload stateless and the
observation found a persistent volume mounted. The contract declares an http
interface and nothing listens on its port: seen by k8s-observer+ebpf says the
node agent saw that in the kernel, and Kubernetes agreed. 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, then 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. 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 became mandatory and one SBOM package version moved. All of it is one contract compared with another — no running service was consulted. The classification rules are a published table, not a heuristic.
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. compat=incompatible is a second judgement: the consumer's
declared version range does not admit 2.0.1.
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, so no amount of reading contracts would ever have found it — it arrives
as confidence=observed. orders-service, which both declared the dependency
and was seen using it, is upgraded to confidence=corroborated.
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.
Story 5 — "My architecture diagram and my traffic disagree"¶
Reconcile the edges contracts declare against the edges something observed.
Reconciliation: 1 matched, 13 declared-not-observed, 1 observed-not-declared
[declared-not-observed] api-gateway -> auth-service (count=0)
[declared-not-observed] api-gateway -> orders-service (count=0)
[declared-not-observed] api-gateway -> payments-service (count=0)
[observed-not-declared] audit-log -> payments-service (count=1)
[declared-not-observed] auth-service -> redis (count=0)
[declared-not-observed] fraud-service -> redis (count=0)
[declared-not-observed] frontend -> api-gateway (count=0)
[declared-not-observed] notification-worker -> email-provider (count=0)
[declared-not-observed] orders-service -> notification-worker (count=0)
[matched] orders-service -> payments-service (count=1)
[declared-not-observed] orders-service -> postgresql (count=0)
[declared-not-observed] pacto-demo -> frontend (count=0)
[declared-not-observed] payments-service -> fraud-service (count=0)
[declared-not-observed] payments-service -> postgresql (count=0)
[declared-not-observed] payments-service -> stripe-api (count=0)
Three verdicts. observed-not-declared is the shadow dependency from story 4,
stated as a reconciliation result. declared-not-observed is the opposite risk
— an edge in the diagram that no traffic has ever taken.
Story 6 — "I want an agent on this, without handing it write access"¶
Serve a contract as MCP tools, and check what the default withholds.
pacto mcp turns a bundle's OpenAPI interface into MCP tools. By default it
turns only the safe half. Point a human and an agent at one process:
Then open http://127.0.0.1:8899/#/fleet. The dashboard is itself a Pacto
bundle with a real OpenAPI contract, so the agent's tools are generated from the
contract of the very server the human is looking at. The tools come from the
dashboard's OpenAPI interface, one per read-only operation. Beside the interface
operations the server always registers Pacto's own authoring tools —
pacto_create and pacto_edit write contract files to disk.
Mutating operations are withheld by default, in every bundle. Serve the demo's
payments service instead, and its five mutating operations — every POST, PUT,
PATCH and DELETE that bundle's interface declares — are dropped with a
warning on stderr:
The base URL is deliberately a dead address: the tools are registered and listed without anything ever being called.
Add --allow-writes and both the warning and the restriction disappear.
--allow-writes governs the interface half only; withhold the authoring tools
by not registering this server where you do not want contracts written. See
MCP integration for the full wiring.
Next¶
The Quickstart takes an empty directory to a validated
contract and a caught breaking change in about five minutes. The
contract reference is the complete list of
sections, and pacto diff
is the complete table of what counts as a breaking change.