ADR 0018: Declarative image builds — build backends + the building state
Status: Proposed
Date: 2026-07-08
Related: ADR-0005 (forge, the in-loop candidate builder this
extends), ADR-0008 (the composed-world images these
builds produce), ADR-0012 (digest pinning; rendered pods consume
image@digest), ADR-0013 (the pluggable-backend
precedent this copies), ADR-0015/ADR-0016
(the controller + ledger the building state lands in).
Context
Four tiers of image production exist today, and only the first two are automated:
- CI-built: the engine/loop image (
Containerfile.crucible,docker.yml) and the ADR-0008 world-base image, whose CI workflow ends with printed instructions telling a human to repinWORLD_BASEinContainerfile.crucible. - Hand-built: every sandbox and loop-openshell image. The Containerfiles embed the procedure
("on the arm64 laptop run the build remotely on the cluster"): mutable
:dev/:m1tags, digests hand-plumbed into manifests, deploy profiles, and gitops values. - In-loop candidate builds: forge — buildah
build_and_pushfor real Dockerfile builds, and the native-OCIderive_layer(oci-client: deterministic overlay layer, server-side blob mount, manifest rewrite) for base+edited-files candidates. This tier works. - Version-knob choreography:
VLLM_VERSION,REPO_SHA,WORLD_BASEare ARGs kept in sync by convention and comments.
Onboarding a new composite therefore means handwriting 2–3 Containerfiles, hand-running remote builds, and hand-plumbing digests — and the outer loop has no answer to "an approved pack implies a rebuilt world image" (the WorkKind::Build gap). Two prior calls shape the fix:
- On-cluster builds borrow buildit's detached rootless-buildah Job pattern: owner-ref'd registry secret, digest-file output, ttl-reaped, idle-node targeting. The controller must not stream build contexts; detached Jobs only.
- GitHub Actions dispatch is the second backend. Other teams already build images in Actions; for the ADR-0013 self-serve story a team without cluster build capacity brings a repo with a build workflow and crucible dispatches it. (The earlier "GH runners are very weak" ruling was about candidate test runs, which stay on-cluster; plain image builds are what those runners are fine at.)
Decision
1. One BuildBackend contract, two implementations
Cluster (detached rootless-buildah Job, folded into forge next to the existing buildah/oci
paths — we do not ship a separate buildit binary in the controller image) and GithubActions
(workflow_dispatch + poll). A backend's only obligations: run the build, push to the tag it was
given, report success/failure. Crucible resolves the digest itself via the existing
forge::oci::pin_digest against the pushed tag — the registry is the single source of truth for
both backends, retries are idempotent (a re-dispatch of an already-built context is a no-op
push), and no output ever has to travel back through Job logs or Actions artifacts.
2. Named builds, declared in the domain manifest
[build.backend-sandbox]
backend = "cluster"
image = "ghcr.io/example-org/backend-sandbox"
timeout = "30m"
[build.backend-sandbox.cluster]
containerfile = "domains/backend/Containerfile.sandbox"
context = "." # or an OCI-artifact ref (buildit's ctx_ref trick)
platform = "linux/amd64"
[build.backend-sandbox.watch]
paths = ["domains/backend/Containerfile.sandbox", "domains/backend/tools/**"]
[build.sandbox]
backend = "github-actions"
image = "ghcr.io/example-org/fullstack-sandbox"
needs = ["backend-sandbox"] # ordering + digest availability
[build.sandbox.github]
repo = "neuralmagic/crucible" # profile default; must pass the allowed-orgs whitelist
workflow = "crucible-build.yml" # must exist on the dispatched ref; we never generate it
ref = "main"
[build.sandbox.github.inputs] # forwarded after template expansion, nothing else
containerfile = "domains/fullstack/Containerfile.sandbox"
base-image = "{{ builds.backend-sandbox.digest_ref }}"
image = "{{ image }}:{{ tag }}"
Load-bearing pieces:
needsis a plain dependency list (reconcile won't dispatch a build until its deps have digests), and{{ builds.<name>.digest_ref }}hands the pinned output of a dependency to a downstream build. Together they retire the manual world-base repin dance mechanically.watch.pathsdefines "build-needed": hash the watched paths at the pinned ref → the context digest; rebuild when it differs from the last recorded build for this image. The same predicate drives both backends. No[build]block ⇒ today's behavior exactly (statically configured image, never rebuilt) — the schema is purely additive. An emptywatch.pathsinside a build block is the opposite: nothing to compare ⇒ always rebuild (a force signal that never equals a recorded digest), not a constant "nothing changed".- The template vocabulary is closed:
sha,tag,image,correlation_id,builds.<name>.digest_ref. Nothing else expands. Anything richer turns the config into a programming language and — because the manifest is agent-influenced content post-pack-approval — a string-injection path into someone's CI. For the same reason there is no env/secret pass-through: credentials live on the backend side (the workflow repo's own secrets, the cluster Job's mounted authfile), never in our config.
3. The workflow-file contract (versioned), with declared escape hatches
A conforming workflow (crucible-build.yml, contract v1) honors exactly two behaviors:
- echo the
correlation_idinput into the run name —workflow_dispatchreturns 204 with no run id, so the run name is how we find our dispatch; - push the built image to the
imageinput's tag.
Arbitrary pre-existing workflows that can't be modified declare how we adapt instead:
[build.sandbox.github.outputs]
digest = "registry-tag" # default; or { from = "artifact", name = "digest", path = "digest.txt" }
correlation = "run-name" # default; or "dispatch-window" (serialize dispatches per workflow)
Introspection is validation, not magic: at manifest load / admin time, fetch the workflow
YAML and parse on.workflow_dispatch.inputs — fail fast when the declared mapping misses a
required input or names one that doesn't exist, warn on type mismatches. Introspection checks the
wiring a human declared; it never invents mappings. (Later it can power the admin UI: discovered
inputs rendered next to the mapping.)
4. The controller blocks measurement on an explicit building state
Remote builds take single-digit minutes, so this is a reconcile state, not an inline preflight
wait: a build-needed row enters building with the dispatched Job/run and expected tag recorded;
reconcile polls the backend the way it already watches work pods; success pins the digest onto
the row and only then does the run become dispatchable (rendered pods consume image@digest,
ADR-0012). Failure parks the row with a build-log pointer as evidence; a timeout cap keeps a
wedged run from holding a rig slot; controller restart re-adopts in-flight builds by listing
Jobs/runs, never from memory. Budgeting: per-kind concurrency cap, no $ ledger — builds are
compute, not LLM spend.
5. crucible build <name> — the same path, human-invoked
The CLI subcommand dispatches a named build (either backend), waits, and prints the digest-pinned ref. It replaces today's hand-run remote cluster build and is the exact code path the controller dispatches later — one implementation, two callers.
6. Self-test on the self-hosting domain
The self-hosting domain (the engine-develops-itself pack, the cheapest full integration test we own)
gets a [build.loop] block dispatching this repo's own Actions build of
Containerfile.crucible. That proves the GithubActions backend end-to-end — dispatch,
correlation, poll, tag push, pin_digest — against CI we fully control, before any external
team's workflow is in the loop. The discrimination check mirrors ADR-0014: known-good = dispatch
at HEAD and assert the resolved digest matches the pushed tag; known-bad = a workflow ref whose
build must fail, asserting the row parks with the log pointer.
Alternatives considered
- Generate workflow files on the fly — rejected: repos own their build environment; we version a contract instead, like the turn-result envelope (ADR-0017).
- Digest reporting via artifacts/job outputs as the default — rejected: the registry already knows; kept only as a declared escape hatch for unmodifiable workflows.
- Inline preflight blocking instead of a
buildingstate — rejected: minutes-long remote builds would wedge reconcile; explicit state is also what restart re-adoption needs. - Ship buildit as a CLI inside the controller image — rejected: forge already has kube-rs, buildah args, and authfile handling; folding the detached-Job mode in avoids a second binary with its own release pipeline.
- Auto-map introspected inputs by name — rejected: silent mis-wiring into someone's CI; introspection validates declared mappings only.
Migration
- M0 — forge grows the detached-Job cluster backend +
crucible build(CLI-only). - M1 — controller
buildingstate + build dispatch/adoption in reconcile; approved packs with a[build]block flow through it. - M2 — GithubActions backend + workflow introspection validation;
crucible-build.ymlcontract v1 lands in this repo. - M3 — dogfood: the self-hosting domain's
[build.loop]self-test; convert the hand-built sandbox and loop-openshell images to declared builds; retire the world-base manual repin.
Consequences
Onboarding a domain's images becomes a manifest edit plus (optionally) a workflow file; digests
flow mechanically from build to consumer; the measurement rig can never run on a stale or
half-built image; and the hand-run build skill becomes a fallback rather than the procedure. The
cost: a new reconcile state, a versioned workflow contract to maintain, and GH-side coupling
(App installation needs actions:write) for the remote backend.