Architecture
Pacto follows a layered architecture where dependencies flow predominantly in one direction. There are small, deliberate exceptions documented below. This page describes the internal design for contributors and plugin authors.
Table of contents
- Dependency graph
- Layer overview
- Package responsibilities
pkg/contract– Domain modelpkg/validation– Validation enginepkg/diff– Change classifierpkg/sbom– SBOM parser and differpkg/graph– Dependency resolverpkg/override– YAML overridespkg/doc– Documentation generatorpkg/plugin– Plugin systempkg/dashboard– Dashboard serverinternal/app– Application servicesinternal/cli– CLI layerpkg/oci– OCI adapterinternal/mcp– MCP serverinternal/logger– Logger setupinternal/update– Update checker
- Dashboard architecture
- Design principles
- Architectural invariants
Dependency graph
graph TD
MAIN[cmd/pacto/main.go<br/>Composition Root] --> CLI[internal/cli<br/>Cobra Commands]
MAIN --> APP
MAIN --> OCI
MAIN --> PLUG
CLI --> LOG[internal/logger<br/>Logger Setup]
CLI --> MCP[internal/mcp<br/>MCP Server]
CLI --> UPDATE[internal/update<br/>Update Checker]
MCP --> APP
CLI --> APP[internal/app<br/>Application Services]
APP --> VAL[pkg/validation<br/>Four-Layer Validator]
APP --> DIFF[pkg/diff<br/>Change Classifier]
APP --> GRAPH[pkg/graph<br/>Dependency Resolver]
APP --> OCI[pkg/oci<br/>OCI Adapter]
APP --> PLUG[pkg/plugin<br/>Plugin Runner]
APP --> DOC[pkg/doc<br/>Doc Generator]
APP --> OVER[pkg/override<br/>YAML Overrides]
CLI --> DASH[pkg/dashboard<br/>Dashboard Server]
DASH --> CONTRACT
DASH --> DOC
DASH --> DIFF
DASH --> VAL
DASH --> GRAPH
DASH --> OCI
DIFF --> SBOM[pkg/sbom<br/>SBOM Parser & Differ]
VAL --> GRAPH
DOC --> GRAPH
DOC --> VAL
VAL --> CONTRACT[pkg/contract<br/>Domain Model]
DOC --> CONTRACT
DIFF --> CONTRACT
GRAPH --> CONTRACT
OCI --> CONTRACT
PLUG --> CONTRACT
UPDATE -.-> OCI
classDef pkg fill:#e0f0ff,stroke:#4a90d9
classDef internal fill:#fff3e0,stroke:#e6a23c
class CONTRACT,VAL,DIFF,GRAPH,PLUG,DOC,SBOM,OVER,DASH,OCI pkg
class APP,CLI,LOG,MCP,MAIN,UPDATE internal
Dependencies flow downward only. The OCI adapter (pkg/oci) is a public package, importable by external consumers such as the Kubernetes Operator.
All core domain logic lives in pkg/ and is reusable outside the CLI — for example, by the Kubernetes Operator.
Layer overview
The codebase is organized into three layers:
| Layer | Location | Responsibility |
|---|---|---|
| Core | pkg/ | Pure, reusable domain logic. No CLI deps, no side effects beyond minimal I/O. |
| Application | internal/app | Use-case orchestration. Each CLI command maps to one service method. Returns structured results (never prints). |
| Interfaces | internal/cli, cmd/ | Thin adapters. Flag parsing, output formatting, process bootstrap. Zero business logic. |
Infrastructure adapters live in internal/ because they depend on external systems or framework-specific details:
| Package | Role |
|---|---|
internal/mcp | Model Context Protocol server for AI tool integration |
internal/logger | Global slog configuration |
internal/update | Async GitHub version checking and self-update |
The OCI adapter (pkg/oci) is a public package so it can be imported by external consumers (e.g., the Kubernetes operator).
Test infrastructure lives in internal/testutil, which provides shared mocks and fixtures (MockBundleStore, MockPluginRunner, TestBundle()) used across test packages.
pkg/dashboard is the largest package in the core layer. Unlike the other pkg/* packages (which are focused processing libraries), the dashboard is a self-contained application component: HTTP server, multi-source data aggregation, graph visualization, compliance engine, Kubernetes client, and embedded SPA. Its complexity is justified because it must remain reusable outside the CLI binary (the Kubernetes operator embeds the same dashboard server).
Package responsibilities
pkg/contract – Domain model
The root public package. Contains pure Go types and logic with zero I/O and zero framework dependencies. Imports nothing from the project.
Contract,ServiceIdentity,Interface,Runtime,State,Configuration,Policy, etc.Parse()– YAML deserializationOCIReference– OCI reference parsingRange– Semver constraint evaluationBundle– Contract + file system
pkg/validation – Validation engine
Four-layer, short-circuit validation:
flowchart LR
A[Layer 1<br/>Structural<br/>JSON Schema] --> B[Layer 2<br/>Cross-Field<br/>Reference Validation]
B --> C[Layer 3<br/>Semantic<br/>Consistency Checks]
C --> D[Layer 4<br/>Policy<br/>Enforcement]
Each layer short-circuits – if it produces errors, subsequent layers are skipped.
Also includes runtime validation (ValidateRuntime) – a foundational abstraction for comparing a contract’s declared state against observed runtime conditions. This is consumed by the Kubernetes Operator without introducing platform-specific dependencies into the core library.
pkg/diff – Change classifier
Compares two contracts and classifies every change using a deterministic rule table. Sub-analyzers handle specific sections:
contract.go– service identity, scalingruntime.go– workload, state, lifecycle, healthinterfaces.go– interface additions/removals/changes, configuration and policy diffingdependency.go– dependency list changesopenapi.go– deep OpenAPI diff (paths, methods, parameters, request bodies, responses)schema.go– recursive JSON Schema diff (properties, required fields, types, constraints)
pkg/sbom – SBOM parser and differ
Parses SPDX 2.3 and CycloneDX 1.5 SBOM files from the bundle’s sbom/ directory and normalizes them into a unified package model. Provides a diff engine that compares two SBOM documents and reports package-level changes (added, removed, version/license modified).
ParseFromFS()– scanssbom/for recognized extensions, auto-detects formatHasSBOM()– checks whether a bundle contains recognized SBOM filesDiff()– compares two SBOM documents and returns changes
The diff engine (pkg/diff) calls into this package when both bundles contain SBOMs. Results are reported separately from contract changes and don’t affect classification.
pkg/graph – Dependency resolver
Builds a dependency graph by recursively fetching contracts from OCI registries and local paths. Sibling dependencies at each level are resolved concurrently. Detects cycles and version conflicts.
ParseDependencyRef()– centralized dependency reference parser (oci://,file://, bare paths)RenderTree()/RenderDiffTree()– tree-style rendering with connectorsDiffGraphs()– structural diff between two dependency graphs
pkg/override – YAML overrides
Applies value-file and --set overrides to raw YAML before parsing. Supports deep merge, dot-separated paths, and array index notation.
pkg/doc – Documentation generator
Generates rich Markdown documentation from a contract. Reads OpenAPI specs, event contracts, and JSON Schema configuration to produce a comprehensive service document with architecture diagrams, interface tables, and configuration details. Includes an HTTP server for browser-based viewing.
pkg/plugin – Plugin system
Out-of-process plugin execution via JSON stdin/stdout. Discovers plugin binaries and manages the communication protocol. See the Plugin Development guide.
pkg/dashboard – Dashboard server
The exploration and observability layer of the Pacto system. See Dashboard architecture below for a detailed design description.
internal/app – Application services
Each CLI command maps to exactly one service method. This layer orchestrates pkg/* packages and infrastructure adapters. Methods are stateless: they take an options struct and return a result struct, never printing directly.
Init(),Validate(),Pack(),Push(),Pull()Diff(),Graph(),Explain(),Generate(),Doc()- Shared helpers:
resolveBundle(),resolveBundleWithOverrides(),loadAndValidateLocal(),loadAndValidateFull()
internal/cli – CLI layer
Cobra command handlers and Viper configuration. Zero business logic – only input parsing, orchestration, and output formatting.
pkg/oci – OCI adapter
Wraps go-containerregistry to handle OCI registry operations. This is a public package, importable by external consumers such as the Kubernetes operator. Pacto distributes contracts as OCI artifacts – the same standard behind container images – so they work with any OCI-compliant registry (GHCR, ECR, ACR, Docker Hub, Harbor) without new infrastructure. Every pushed contract is content-addressed with a digest, making it immutable and verifiable.
Key components:
BundleStoreinterface – the core abstraction:Push(),Pull(),Resolve(),ListTags()Client– implementsBundleStoreusinggo-containerregistry. Translates betweencontract.Bundleand OCI images (tar.gz layer with metadata labels)CachedStore– wraps anyBundleStorewith in-memory and disk caching (~/.cache/pacto/oci/<registry>/<repo>/<tag>/bundle.tar.gz). Can be disabled at runtime via--no-cacheResolver– lazy version resolution with semver filtering.Resolve()pulls bundles inLocalOnlyorRemoteAllowedmode.FetchAllVersions()pulls every semver tag to populate the cache.FilterSemverTags()selects valid semver tags sorted descending- Credential chain –
NewKeychain()tries sources in priority order: explicit flags/env vars, pacto config (~/.config/pacto/config.json),ghCLI token (GitHub registries), Docker config + credential helpers, cloud auto-detection (ECR/GCR/ACR), anonymous fallback - Typed errors –
AuthenticationError,ArtifactNotFoundError,RegistryUnreachableError,InvalidRefError,InvalidBundleError,NoMatchingVersionError
internal/mcp – MCP server
Thin adapter layer that exposes Pacto operations as Model Context Protocol tools. Each MCP tool handler delegates to an internal/app service method – no business logic lives here. The server communicates over stdio and is started via pacto mcp. Used by AI tools such as Claude, Cursor, and Copilot.
internal/logger – Logger setup
Configures Go’s standard log/slog default logger based on the --verbose flag. When verbose mode is enabled, debug-level messages are emitted to stderr; otherwise only warnings and above are shown. Called once during CLI initialization via PersistentPreRunE – all packages use slog.Debug() directly with no wrappers.
internal/update – Update checker
Performs async version checking against the GitHub releases API. Started in a background goroutine during CLI initialization, with a 200ms timeout to avoid blocking. Results are cached on disk for 24 hours (~/.config/pacto/update-check.json) to minimize API calls. Suppressed for dev builds and JSON output mode.
Dashboard architecture
The dashboard is complex enough to warrant its own design section. It provides a web-based UI for navigating contracts, dependency graphs, version history, interface details, configuration schemas, and diffs – aggregated from multiple data sources.
Public source model
The dashboard exposes exactly three public source types:
| Source type | Role | Key type |
|---|---|---|
local | Contract from filesystem | LocalSource |
oci | Contract from OCI registry | OCISource |
k8s | Runtime enrichment from Kubernetes | K8sSource |
There is no "cache" source visible to the API or UI. Materialized bundles on disk are an internal implementation detail of the OCI source (see Internal materialization below).
Discovery lifecycle
OCISource runs a continuous background loop (not one-shot):
- Shallow scan (synchronous, at first
ListServicescall) – oneListTags+Pullper configured repo. Fast. - Deep discovery (background goroutine) – BFS across dependency refs, prefetches all semver versions. Closes
s.doneafter the first cycle, ending the “discovering” UI state. - Periodic rediscovery – after the first cycle, re-runs every 60 seconds (
ociRediscoverInterval). Picks up new services, dependencies, and versions pushed since the last scan. Each cycle rescans the internal cache and invalidates in-memory caches so enrichment data (hash, classification) surfaces immediately.
K8sSource is query-on-demand with a 3-second list cache TTL. Each API call fetches fresh data – no background polling or watching.
Source categories
Sources are divided into two categories with different roles:
Contract sources (local, oci) provide the authoritative service definition – interfaces, configuration, dependencies, version, owner. Exactly one contract snapshot wins per service. Priority: local > oci (explicit dev intent wins over registry baseline).
Runtime source (k8s) enriches the contract with live cluster state – contract status, conditions, endpoints, resources, ports, scaling, insights, checks. Runtime data never overrides contract content. The enrichWithRuntime() function in source_resolver.go enforces this boundary: it copies k8s-specific fields but preserves all contract fields untouched.
Resolution model
ResolvedSource (source_resolver.go) is the central aggregation layer. It combines contract and runtime sources into a unified view:
- Contract resolution – iterates contract sources in priority order (local first, then oci). The first source that has the service wins. This produces one authoritative contract snapshot.
- Runtime enrichment – if k8s is available and has data for the service, runtime fields are layered on top of the contract snapshot without replacing any contract content.
- Service list – all sources are queried concurrently. Services are grouped by name across sources, merged using
mergeServiceEntry(). TheSourcesarray on each service lists all source types where that service was found.
BuildResolvedSource() constructs the ResolvedSource from the map of detected sources, automatically separating contract sources from the runtime source.
Version history
Version history is merged across sources in a defined order (resolverVersionSources in source_resolver.go):
- k8s – PactoRevision CRDs are most authoritative (deployed versions with timestamps)
- oci – registry tags provide the full version catalog
- local – current on-disk version
Cache/materialized bundles do not participate as a separate history source at this level. Instead, OCISource.GetVersions() internally enriches its bare tag listings with hash, createdAt, and classification from materialized bundles before returning them. This keeps cache as an internal OCI concern, invisible to the resolver.
Versions are deduplicated by version string. When the same version appears in multiple sources, enrichVersion() fills empty fields (hash, createdAt, classification, ref) from later sources without overwriting existing values.
Classification
ClassifyVersions() (version_classify.go) is a pure derivational function that computes diff classification between consecutive versions. It operates on BundlePair structs (tag + parsed bundle) and is independent of any data source.
Classification requires materialized bundles – both the current and previous version must have their contract bundles available locally. If either bundle is missing (not yet fetched from the registry), that version pair is skipped and receives no classification. This is intentional: classification is computed on demand as bundles become available, not assumed.
Internal materialization
CacheSource (source_cache.go) reads materialized OCI bundles from the disk cache (~/.cache/pacto/oci/). It is not a public data source – it exists solely as an internal backing store for OCISource, providing contract hash, classification, and createdAt enrichment from previously pulled bundles.
The flow:
OCISource.SetCache(cs)wires aCacheSourceinternallyOCISource.GetVersions()lists tags from the registry (bare version + ref), then enriches each version with hash, createdAt, and classification from the internal cache- Cache rescans happen in three places: after each background discovery cycle (
discoverAndPrefetch), after resolve operations, and after fetch-all-versions – all callRescanCache()+ memory cache invalidation so new data surfaces immediately - If no live OCI registry is configured but cached bundles exist,
ActiveSources()indetect.gomaps theCacheSourceunder the"oci"key – the user sees"oci"as the source, never"cache"
The createdAt timestamp from cached bundles reflects local materialization time (when the bundle was pulled to disk), not the registry push time. OCI registries do not expose push timestamps via tag listing.
--no-cache semantics
The --no-cache flag is a cold-start mode, not a fully stateless mode:
- At startup,
DetectSources()skipsdetectCache()entirely – no pre-existing cached bundles are scanned or indexed CachedStore.DisableCache()skips disk reads (no stale data), but disk writes remain enabled so same-session pulls are persisted- The dashboard resolves
cacheDirfromCachedStore.CacheDir()when not explicitly set, ensuringRefreshCacheSources()knows where to find materialized bundles - The
memCacheis always wired at startup (even with--no-cache) viaSetCacheSource(nil, memCache), ensuringRefreshCacheSources()can invalidate stale entries after on-the-fly creation - If the user triggers “Fetch all versions” or lazy dependency resolution,
RefreshCacheSources()creates aCacheSourceon the fly from disk and wires it into the OCI source for enrichment - The
onDiscovercallback is wired toserver.RefreshCacheSources(not justmemCache.InvalidateAll), so continuous background discovery also triggers on-the-flyCacheSourcecreation - This means
--no-cacheensures a clean start but allows same-session materialization to enrich the current view
Graph model
The dashboard builds two graph representations:
Global graph (buildGlobalGraph()) – a flat structure with GraphNodeData and GraphEdgeData, designed for D3.js force-directed visualization. Includes unresolved external dependencies as nodes with status: "external". Edges are typed as "dependency" (contract deps) or "reference" (config/policy refs).
Per-service graph (buildGraph()) – a recursive DependencyGraph with GraphNode and GraphEdge, used for tree visualization of a single service’s dependency chain. Includes cycle detection.
Both graphs use ref-alias mapping (buildRefAliases()) to resolve OCI repository names (e.g., my-service-pacto) to contract service names (e.g., my-service), based on imageRef and chartRef fields from the service index.
computeBlastRadius() performs BFS on the reverse dependency graph (required deps only) to count how many services would be transitively affected if a given service breaks.
Server and API
The HTTP server is built on Huma v2 with typed I/O structs and automatic OpenAPI 3.1 spec generation. Static files (embedded SPA) and CORS are served on the raw http.ServeMux; only API operations go through Huma.
Key API operations:
| Endpoint | Method | Purpose |
|---|---|---|
/health | GET | Health status + version |
/metrics | GET | Service and source counts |
/api/services | GET | Service list with blast radius, compliance, checks |
/api/services/{name} | GET | Full service details |
/api/services/{name}/versions | GET | Version history |
/api/services/{name}/sources | GET | Per-source breakdown |
/api/services/{name}/dependents | GET | Reverse dependency lookup |
/api/services/{name}/refs | GET | Config/policy cross-references |
/api/services/{name}/graph | GET | Per-service dependency tree |
/api/graph | GET | Global D3-ready dependency graph |
/api/diff | GET | Classified diff between two versions |
/api/sources | GET | Detected source status and discovery state |
/api/refresh | POST | Force-refresh all sources |
/api/resolve | POST | Lazy-resolve a remote dependency |
/api/versions | POST | List registry tags, optionally fetch all |
/api/debug/* | GET | Diagnostics (requires --diagnostics flag) |
When running alongside the Kubernetes operator, EnrichFromK8s() automatically discovers OCI repositories from CRD resolvedRef fields, enabling full contract bundles, version history, and diffs without explicit OCI arguments.
Version tracking
The dashboard computes version tracking semantics from two sources:
- Version policy (
versionPolicy): the preferred source is the operator’sstatus.contract.resolutionPolicyfield (Latest→"tracking",PinnedTag→"pinned-tag",PinnedDigest→"pinned-digest"), normalized byNormalizeResolutionPolicy(). When unavailable (non-K8s sources, older operators),ClassifyVersionPolicy()provides a conservative fallback that only classifies unambiguous cases (digest, explicit semver tag) and returns empty for ambiguous refs. - Latest available (
latestAvailable): the highest semver version from the existing version list. Computed byComputeLatestAvailable(). - Update available (
updateAvailable): true whenlatestAvailableis a higher semver than the currentversion. Computed byIsUpdateAvailable(). This is informational – it does not affect contract compliance status. - Current version marker (
isCurrent): set on theVersionentry matchingServiceDetails.VersionviaMarkCurrentVersion().
Operator-provided resolutionPolicy is propagated through the K8s source (serviceDetailsFromK8sStatus), carried forward by enrichWithRuntime(), and preserved by enrichVersionTracking() which only applies the fallback when no policy is already set.
These fields are populated during the service index cache rebuild (enrichVersionTracking()) and surfaced through the existing /api/services and /api/services/{name} endpoints.
Design principles
- Pure core –
pkg/*packages have zero CLI/Kubernetes dependencies and are reusable from any Go program - Strict layering – CLI → App → Core (
pkg/) → Domain (pkg/contract) - Observation separated from validation – runtime observation (collecting actual state from Kubernetes, CI, etc.) happens outside
pkg/; validation against observed state happens insidepkg/validation - No global state – all instances created in the composition root (
main.go); the only global isslog.SetDefault()configured once at startup - Interface-based – engines depend on interfaces (
DataSource,BundleStore,ContractFetcher,Runner), not concrete implementations - Out-of-process plugins – language-agnostic, version-independent
- Embedded schemas – JSON Schema compiled into the binary
- Deterministic validation – no configurable rules; same input, same result
Architectural invariants
These rules must be preserved by future changes. Each exists for a specific reason.
| Invariant | Rationale |
|---|---|
pkg/contract imports nothing from the project | Foundation layer. If it depends on anything above, the entire dependency graph becomes circular. |
pkg/* must not import internal/cli or internal/app | Core logic must remain reusable outside the CLI (operator, MCP, tests). |
pkg/oci is a public package | OCI primitives (client, credentials, tag resolution) are importable by external consumers such as the Kubernetes operator. |
| K8s enriches runtime only, never overrides contract content | Contract is the source of truth for interfaces, config, dependencies, version. K8s provides live state (contract status, conditions, endpoints). Mixing them would make the contract unreliable. |
| Cache must never become a public source | Users see three sources: local, oci, k8s. Cache is an internal optimization. Exposing it would create confusion about which “oci” data is authoritative. |
resolverVersionSources must not include "cache" | Cache enrichment happens inside OCISource.GetVersions(), not at the resolver level. Adding cache to the resolver would double-count versions. |
| Classification requires materialized bundles | ClassifyVersions() diffs consecutive bundles. Without both bundles available, no classification is computed. This is correct behavior, not a bug. |
--no-cache skips startup scanning, not same-session materialization | Cold-start mode ensures deterministic initial state. DisableCache() only skips disk reads — disk writes remain enabled so bundles fetched during the session are persisted and available for enrichment. |
DisableCache() must never disable disk writes | Same-session materialization (fetch-all-versions, resolve) relies on CachedStore writing bundles to disk. CacheSource then reads these during RefreshCacheSources() to provide hash, createdAt, and classification enrichment. |
internal/app methods are stateless | Options in, result out. No side effects beyond the operation itself. This makes testing and composition straightforward. |
| Validation is deterministic | No configurable rule sets. Same contract + same schema = same result, always. |
CacheSource labels its output as "oci", never "cache" | When cache stands in for OCI (offline fallback), mergeServiceEntry() picks up Source from the service. Labeling as "cache" would leak the internal concept to the UI. |
| OCI discovery is continuous, not one-shot | New services and versions pushed after startup must surface without restarting the dashboard. The background loop re-runs discovery every 60 seconds. |
| K8s enrichment retries stop on permanent errors | If the Pacto CRD is not installed (ListServices returns “resource not found”), EnrichFromK8s nils the K8s source so the retry loop exits immediately instead of waiting 30 seconds. |
| UI data refresh must not disrupt user state | DOM morphing preserves scroll position, form values, <details> open/closed state, and D3-managed containers. Debug panels use patchDOM instead of innerHTML replacement. |