ADR-064: gyrum-catalog library + hexagonal architecture
Status: Accepted Date: 2026-04-22 Related: ADR-060 (catalog-driven infrastructure), ADR-061 (extract pipelines + projects libraries)
Context
ADR-060 introduced capture-layer experiments (exp-315 Hetzner audit, exp-316
GitHub repo audit, planned exp-317 Prometheus drift, exp-318 Caddy routes,
exp-319 fleet rollup). Each is a standalone shell script under
experiments/exp-NNN-slug/, orchestrating its own plug-ins, writing its own
JSON envelope, handling its own errors.
ADR-061 extracted the pipeline engine and project schema into standalone Go +
TypeScript libraries (gyrum-pipelines, gyrum-projects). The catalog work
needs the same treatment for the same reasons: every new audit experiment is
re-implementing the same orchestration, envelope shape, secret-scrubbing,
schema versioning, and target-type taxonomy.
The operator's framing on 2026-04-22 review of the audit experiments:
with the experiments to extract the catalog, how about a common library or hex architecture design so there's more of a consistency that can be used cataloguing the different services
The risks of not extracting:
- Envelope drift. exp-315 already had to define its own envelope; exp-316
followed but with subtle differences (e.g.
target.typecasing). Without a library, exp-330 will diverge again. - Secret-scrubbing inconsistency. ADR-060's boundary rule says catalogs never carry secrets. Today each experiment script must enforce this in its own way. A common library can scrub by default.
- Plug-in reuse. A
docker-containersplug-in is identical whether it runs inside exp-315 (Hetzner audit) or inside a future exp-330 (Kubernetes node audit). Without a registry, copy-paste is the only sharing mechanism. - Schema migration pain. When
schemaVersionbumps from1.0to2.0, every consumer that hand-parses the JSON breaks. A typed library makes the migration mechanical.
Decision
Extract a third standalone library — gyrum-labs/gyrum-catalog — alongside
gyrum-pipelines and gyrum-projects (per ADR-061). The catalog library is
the canonical implementation of:
- The catalog envelope schema (matching
docs/specs/catalog-spec.md) - The Plug-in interface (
Run(ctx, target) → Result) - The Runner that orchestrates plug-ins against a target
- Cross-cutting concerns: secret scrubbing, timing, error capture, schema versioning, target-type taxonomy
- Storage adapters (filesystem, blob, git-versioned, in-memory-for-tests)
gyrum-catalog ships Go (canonical) + TypeScript (mirror), follows
the same GitHub-standard skeleton as ADR-061, ships TDD-first.
Why a third library, not folded into gyrum-pipelines
Catalogs and pipelines are conceptually distinct:
- A pipeline composes work units (any work, generic). The runner doesn't care what the work produces.
- A catalog is a specific shape of work output — a versioned, structured observation of a target. The catalog library has cross-cutting concerns (secret scrubbing, schema version) that pipelines don't.
A catalog uses pipelines internally (the orchestration of plug-ins is a pipeline-style flow), but the catalog domain is its own thing. Folding it into gyrum-pipelines would either pollute the pipelines library with catalog-specific logic, or hide the catalog domain inside pipeline names. Cleaner separation: pipelines is the workflow primitive, catalog is the observation primitive that USES the workflow primitive.
Hexagonal architecture
The library is structured as a hexagon:
┌─────────────────────┐
│ Domain │
│ (no dependencies) │
│ │
│ Catalog, Target, │
│ Plugin (interface),│
│ Result, Envelope │
└──────────┬──────────┘
│
┌─────────────────┴─────────────────┐
│ │
┌───▼───┐ ┌────▼────┐
│ Ports │ │ Ports │
│ (in) │ │ (out) │
└───┬───┘ └────┬────┘
┌─────────┴─────────┐ ┌────────┴────────┐
│ RunCatalog │ │ Storage │
│ ListPlugins │ │ Scrubber │
│ ValidateSchema │ │ ClockSource │
└─────────┬─────────┘ └────────┬────────┘
│ │
┌─────────┴─────────┐ ┌────────┴────────────────────┐
│ Adapters in │ │ Adapters out │
├───────────────────┤ ├─────────────────────────────┤
│ CLI runner │ │ Filesystem storage │
│ HTTP API │ │ Blob (S3-compat) storage │
│ Pipeline-block │ │ Git-versioned storage │
│ (in gyrum-pipelines│ │ In-memory storage (tests) │
│ via gy-block) │ │ Regex secret scrubber │
└───────────────────┘ │ Real-clock / mock-clock │
└─────────────────────────────┘
Domain (zero dependencies)
package catalog
// Target identifies what is being captured. The taxonomy is closed —
// new types are minor-version additions to this package.
type TargetType string
const (
TargetHetznerServer TargetType = "hetzner-server"
TargetGitHubRepo TargetType = "github-repo"
TargetFleet TargetType = "fleet"
TargetPrometheus TargetType = "prometheus-instance"
TargetCaddyHost TargetType = "caddy-host"
)
type Target struct {
Type TargetType
ID string
}
// Plugin is the unit of capture. Every probe implements this.
type Plugin interface {
Name() string
Version() string
Run(ctx context.Context, target Target) (Result, error)
}
// Result is what a plugin returns. It MUST be JSON-serializable.
// The scrubber walks the tree before serialization.
type Result struct {
Data any
}
// PluginResult is one slot in the catalog envelope.
type PluginResult struct {
Name string
Version string
OK bool
DurationMs int64
Result Result
Error string
Stderr string
}
// Catalog is the envelope. Stable schema; matches catalog-spec.md.
type Catalog struct {
SchemaVersion string
Target Target
CapturedAt time.Time
CapturedBy string
CapturedBySchemaVersion string
Plugins []PluginResult
Warnings []string
}
The domain has zero external dependencies. No HTTP libraries. No SSH libraries. No filesystem. Pure types + interfaces.
Ports (interfaces over external concerns)
// Storage persists catalogs. Implementations: filesystem, blob, git, memory.
type Storage interface {
Put(ctx context.Context, c *Catalog) (CatalogRef, error)
Get(ctx context.Context, ref CatalogRef) (*Catalog, error)
List(ctx context.Context, target Target, opts ListOpts) ([]CatalogRef, error)
SetBaseline(ctx context.Context, target Target, ref CatalogRef) error
Baseline(ctx context.Context, target Target) (*Catalog, error)
}
// Scrubber removes secrets from a catalog before serialization. Default
// implementation: regex over common patterns (PEM headers, GitHub PAT
// shapes, JWT shapes, AWS keys). Pluggable so consumers can add their
// own patterns.
type Scrubber interface {
Scrub(c *Catalog) (*Catalog, []ScrubFinding, error)
}
// ClockSource — for test determinism on CapturedAt.
type ClockSource interface {
Now() time.Time
}
Core (use case)
// RunCatalog is the central use case: take a target + a list of plugins,
// orchestrate, return a Catalog. Pure given the ports — no I/O directly,
// only through interfaces.
type RunCatalog struct {
Storage Storage
Scrubber Scrubber
Clock ClockSource
CapturedBy string // experiment id, e.g. "exp-315-hetzner-server-audit"
}
func (r *RunCatalog) Run(ctx context.Context, target Target, plugins []Plugin) (*Catalog, error) {
started := r.Clock.Now()
cat := &Catalog{
SchemaVersion: "1.0",
Target: target,
CapturedAt: started,
CapturedBy: r.CapturedBy,
CapturedBySchemaVersion: "1.0",
}
for _, p := range plugins {
slot := r.runOne(ctx, p, target)
cat.Plugins = append(cat.Plugins, slot)
}
scrubbed, findings, err := r.Scrubber.Scrub(cat)
if err != nil {
return nil, fmt.Errorf("scrub: %w", err)
}
if len(findings) > 0 {
for _, f := range findings {
scrubbed.Warnings = append(scrubbed.Warnings, fmt.Sprintf("scrubbed: %s", f.Pattern))
}
}
if _, err := r.Storage.Put(ctx, scrubbed); err != nil {
return nil, fmt.Errorf("storage.Put: %w", err)
}
return scrubbed, nil
}
func (r *RunCatalog) runOne(ctx context.Context, p Plugin, target Target) PluginResult {
started := r.Clock.Now()
result, err := p.Run(ctx, target)
elapsed := r.Clock.Now().Sub(started).Milliseconds()
if err != nil {
return PluginResult{Name: p.Name(), Version: p.Version(), OK: false, DurationMs: elapsed, Error: err.Error()}
}
return PluginResult{Name: p.Name(), Version: p.Version(), OK: true, DurationMs: elapsed, Result: result}
}
The core is deterministic given its ports. Tests inject mock Storage, mock Scrubber, mock Clock — no I/O. Tests run in microseconds.
Adapters
Adapters are external implementations of ports. Each is its own subpackage so consumers import only what they need (small bundle in TS, narrow Go import graph):
pkg/
├── catalog/ # Domain (no deps beyond stdlib)
├── runner/ # Core use case (depends on catalog only)
├── scrubber/
│ └── regex/ # Default Scrubber implementation
├── storage/
│ ├── memory/ # In-memory (tests, smoke runs)
│ ├── filesystem/ # Files in a directory
│ ├── git/ # Filesystem + auto-commit
│ └── blob/ # S3-compat
├── plugins/ # Reference plug-ins (callers can write their own)
│ ├── docker/ # docker-containers plug-in
│ ├── ports/ # listening-ports plug-in
│ ├── systemd/ # systemd-services plug-in
│ ├── disk/ # disk-usage plug-in
│ ├── packages/ # installed-packages plug-in
│ ├── caddy/ # reverse-proxy-config plug-in
│ ├── github/ # github-* plug-ins (workflows, branch-protection, ...)
│ └── prometheus/ # prometheus-targets plug-in
├── targets/ # Target-type registry, validation
├── ssh/ # SSH executor used by server-side plug-ins
├── ghapi/ # GitHub API client used by github plug-ins
└── cli/ # CLI adapter — `gy-catalog run --target ... --plugin ...`
How plug-ins compose with this library
A plug-in is a Go type implementing catalog.Plugin. The SSH port is a
two-interface design — Executor hands out per-host Session handles,
and Session owns the remote /tmp/audit-* workspace for the run's
duration:
// Executor connects to a target host; returns a Session handle.
type Executor interface {
Connect(ctx context.Context, host string) (Session, error)
}
// Session is the per-host handle that owns the /tmp/audit-* workspace
// for the run's duration. Close is responsible for cleaning up remote
// state regardless of success/failure.
type Session interface {
PrepareRemoteDir(ctx context.Context) (string, error)
CopyFile(ctx context.Context, localPath, remotePath string) error
Run(ctx context.Context, cmd string) (stdout []byte, err error)
Close() error
}
Example plug-in using the port:
package docker
type Plugin struct{ Exec ssh.Executor }
func (p *Plugin) Name() string { return "docker-containers" }
func (p *Plugin) Version() string { return "1.0" }
func (p *Plugin) Run(ctx context.Context, target catalog.Target) (catalog.Result, error) {
if target.Type != catalog.TargetHetznerServer {
return catalog.Result{}, errors.New("docker plug-in requires server target")
}
sess, err := p.Exec.Connect(ctx, target.ID)
if err != nil { return catalog.Result{}, err }
defer sess.Close() // cleans up /tmp/audit-* on the remote
out, err := sess.Run(ctx, `docker ps --format '{{json .}}'`)
if err != nil { return catalog.Result{}, err }
parsed, err := parseDockerPS(out)
if err != nil { return catalog.Result{}, err }
return catalog.Result{Data: parsed}, nil
}
Bash shell scripts in experiments/exp-NNN-slug/plugins/ STAY (they're the
on-target executables); the Go side wraps them as the Plugin interface
implementation. Or — once the library matures — plug-ins can be pure Go,
using the SSH adapter to run arbitrary commands directly. Both styles
coexist; bash plug-ins are easier to write and ship to the target, Go
plug-ins are easier to test in isolation.
How experiments use the library
After extraction, an experiment shrinks dramatically. exp-315's
audit-server.sh becomes either:
- A thin wrapper that exec's
gy-catalog run --target hetzner-server --id $SERVER --plugins docker,ports,systemd,disk,packages,caddy --out $EXP_DIR, OR - A 30-line Go program in the experiment dir that imports the library and registers its plug-ins
Either way, the orchestration logic (timing, error capture, envelope, scrub, storage) lives in the library, NOT in the script. New experiments contribute plug-ins and target types, not orchestration code.
TypeScript mirror
The TypeScript side has the SAME domain types and a read-only consumer for:
- Validating catalogs at the wire boundary (frontend reads
/api/v1/catalogs/...JSON; types come from the library) - Running scrubber client-side (defense-in-depth — never trust the server forgot to scrub)
- Diff visualisation:
audit:diffblock output is structured; the TypeScript side renders it natively without re-parsing - Type-safe import wizards: see ADR-061's
gyrum-projectsmirror pattern
Cross-language fixture parity (per ADR-061) is enforced via shared
fixtures/ and a parity.yml workflow.
Repository skeleton (gyrum-catalog)
Follows the GitHub-standard layout from ADR-061 § "Repository layout" exactly (LICENSE, CONTRIBUTING, SECURITY, CODE_OF_CONDUCT, .github workflows, docs/concepts/userguide/tutorials/examples/reference, tests/, fixtures/, ts/, pkg/). Specifically for gyrum-catalog:
gyrum-labs/gyrum-catalog/
├── README.md # Quick start: define a plugin, run, get JSON
├── docs/
│ ├── concepts.md # Domain, ports, adapters, plugins
│ ├── userguide.md # Full reference
│ ├── tutorials/
│ │ ├── 01-first-plugin.md
│ │ ├── 02-custom-storage-adapter.md
│ │ ├── 03-cross-target-plugin.md # one plug-in working on multiple Target types
│ │ ├── 04-catalog-diff-and-baseline.md
│ │ └── 05-rag-context-from-catalog.md # RAG use case (mirrors ADR-061's pipeline RAG tutorial)
│ ├── reference/
│ │ ├── envelope-schema.md # mirror of catalog-spec.md
│ │ ├── plugin-interface.md
│ │ ├── target-types.md
│ │ └── scrubber-rules.md
│ └── faq.md
├── pkg/ # Go canonical impl
├── ts/ # TypeScript mirror
├── fixtures/ # Shared catalog JSONs (valid + invalid)
├── examples/ # End-to-end runnable demos
└── ... # standard GitHub-standard files
Migration path
Same pattern as ADR-061: small PRs, application repos depend on the library once it exists, application code shrinks.
| Step | Where | What |
|---|---|---|
| 1 | gyrum-catalog (new repo) | Bootstrap: skeleton, LICENSE, branch protection, CI |
| 2 | gyrum-catalog | Extract domain types + RunCatalog + memory storage + regex scrubber + 2 plug-ins (docker, ports). First v0.1.0 |
| 3 | gyrum-catalog | TypeScript mirror of domain + parser. Cross-language parity tests |
| 4 | gyrum-catalog | Tutorials + reference docs |
| 5 | ai-research | exp-315 wraps gy-catalog run instead of inlining orchestration |
| 6 | gyrum-catalog | Add filesystem storage + git-versioned storage adapters |
| 7 | ai-research | exp-316/317/318/319 also use the library |
| 8 | gyrum-pipelines | Register audit:capture block that wraps a catalog Plugin invocation. Now ADR-060's composition layer can chain catalog operations natively. |
Each step independently shippable. Application repos work throughout.
Consequences
Easier:
- New audit experiments are smaller — they contribute plug-ins, not orchestration
- Envelope drift becomes impossible — the library owns the envelope; can't invent a different one
- Secret scrubbing is on by default — opt-OUT is explicit, not opt-IN
- Cross-target plug-ins become possible (one plug-in works for both Hetzner servers and dedicated bare-metal, etc.)
- Visualisation: TypeScript consumers get typed access to the catalog tree
- The catalog domain is independently testable; mock storage, mock scrubber, mock clock — no real I/O in unit tests
Harder:
- Three libraries to coordinate (pipelines, projects, catalog) and their release cadences
- Cross-library refactors: a target-type addition in catalog might warrant a block addition in pipelines
- Schema migration discipline: bumping
SchemaVersionrequires a TypeScript mirror update + parity-test fixture rewrite
What we sign up to maintain:
- Catalog envelope as a stable contract (catalog-spec.md remains the human reference; the library is the machine reference; CI parity test ties them)
- Plug-in interface stability — any signature change is a major version
bump on
gyrum-catalog - The hexagon: never let infrastructure (HTTP, SSH, FS) leak into the domain. Adapters are the boundary; static analysis can enforce.
- Target-type taxonomy (closed enum). Adding a target type is an explicit contribution, not a YOLO string.
Decisions that evolved during implementation
The ADR's original SSH-port sketch was a one-shot Exec.Run(ctx, host, cmd) -> out.
When the SSH adapter actually shipped in gyrum-catalog v0.0.8
(gyrum-labs/gyrum-catalog#7) it settled as a richer two-interface design:
Executor.Connect(ctx, host) -> Session, where Session exposes
PrepareRemoteDir / CopyFile / Run / Close.
This shape matches exp-315's real needs — scp-then-run-then-cleanup — and
gives Session.Close a natural home for remote /tmp/audit-* cleanup,
without the caller threading a directory path through every Run call.
The one-shot sketch would have forced either a separate port for file
transfer, or leaky per-call host+workdir parameters on every invocation.
This is not a breaking change to ADR-064's thesis. The hexagonal architecture (domain / ports / adapters) is unchanged; only the SSH port's signature evolved between sketch and implementation.
Alternatives considered
- Keep orchestration in each experiment. Rejected: drift surface area scales with experiments. We already see drift in just two scripts (exp-315 vs exp-316).
- Fold catalog into gyrum-pipelines. Rejected: catalog has its own domain (envelope, scrubber, target taxonomy) that pollutes pipelines. The libraries SHOULD be small and focused; this is a third focus area.
- Use a generic data-pipeline library (Apache Beam, Prefect, Dagster). Rejected: those are full systems with their own runtime requirements. catalog is small, opinionated (single-target capture, not data ETL), and benefits from being purpose-built.
- Pure Go library only, no TypeScript mirror. Rejected: same reasoning as ADR-061 — schema drift between Go server and TS frontend is the default state without a shared library; we are not paying for it.
How this hooks into ADR-058/059/060/061/062/063
- ADR-058 (5-mode UI): catalog viewers live in Infra mode; tab on each
server detail page rendered by
gyrum-uifrom gyrum-catalog's TS types - ADR-059 (project-first IA): a project's catalog membership comes from
joining its
reposfield with capture-by-target - ADR-060 (catalog-driven infrastructure): this ADR is the implementation layer for ADR-060's two-layer model
- ADR-061 (libraries extraction): same skeleton, same TDD, same GitHub-standard. Three libraries side-by-side, coordinated via shared fixtures and shared review discipline
- ADR-062 (design system / Web Components): catalog tree visualisation
is a
<gy-catalog-view>Web Component; uses gyrum-catalog's TS types - ADR-063 (tenant scoping + portability): each catalog carries a
tenant: ...field (already in the envelope schema); export-import bundles serialise per-tenant catalogs
Open questions for follow-up
- Plug-in versioning policy across the registry. When
docker-containersbumps from v1.0 to v2.0, does the runner accept both, or refuse old? Lean: accept both for one minor release window, then drop. Document policy indocs/reference/plugin-interface.mdonce written. - Caching. Some plug-ins are cheap (1ms), some are expensive
(
installed-packagesSSHes + greps a long list). Cache key is(target, plugin-version, ttl). Out of v1; revisit when audits run per-minute. - Streaming results. Long captures (fleet rollup) might benefit from streaming partial results to the consumer instead of buffering. v1 is buffered; revisit when fleets exceed ~100 targets.
- WASM: Could the Go core compile to WASM and let the TS side run the same orchestration locally (e.g. for "audit my own browser" type use-cases)? Cute but speculative. Out of v1.