Skip to content

The Pacto Operational Graph

A single contract tells you what one service is. Most questions worth asking span many: what depends on payments-api, which revision is running in production-eu, is any of it non-compliant, and how sure are we? The Pacto Operational Graph answers those questions. It composes many independent contracts, contract revisions and operational targets into one versioned, navigable, verifiable read model that humans, CLIs, platforms and agents can reason over.

It is framework-independent (pkg/fleet), pure and read-only. It observes no environment and evaluates nothing on its own — it is a query model built around the sources and evaluations Pacto already produces. Internally the immutable read model is a Fleet Snapshot and the pure query layer over it is a Fleet Query; the sections below use those terms wherever precision matters.


Three identities, never flattened

The graph models three distinct things and never collapses them into one: a name, a revision and a running instance are different questions with different answers.

Identity What it is Example
Logical service A stable name and owner. It has revisions and runs in targets, but it is neither. payments-api (owner: payments)
Contract revision An immutable resolved revision — what it declares and how it differs from another revision. Identity is the service plus a content digest: the source's immutable digest, or one derived from the whole bundle when the source has none. Never a ref, never a version; a revision that can be neither pinned nor hashed is omitted rather than given a weaker identity. payments-api@sha256:…
Operational target A concrete place a revision runs, generic as scope/kind/name. production-eu/customer-a → kubernetes-workload payments/payments-api

Keeping them separate is what lets the graph answer "which revision runs where, and is that instance compliant" without guessing.

How certainly a target is matched to a revision

Knowing a target exists is not the same as knowing which revision it runs, so every target records how the link was made. Four outcomes:

Match What Pacto knows
exact The target's content digest matches a revision's. Authoritative: this is the revision running there.
inferred A unique correlation by mutable tag or version suffix. Probably right, not proof.
ambiguous Several revisions match that mutable reference. No link is made — a guess is never presented as fact — and the target carries a REVISION_LINK_AMBIGUOUS limitation.
unresolved Nothing matched, or the target's identity contradicts itself (a recorded digest that disagrees with its digest-pinned reference). No link, and a limitation on the target saying so.

The bottom two are not a failure to report. They are the report, and they sit on the target itself so a consumer can classify a link without parsing snapshot-level messages.

The two read surfaces spell this differently, and the CLI's spelling has a trap:

  • Snapshot and pacto fleet get --target JSON carry revisionMatch, which is omitempty and only ever exact or inferred. An absent revisionMatch is the finding — it means ambiguous or unresolved. Read the target's limitations to learn which.
  • The entity-detail API (GET /api/fleet/entities/target?key=…, what the dashboard reads) carries linkState, always present, with all four values spelled out.

Two things this axis is not. It is not whether Pacto can fetch that revision's content: an exact match can name content sitting in a registry Pacto cannot read, and that is an honest outcome rather than a contradiction (match certainty is not content retrievability). And its inferred is a different word from the inferred relationship provenance below — one is how a target was matched to a revision, the other is how an edge was derived.


Relationships: declared, observed, inferred

Edges in the graph carry a provenance discriminator so a fact's origin is never ambiguous:

  • declared — the relationship comes from a contract (dependencies[], and config/policy refs).
  • observed — a relationship seen in running traffic. Observed dependencies are produced by the OTel observer and, when an observation source is configured, folded into the snapshot's edges as observed-provenance relationships (kept in a separate adjacency index from the declared graph). Reconciliation and impact consume them; because every edge keeps its provenance, an observed edge is never mistaken for a declared one.
  • inferred — a relationship deduced heuristically. Reserved, not yet produced.
  • declared+observed — one edge backed by both a declaration and an observation. The neighborhood projection merges the two adjacency indexes for display and emits this combined value, so a consumer branching on provenance must handle all four.

Sources

The graph is assembled from sources — a framework-neutral ingestion seam. Each source observes what it can right now and contributes revisions and targets. (The dashboard lists these as Data sources. They are not the same thing as an evidence collector, which produces compliance evidence for a source to carry.)

  • Local bundles (--local) — the revision a developer is editing, before it is pushed.
  • Contracts in OCI (--oci <ref>) — the published revision catalogue, resolved cache-first so a pulled ref works offline.
  • Local OCI cache (--cache) — every bundle already pulled to disk, as an offline baseline.
  • Live Kubernetes (--k8s [--namespace]) — Pacto CRs read straight from a running cluster: which revision runs in which target and its operator-computed compliance, findings, coverage and observed runtime.
  • Ingested external evidence (--evidence-url <url>) — a remote environment's signed, versioned EvidenceSet report, verified and evaluated at ingestion by the Evidence Server and published to the contract registry, then exposed as an operational target. --evidence-url consumes a running Evidence Server's read-only contribution over HTTP — the only way in, because the store is a registry the CLI has no business holding a credential for.
  • Offline target-state fixtures (--target-state) — an unsigned demo and test adapter for supplying targets without a cluster.

Offline trace exports are Data Sources too, claiming names in the same namespace, but they supply observed dependency edges rather than revisions and targets — see Observation sources.

Every source flag above is shared by pacto fleet and the MCP fleet server, so the same graph is reachable from either. pacto impact reads a narrower set — --local and --target-state only, alongside --freshness, a single-file --traces and --include-observed — so a blast-radius question is answered from an offline graph. No source leaks its transport into the graph: a Kubernetes-backed source, an OCI-backed source, a local source and the dashboard adapter each implement the same small interface, so the read model stays free of Kubernetes, MCP and dashboard code.

What a source sent is not what it contributed. A source's record counts (revisionCount, targetCount) are the raw records it supplied, counted as ingested. The product entities attributable to it (contributed, broken down by kind) are a different and usually larger set, so the two are reported side by side and never reconciled into one number. A revision that two sources both reported is one record in each of their counts and one shared entity contributed by both. A service is derived from the revisions and targets a source reported — no source ever sends a service record — so a source whose records are entirely revisions still contributes services, and the same service is attributable to several sources at once. Both counts are computed over the complete population, never over the bounded entity preview beside them.

What a target-state fixture looks like

--target-state is the only source you have to author yourself, so here is the whole format. It is a single YAML or JSON document — JSON is a subset of YAML, so one parser reads both — and the decoder is strict: a second --- document, an unknown field or a schemaVersion other than pacto.dev/fleet-targets/v1 is rejected and the whole file contributes nothing.

schemaVersion: pacto.dev/fleet-targets/v1
targets:
  - service: orders-service          # required: the service this target runs
    name: commerce/orders-service    # required: unique within the scope
    scope: production-eu             # the environment, e.g. a cluster
    kind: kubernetes-workload        # what sort of target it is
    labels: { env: production, region: eu }
    requestedRef: oci://ghcr.io/acme/orders-service:1.2.0
    resolvedRef: oci://ghcr.io/acme/orders-service:1.2.0
    digest: sha256:…
    compliance: NonCompliant
    coverage: { evaluated: 5, required: 5 }
    evidenceAt: 2026-07-29T09:40:00Z     # when the evidence was gathered
    reconciledAt: 2026-07-29T09:41:00Z   # when the target was last reconciled
    observedRuntime: { replicas: 3 }     # free-form; surfaced as a bounded preview
    findings:
      - code: STATELESS_PERSISTENT_CONFLICT   # required within a finding
        severity: error
        category: RuntimeDrift
        subjectKind: state
        subjectName: orders-db
        message: declared stateless but the observed workload mounts a volume
state:                               # optional: the source's own health
  status: available
  message: ""

Only service and name are required on a target — everything else may be omitted, and an omitted evidenceAt is exactly how you model a target the collector could not observe. The enumerations are closed:

Field Accepted values
compliance Compliant, NonCompliant, Unknown, Warning, Invalid, Reference, NotEvaluated, or omitted
findings[].severity error, warning, info, unknown, or omitted
state.status available, partial, stale, unavailable (anything else reads as available)

A file may declare at most 5000 targets. An individual entry that fails validation is skipped with a SOURCE_RECORD_INVALID limitation and the rest of the file is kept; a failure of the file — missing, unparseable, wrong schema version, unknown field — drops the whole source with a SOURCE_UNAVAILABLE limitation and marks the snapshot partial.

A malformed fixture is reported the same way as a missing one

Both produce exactly SOURCE_UNAVAILABLE: source target-state is unavailable; its records are missing from this snapshot. The parse error itself is not surfaced, and -v does not add it, so a typo in a field name looks identical to a path that does not exist. If the source drops and the path is right, suspect the file: check schemaVersion first, then field spelling.

examples/demo/fleet-targets.yaml is a complete worked fixture — compliant, non-compliant, unknown and stale targets across two scopes — and it is the file the live demo runs on.


Freshness and completeness

Incompleteness is always explicit. A source reports its state; a snapshot and every query answer carry an as-of time, a completeness and a list of limitations. Two rules are absolute:

An unavailable source is never an empty result. If a registry is unreachable or a cluster is disconnected, its records are missing, and the answer says so — it is never rendered as "nothing is there".

Absence of telemetry is not evidence of absence. A missing observation under partial coverage is uncertainty, not a confirmed "no".

One case does not carry the envelope, and it is the case where you most want it. get, graph and explain name a single subject, and a subject that is not in the snapshot is a failure, not an answer: pacto fleet get ghost exits 1 with service "ghost" not found in the fleet snapshot on stderr and nothing on stdout whatever --output-format said, and the MCP equivalents return the same string as a tool error. There is no meta on it, so there is no completeness telling you whether the snapshot that missed was whole. Get that reading from search or status on the same snapshot before you read a subject miss as an absence.

Every source reports one status, and the snapshot rolls those up into one completeness value:

Term Where it applies Meaning
available source Reachable and current.
partial source and snapshot The source returned some but not all of its records, or at least one source is degraded. Treat the answer as incomplete knowledge.
stale source The most recent data is older than the freshness window.
unavailable source The source could not be observed at all; its records are absent, not empty.
complete snapshot Every source was available and current.
empty snapshot Every source was available and produced no record. A genuine empty, not a hidden failure.

The dashboard uses a matching per-section vocabulary — present, empty, not_applicable, unavailable, defined term by term under Section provenance — for the same reason: a blank must always explain why it is blank. When a source fails, its error is sanitized to a category code (AUTH_FAILED, NOT_FOUND, UNAVAILABLE, CANCELLED) and a generic message, so credentials, tokens and host names never leak to a consumer.

A query answer's meta lists every source in the snapshot. The product answers the dashboard reads — the /api/fleet/* envelope, schema version pacto.dev/fleet-product/v1 — cap that list at 50 sources, least healthy first, and flag the cut with sourcesTruncated. Because the list is capped there, a product answer also carries sourceCounts: every source in the snapshot tallied by health state, over the complete population the list was cut from. Counting the sources a consumer received would understate the fleet precisely when it matters, because the capped list is deliberately biased toward the least healthy. A status the read model does not recognize is never folded into a bucket — total simply stays above the sum of the buckets, rather than the tally adding up perfectly and being wrong.


Query semantics

The read model answers five kinds of question, each a pure operation. None performs I/O; a single snapshot serves concurrent queries. Every answer carries a meta envelope with schemaVersion, snapshotId, asOf, completeness, limitations and sources — a consumer can always tell how much of the system the answer actually covers. schemaVersion (pacto.dev/fleet/v1) is the compatibility contract to branch on; snapshotId is the content digest that proves two answers came from the same system view.

Query Answers
search Which logical services match this filter (owner, label, status, compliance, capability, dependency, readiness). Bounded and deterministically ordered.
get Everything about one service (its revisions, targets, declared dependencies, dependents, tools and skills) or one target.
graph Traverse dependencies or dependents from a service — direct or transitive, cycle-safe, with unresolved edges surfaced.
status What needs attention: non-compliant or unknown targets, invalid contracts, stale evidence, missing readiness, unresolved dependencies.
explain Deterministic, structured reasons for a subject's state. Pacto embeds no model — it hands an agent structured reasons to turn into prose.

A not found under partial completeness is not proof the thing does not exist — the answer's meta.completeness tells the caller whether absence is trustworthy. Two more operations sit beside the five and are not queries: snapshot emits the whole read model as one document, and reconcile reports declared dependencies against observed ones (Observed dependencies and reconciliation).

Errors are typed: a missing identity is a not-found, an ambiguous one lists its matches.

A pacto fleet search --output-format json answer over a local bundle root with an unreachable OCI source:

{
  "meta": {
    "schemaVersion": "pacto.dev/fleet/v1",
    "snapshotId": "sha256:c81df8fd572ecaa7c884968e728daff5b47c40b8e8c5cbcf1926c19740db95a9",
    "asOf": "2026-07-29T10:00:00Z",
    "completeness": "partial",
    "limitations": [
      { "code": "SOURCE_UNAVAILABLE", "source": "oci",
        "message": "source oci is unavailable; its records are missing from this snapshot" }
    ],
    "sources": [
      { "id": "local", "kind": "local", "status": "available",
        "lastSuccessfulSync": "2026-07-29T10:00:00Z",
        "observedAt": "2026-07-29T10:00:00Z",
        "revisionCount": 25, "targetCount": 0 },
      { "id": "oci", "kind": "oci", "status": "unavailable",
        "error": { "code": "UNAVAILABLE", "message": "the source is unavailable" },
        "revisionCount": 0, "targetCount": 0 }
    ]
  },
  "total": 1,
  "count": 1,
  "services": [
    { "key": "payments-service", "name": "payments-service",
      "owner": "team/payments", "status": "NotEvaluated",
      "revisionCount": 6, "targetCount": 0, "sources": ["local"] }
  ]
}

total is the whole matched population; count is how many rows this page carries. key is the service's canonical identity — match on it, not on name, which is not unique across domains. owner here is the authored owner label, not the canonical owner key (see ownership).


Aggregates: what a bounded list can still tell you about the whole

Every list answer is bounded, so the rows a consumer receives are one slice of the population its filter matched. Alongside them the read model returns an aggregate computed over the complete matched population, before paging — computed in the backend, because a distribution drawn from the rows would present the first page as the fleet.

The matched population is heterogeneous by design — one query can match services, revisions and targets at once — so every tally names the population it partitions instead of sharing one denominator, and the per-kind counts are reported rather than derived by summing buckets, so a disagreement between a denominator and its buckets stays visible.

Tally Partitions Buckets
serviceCompliance matched services the compliance states, rolled up from each service's targets
targetCompliance matched operational targets the compliance states as observed per target
ownership matched services consistent · conflicting · unowned
readiness matched contract revisions passing · belowThreshold · expired · notDeclared

serviceCompliance and targetCompliance are never summed: a service status is already a roll-up of its targets, so adding them counts the same operational reality twice.

Ownership is a property of revisions agreeing, not of one field somebody set — service.owner is authored on each contract revision. A service is consistent when every revision that declares an owner declares the same one; a revision that declares none is silence, not a contradiction. conflicting is never folded into unowned: "two teams claim this" and "nobody claims this" need opposite fixes. Neither is folded into consistent, because the owner shown on a service is a documented tie-break, and counting a conflicted service as owned would present that tie-break as agreement.

Beside the partition, an aggregate carries a bounded ranking of the consistently owned services by owner (byOwner), largest first. It is explicitly not a partition: conflicted and unowned services have no single owner to rank under, beyondRanking holds the services whose owner fell past the bound, unidentifiedOwnership holds the consistently owned services whose declared owner resolves to no canonical identity and distinctOwners says how many owners exist in total — so sum(byOwner.services) + beyondRanking + unidentifiedOwnership == ownership.consistent, and a consumer can state exactly what the ranking omits.

An owner identity is namespaced: team:payments and dri:payments are two owners that happen to print the same word. A ranking row whose human label is shared by an owner of the other kind is flagged ambiguous, and a consumer must show the namespace for it. Ambiguity is decided over the complete population of distinct owner keys, never over the rows that survived the ranking cut — otherwise the same owner would read one way when its collider ranked second and another way when it ranked two hundredth, and a canonical identity would depend on where the bound happened to fall.

A revision's declared contact points — an email address, a chat channel or a URL — travel with its ownership as bounded metadata and are never identity: no owner key, no link and no ranking row is derivable from one. That is what unidentifiedOwnership exists to count. A contract naming a mailing list but no Team or DRI has declared an owner, so folding it into unowned would report a governance gap the team already closed, and minting an owner key out of the address would invent an identity nobody authored. The contacts preview is a pointer: its absence means "not carried here", never "none declared".

Readiness is bucketed per contract revision and never per service, per target or per fleet: readiness is the authored preparedness of one immutable contract, assessed against the threshold that contract set for itself. It is orthogonal to compliance — a revision whose readiness passes can be running on a target observed to violate its contract, and a revision nobody assessed can be running perfectly. notDeclared is its own bucket because "nobody wrote an assessment" is not the same answer as "the assessment does not pass", and expired is its own bucket because an assessment past its expires date earns no weight and cannot be read as current.

The overview carries the same two tallies over the whole snapshot rather than over a filtered population. They sit there, and not in the attention backlog, because neither is an operational failure: "is ownership declared at all" and "is anyone assessing readiness" are systemic questions about how the fleet is organized and authored.


Who consumes it

A human portal and an agent consume the same graph.

flowchart LR
    subgraph Sources["Sources"]
        OCI["Contracts in OCI<br/>published revisions"]
        LOCAL["Local bundles<br/>revision being edited"]
        K8S["Live Kubernetes<br/>Pacto CRs: which revision runs where"]
        EVI["Evidence Server<br/>durable EvidenceSet records<br/>signature checked at ingestion, not stored"]
    end
    OTEL["OTel trace file<br/>offline analysis"]
    OCI --> OG
    LOCAL --> OG
    K8S --> OG
    EVI --> OG
    OTEL -->|--traces| OG
    OG["Operational Graph<br/><i>Fleet Snapshot · immutable read model</i>"]
    OTEL --> RECON["reconcile · impact<br/>declared vs observed"]
    OG --> RECON
    OG --> Q["Fleet Query<br/>search · get · graph · status · explain"]
    Q --> DASH["Dashboard"]
    Q --> CLI["CLI<br/>pacto fleet …"]
    Q --> MCP["MCP fleet tools"]
    Q --> PLAT["Platforms"]
    Q --> AGENT["Agents"]
  • Dashboard — the visual front door. It builds one snapshot from every source it detects — local bundles, OCI, the disk cache and the live cluster — and serves the operational graph and change analysis through /api/fleet/*. The Operational Graph view offers three perspectivesServices (logical), Revisions (content-addressed) and Operational targets (the places a revision runs) — and a Knowledge control (Expected · Observed · Differences). The Operational targets perspective is honest about what it can know: an operational target links to the dependency service it depends on, never to each peer target — a full target-to-target mesh would assert runtime routing the snapshot never observed, so it is never drawn. Its overview and its list pages draw every figure from the aggregate above, so a figure and the rows beneath it always describe the same population: narrowing the filter narrows both, and a bucket of a figure is a link to the rows it counted. The data sources everything above was built from are a product surface of their own rather than a diagnostic panel: the overview carries them as a section with the fleet-wide health tally, and each source has a page saying what it is, whether it is healthy, when it last synced, how many records it sent and which product entities are attributable to it.
  • CLI (pacto fleet …) — the five queries on the command line: pacto fleet search, pacto fleet get, pacto fleet graph, pacto fleet status, pacto fleet explain, plus pacto fleet reconcile (declared vs observed) and pacto fleet snapshot (the whole read model as one document). Scriptable, deterministic output.
  • MCP fleet toolspacto_fleet_search, pacto_fleet_get, pacto_fleet_graph, pacto_fleet_status and pacto_fleet_explain give an agent read-only understanding of the operational system, and pacto_impact is the sixth tool of the same family — the one that re-reads its sources on every call. They are one of three MCP tool families — see MCP integration for how they differ from authoring tools and generated service tools.

A read model around many evaluations, not a new evaluator

The operational graph does not replace the engine. Compliance is still the pure Evaluate(Contract, EvidenceSet) function producing findings for one service in one environment (see Collectors and the evidence boundary). The graph is the read/query model around many such evaluations — it references each target's findings and coverage rather than re-computing them.

flowchart TB
    subgraph Eval["Per-target evaluation (the engine — one at a time)"]
        C["Contract"] --> EV["Evaluate"]
        E["EvidenceSet"] --> EV
        EV --> F["Findings + Coverage"]
    end
    C -.-> OG
    F -.-> OG
    OG["Operational Graph<br/>composes many revisions + many targets"]
    OG --> ANS["Query answers<br/>with asOf · completeness · limitations"]

Impact analysis, built on this substrate

The graph maintains a reverse-dependency index: for any service, which services declare a required dependency on it. Impact analysis builds directly on that index. pacto impact <old> <new> composes a semantic contract diff with this graph to answer "if this revision ships, what is the transitive blast radius" — direct and transitive affected consumers, active targets, owners, a compatibility verdict and a per-consumer confidence grade. No new data, a new question over the same graph the dashboard and CLI already query — shipped today on the CLI, as an MCP tool and, under the name Change analysis, in the dashboard. See Impact analysis for the full model.


External evidence ingestion

Runtime evidence no longer has to come only from a cluster the operator watches. The source seam is deliberately environment-neutral, so a remote or disconnected environment can participate without Pacto reaching into it: the remote side produces a signed, versioned EvidenceSet, reports it outbound to an ingestion endpoint, and the platform verifies the signature, evaluates the evidence and exposes the result as an operational target. The freshness rules hold across the boundary — a target goes stale when its evidence ages past the window and its source becomes unavailable when it stops reporting, never a silent empty and never deleted. This is the shipped external evidence protocol; for keys and CLI usage see evidence security and tooling.

Ingested evidence is backed by the Evidence Server, a stateless boundary in front of your contract registry. Every accepted envelope is published as an OCI 1.1 referrer of the exact contract revision it reports on before it becomes a target, so replay protection and latest-target state survive a restart with no local state at all — see evidence in the registry. The Evidence Server is an optional operator-managed component of the pacto-operator Helm chart (evidence.enabled=true), and it runs the same way outside Kubernetes via pacto evidence serve. There is no standalone evidence chart. When both the dashboard and the Evidence Server are enabled, the operator wires the dashboard to the server over HTTP; the dashboard consumes its read-only contribution and never holds a registry credential. The deployment topology keeps the responsibility split clean: the Evidence Server owns ingestion, verification, evaluation and publication, the dashboard consumes the read-only contribution and the operator manages the Kubernetes lifecycle.


Observed dependencies and reconciliation

Declared intent is only half the picture; the other half is what traffic actually does. The OTel observer (pacto otel observe <traces.json>) is an offline analyzer: it reads an exported OTLP/JSON trace file and derives the caller-to-callee reachability edges its outbound spans prove. It is not a receiver or a live collector — there is no OTLP endpoint and nothing is deployed; it processes a file you hand it. It reports only what it saw and never asserts a dependency is absent — an unseen dependency is uncertainty, not a confirmed "no".

Those observed edges meet the declared graph in three places:

  • The snapshot itselfpacto fleet --traces <file> adds an observation source, and building the snapshot resolves each raw observed endpoint name to a unique domain-qualified service and folds resolved edges into the snapshot as observed relationships. pacto dashboard --traces folds the same edges into the dashboard's snapshot, so runtime evidence is no longer confined to a one-off report. pacto mcp --fleet has no --traces flag: its snapshot is declared-only, and pacto_impact is the one MCP tool that reads a trace file, per call. An endpoint name that matches zero or more than one service (the same name in two domains) is never coerced to a domain; it is preserved as an OBSERVED_IDENTITY_UNRESOLVED limitation, so observed traffic can never be misattributed across domains.
  • Reconciliationpacto fleet reconcile --traces <file> compares what the fleet's contracts declare against what traffic proves, labelling each dependency matched, declared-not-observed (dormant or simply unseen in the window) or observed-not-declared (a shadow dependency the contract never mentions). The caller must resolve to a unique service; the callee is resolved within the caller's domain (mirroring declared-dependency resolution), and anything unresolvable is reported in a distinct unresolved category rather than force-fit to the default domain.
  • Impactpacto impact --traces <file> (or any snapshot that already carries observed edges) lets observed traffic raise a declared consumer to corroborated confidence and surface observed-only (shadow) consumers a declared-only analysis would miss. A shadow consumer must itself be a registered fleet service; an unknown caller name is preserved as an unresolved limitation, never a phantom default-domain consumer.

The OTel observer can also emit signable EvidenceSets (pacto otel observe --evidence), so observed dependencies can travel the same external evidence protocol as any other report. That is two steps rather than a pipe, because traces name services and not contract revisions:

# One EvidenceSet per calling service, as a JSON array
$ pacto otel observe traces.json --evidence --output-format json > sets.json

Each set comes out with an empty ContractRef — a trace cannot know which revision was running. Split the array into one file per set:

$ for i in $(seq 0 $(( $(jq length sets.json) - 1 ))); do
    jq ".[$i]" sets.json > "set-$i.json"
  done

Then edit each file's ContractRef to the revision that service was serving — this is the step only you can do — and sign one set at a time:

$ pacto evidence sign set-0.json \
    --key k.key --key-id k --producer prod > envelope-0.json
$ pacto evidence send envelope-0.json \
    --url https://evidence.example.com/api/evidence/v1/envelopes

pacto evidence sign reads a file and one EvidenceSet at a time: hand it the array and it answers decode evidence set: json: cannot unmarshal array into Go value of type evidence.EvidenceSet, and hand it a set with no ContractRef and it answers invalid evidence set: contract ref is empty. Both are the tool asking for the one thing the traces could not supply.

In the dashboard's Operational Graph the declared/observed split is the Knowledge control: Expected (contract-declared intent), Observed (backed by runtime observation) and Differences (where the two diverge). When a snapshot carries no observation data the graph says so — the edges come back insufficient and the knowledge banner states what is missing — rather than drawing an empty Observed view that would read as "there is no traffic".

Reconciliation is an explicit backend fact, not a frontend guess. Every declared dependency edge in a snapshot carries a reconciliation state computed against the snapshot's observed edges: matched (an observed edge corroborates it), declared-not-observed (observation data exists but did not witness this edge) or insufficient (no observation data at all — so it cannot be reconciled). The dashboard's reconciled layer shows only matched edges and never infers reconciliation from name resolution or from whether a provider is deployed. Feeding observation data to the normal dashboard — so its Operational Graph, reconciliation and Change analysis see observed edges — means configuring observation sources: the offline OTLP/JSON trace files it reads, how they are named, and how they are wired under Kubernetes. The observed capability the UI advertises is derived from the published snapshot, never a hardcoded flag.


Why the fleet is not a new contract kind

There is no kind: Fleet and no fleet: section in a contract. The graph is discovered from the sources you already have — published contracts, local bundles and runtime evidence — not declared in a new document a team would have to author and keep in sync. Adding a fleet manifest would recreate the exact problem Pacto exists to remove: a hand-maintained aggregate that drifts from reality the moment a service is added or a revision ships. The fleet is a view, computed on demand, versioned by its as-of time — not a thing anyone writes down.


Why Pacto does not act or authorize

The operational graph makes the system knowable. It does not run it. Pacto's verbs are bounded and deliberate: it declares intent, resolves references, diffs changes, graphs relationships, evaluates evidence and explains state. That is the whole list.

  • It does not act. Deploying, scaling, provisioning and remediating are done by external controllers and delivery systems. The graph tells them what is true; it performs no action itself.
  • It does not authorize. Whether a human or an agent may do something stays with policy and IAM systems (OPA, Kyverno, admission control, your identity provider). The graph never grants, scopes or revokes a permission.

This is why the fleet query tools are read-only and why a partial answer is labelled as incomplete knowledge rather than a decision. Pacto supplies the verifiable operational meaning that controllers act on and that authorization systems reason about — it is not either of them.


See also