ADR-0006: Operator-owned runtime on a polyrepo
Status: Accepted · recorded retrospectively (decision from 2026-06) · amended by ADR-0011
Status — partly ahead of the code. Releases are tagged in every repository at once (releases), and the operator reads the front door's
releases.json:stablenames the newest release,edgethe floatinglatestimages published frommain. The all-in-one appliance is built inzaentrum-operator(deploy/allinone) and published asghcr.io/zaentrum/appliance— not by the front door, and not asghcr.io/zaentrum/zaentrum, which does not exist; a release's appliance pins the release, main's runslatest. The polyrepo and the operator as runtime owner are built as decided.
Context
Section titled “Context”Zaentrum began as a single orchestrator repository that contained everything: the operator, the deploy templates, and buildable source for nine services. "It includes everything" was true in three different senses, and conflating them hurt all three:
- Install / assembly — one place should tell you how to run the whole platform.
- Runtime — one component should own what actually runs.
- Source — each service's code should live where it is developed and released.
A repo that does all three cannot version any of them cleanly. Fixing one service meant releasing everything. The operator deployed from in-tree source, so "what is running" was whatever the tree happened to contain — not something a release names. And the install story was a README in the middle of a source tree.
There is a second constraint. Zaentrum is content-neutral: it catalogs, processes, and streams files the user already owns. Anything that obtains content is not part of the platform — it is an addon, developed and shipped outside this org. That boundary is only credible if it is auditable, and a boundary that runs through the middle of one source tree is not.
Decision
Section titled “Decision”One rule, three repo shapes: one repo = one container = one image = one release.
1. Polyrepo, with visibility as the only axis. Every service gets its own repository in the zaentrum org and builds exactly one image, ghcr.io/zaentrum/<service>. Because a repository is wholly one visibility level, whether a component is part of the open platform is decided once — when its repo is created — and anyone can audit the boundary by reading the org page. No private path can hide inside a public tree, and CI can enforce the neutrality boundary with a grep instead of a policy document. Addons — the public example is acquire — live in their own repos under their own owners and are never merged in.
2. A front-door repo: zaentrum/zaentrum. This is the only repository that knows the set. It holds the install docs, releases.json (the release channel the operator's auto-update consumes), and the all-in-one appliance image — the docker run one-liner. The appliance composes released image tags; it never builds service code. A front door that built from source would be a second source of truth for every service and would drift from what the services actually released. Composing pins instead makes an appliance release a lockfile: reproducible, and rollback is the previous pin.
3. An operator repo: zaentrum/zaentrum-operator. The controller, the Zaentrum CRD, the deploy templates, and the OLM bundle. The operator is the runtime owner: a cluster declares one Zaentrum CR, and the operator reconciles the platform from published image tags — the same artifacts the appliance pins. Nothing else deploys anything the platform owns. The decision also reserved the CR as the future home of declarative addon installation (an addons/plugins list). ADR-0011 replaced that reservation: each addon is a Helm chart declared by its own ZaentrumAddon resource, which the same operator reconciles — see addon charts. A stock platform is fully working with no addons at all.
flowchart LR
subgraph org["github.com/zaentrum"]
svc["service repos\none image each"]
fd["zaentrum — front door\ninstall docs · releases.json\nappliance image"]
op["zaentrum-operator\ncontroller · Zaentrum CRD\ndeploy templates · OLM bundle"]
end
reg[("ghcr.io/zaentrum/*\nreleased image tags")]
addon["addon repo, e.g. laedeli/acquire\noutside the platform org"]
run["cluster or appliance host"]
svc -- "release → build → push" --> reg
fd -- "pins tags (lockfile)" --> reg
op -- "reconciles pinned tags\nfrom the Zaentrum CR" --> run
addon -. "chart installed via a ZaentrumAddon (ADR-0011)" .-> op
This isn't tidiness for its own sake. When one repo is one container is one image, a release tag answers "what is this, where did it come from, what is running" with the same string — and the seam between platform and addon is a repo boundary, which is the one boundary everyone can see.
Consequences
Section titled “Consequences”- A service fix ships alone. One repo releases, one image tag moves, the front door bumps one pin. Neighbors are untouched.
- The set is defined once and enforced once. Only
zaentrum/zaentrumknows which tags make a platform release; only the operator turns that into running pods.docker runand a cluster install run the same bits. - Cross-service changes span PRs. That is the polyrepo tax. It is paid down with a versioned
schemasrepo: consumers pin a contract version instead of editing N repos atomically, so an interface change is a version bump per consumer, on each consumer's schedule. - CI is meant to be shared, not duplicated. The decision places reusable workflows (build-and-push, lint, the neutrality guard) in a shared
zaentrum/.githubrepo — not yet created; today each repo carries its own copies, and the client repos plus the operator each run their own neutrality gate. - Pins go stale by design, so a bot (Renovate-style) proposes tag bumps to the front door. A stale pin is visible and reviewable; an in-tree build drifting was neither.
- The operator embeds its own copy of the deploy templates. A change to the deploy contract must land both in
deploy/and in the operator's embedded templates. Known cost, accepted: it keeps the operator image self-contained, so a cluster needs the operator and a CR — not a checkout. - Addons are runtime composition, not source integration. An installation adds capability by declaring an addon chart in a
ZaentrumAddonresource. The platform's repos never gain the code; the running system gains the pod.
What this rules out
Section titled “What this rules out”- No monorepo. No shared tree where release units blur and a visibility boundary would have to run through directories.
- No builds in the front door.
zaentrum/zaentrumpins released tags; it has no service source to build. - No deploys from source or floating tags. The operator reconciles named releases. "Whatever is on main" is not a deployable state.
- No content acquisition in the platform. Nothing in the
zaentrumorg obtains content — no code that talks to indexers, trackers, usenet, or torrent sources. That capability exists only as addons installed from their own charts, maintained outside the platform, and a stock install ships with none.