ADR-0038 Architecture decision record
jr2 up builds every image it deploys, except a pinned one
ADR-0019 promised one converging command, but jr2 up builds exactly one image — the instance's. The Harness and operator images come from just recipes at mutable tags (jr2-harness:local), pointed at by images overrides in jr2.config.ts. Nothing can detect that a mutable tag moved, so editing packages/harness/src and running jr2 up reports convergence onto pods running last week's code — the failure the Instance's own artifact already fixed for itself by hashing the materialized bundle rather than the source folder (then its image; now its Assembly, ADR-0069). ADR-0037 would add a second class of build-it-yourself-first image on top of that.
Decision
- Every image
jr2 updeploys,jr2 upbuilds — when its source is visible. The CLI detects a kit checkout by resolving from its own module URL and requiring bothdeploy/harness/Dockerfileandpackages/harness/package.jsonnaming@jr2/harness. In a checkout it builds the Harness and operator images; installed from npm those paths do not resolve, so a real instance takes the published-<kitversion>path and never needs docker for kit images. The checkout is the signal — no flag, no config key, no env. A CLI loaded from undernode_modulesis never a checkout, even when a checkout contains it: an instance inside the repository that installs@jr2/*from npm takes its pinned version's published images, never a build of HEAD beside an Orchestrator at the pin. Thejustrecipes survive as shortcuts for building one image without a converge, never as prerequisites. A registry-ref Sandbox Image (ADR-0037) is the one deployed image whose source is nobody's here: never built, labeled, or delivered by jr2 — the cluster pulls it, and its tag discipline is its owner's. - A Pinned image is deployed and never built. The Custodian runs upstream Envoy (ADR-0059): jr2 writes its configuration and none of its code, so there is no source to build from and a jr2-built Envoy would be a fork to maintain. The kit names it by tag AND digest (
CUSTODIAN_IMAGEin@jr2/orchestrator), so a kit version deploys exactly one set of bytes in both worlds — checkout and installed alike — andimagePullPolicy: IfNotPresentis correct for the same reason a content-addressed tag is.jr2 upresolves its ref into the image map (thecustodiankey) — re-homed ontokitRegistrywhen one is set (ADR-0044) — and delivers nothing: a node pulls it. It is not a Kit image (GLOSSARY.md: "Pinned image"), and moving the pin is a kit change. - Every tag is a content address. Orchestrator, Sandbox, Harness, operator — each addressed by its own inputs and its platform set, the platform as a visible tag suffix (ADR-0045). Three things follow:
imagePullPolicy: IfNotPresentbecomes correct rather than lucky (a unique tag per content means "present" implies "current"), which is what makes kind and a real cluster behave identically instead of needingNeveron one andAlwayson the other; a kit source edit moves its own image's tag with no bookkeeping; and skipping is exact. - Over-hash deliberately. A kit image is hashed over its whole source directory, tests included, not over the exact file list its Dockerfile copies. Deriving the list by hand means a new
COPYsilently desynchronizes it, which is the invisible-stale-image bug being deleted; a needless rebuild in kit dev costs cached-layer seconds. A Sandbox Image's hash covers its build-context directory alone — the Harness rides the pod's/opt/jr2volume (ADR-0037), so a kit edit moves the harness image's own tag and touches no Sandbox Image tag. That hash is also the map KEY the deployed Orchestrator looks the ref up by, which is what lets a Machine name its context byfile:URL and be understood on both sides without a path table (ADR-0049). - A staged tree records nothing about where or when it was staged. The Assembly's hash addresses the staged tree (ADR-0069), so anything in that tree that names its own scratch directory — or the minute it was written — makes one hash name many Assemblies, and "present implies current" stops being true for the one artifact every Instance publishes.
pnpm deploywrites both: each.binshim bakes the staging path intoNODE_PATH, andnode_modules/.modules.yamlis nothing but a record of where and when. So the tree is sealed before it is hashed — the.binshims are deleted, since the Orchestrator runs no bin from an Assembly and a path that names the Assembly's own directory cannot be rewritten to one that contains its own hash;.modules.yamlis deleted with them. (Amended by ADR-0043: the bundle ispnpm deploy's only for a workspace-member instance; a standalone one is a staged copy plus a frozen lockfile install, whose shims name no absolute path — but whose pnpm branch leaves a second where-and-when record,node_modules/.pnpm-workspace-state.json, deleted with.modules.yaml.) A non-UTF-8 file holding the path is a loud failure, never a blind rewrite. Nothing is excluded from the hash any more. The old exclude set named exactly the files that varied, so the tag stood still while the bytes moved and the mechanism that should have exposed the drift was the one hiding it; with an empty set the failure inverts — a bundle that ever varies again re-tags on every converge, in the open, where a rebuild-and-reload every single time is impossible to miss. - One transport branch for all of them, the one the Sandbox Images use:
registryconfigured → push; kind context →kind load; neither → fail loudly namingregistry.jr2 uprecords the converged name→ref map as an annotation on the Orchestrator Deployment and diffs it, so a steady-state converge spends a directory walk and no docker at all. Amended by ADR-0041: when the record is silent (a fresh namespace), the host daemon's own labeled listing answers the build question — the seal below is what made "present implies current" true enough to ask it — while the record keeps answering the delivery one. - The resolved name→ref map reaches the Orchestrator as a ConfigMap, read per provision — never as Deployment env. Env is a pod-template change, so every Dockerfile edit would roll the Orchestrator and put every live run through snapshot restore (ADR-0007) for a change that affects only future Sandboxes. The map is data consulted when creating a pod, not configuration defining the process. The cost is a stale-read window of one kubelet propagation after
jr2 up, and that two workspaces provisioned seconds apart can straddle a change — which was already true across a roll. - The
imagesconfig block is deleted outright — no key, no env escape hatch. Its per-image entries were kit-dev overrides that auto-build now covers; the User Container's image is a staticworkspace()option the Machine carries, not config (ADR-0005, ADR-0049), so nouserentry belongs here either. Nobody should be able to run a patched Harness against a real cluster: that is ADR-0027's "no eject hatch" enforced rather than merely stated. jr2 upreports live workspaces on an older image; it never re-images one. Provision is create-if-absent, so a running Sandbox keeps the image its CR was created with — the only safe behavior, since replacing the pod takes the worktrees and unpushed commits with it, which is precisely the Continuity break ADR-0021 exists to report. So the converge lists them (kubectl get sandboxes, no new state) and stops: "2 running workspaces keep…:9c1e02; new workspaces use:4a77b1; delete these runs to re-image."jr2 downprunes this instance's images from kind nodes by default — superseded by ADR-0039, which replaces name-scoped, down-only pruning with a label-scoped reachability sweep at bothupanddown, host daemon included. Kept as written for the record: scoped to EXACT repo names:jr2-instance-<name>:*plus, per discoveredimages/<x>/,jr2-sandbox-<name>-<x>:*and its-baseintermediate (matched after stripping containerd'sdocker.io/library/namespace, whichkind loadnormalizes local tags into — and nothing else, so a registry-pushed tag keeps its host and stays unmatched). Exact names, never one open-endedjr2-sandbox-<name>-prefix, which also matches instance<name>-extra's images on a shared node. Removal is planned per image id:crictl rmicannot untag — it resolves any tag to the id and takes the whole image, every tag with it — so an id is removed only when every tag on it is this instance's, and an id sharing tags with anyone else (two instances whose image inputs are byte-identical produce one id) is kept whole and reported. Content addressing means ten Dockerfile iterations leave ten full images in the node's containerd, invisible tokubectland on the developer's own disk. Kit images are never pruned — they are shared by every instance on the cluster — and neither are registry-pushed tags. The accepted remainder: deleting animages/<x>/folder orphans that image's already-loaded tags (nothing derives their names any more); they go with the cluster, or by hand.- No
workspace(), no Sandbox Image builds. A registered Machine that composes a Sandbox is the data-plane switch, read off the walk (ADR-0051): a workspace-less instance has no Sandboxes, so it must not pay a docker build for a scaffoldedimages/default/it can never use.
Considered options
- An env escape hatch for kit image refs (
JR2_HARNESS_IMAGE, …), kept for the@kindtier, which pinsjr2-harness-dev:local. Rejected once the stub was read properly:deploy/harness-dev/is a whole alternate Harness — the stub plus a hand-rolled MCP client — written when "flue's real Harness image is not part of this repo," which ADR-0027 made false. The substitution belongs at the provider, not the image:harness.provideralready accepts any OpenAI-compatiblebaseUrl, so pointing@kindat a scripted model endpoint runs the stock Harness and removes the last consumer of image substitution. - The ref map as Deployment env (symmetric with the harness config the Harness containers read from theirs). Attractive because a run's Sandbox image becomes a deterministic function of the Orchestrator generation instead of a read-at-a-moment. Rejected on the roll: bouncing every run in flight because someone added a CLI to a Dockerfile is the wrong trade.
- A
--reimageflag that deletes and re-provisions live Sandboxes. Rejected: it destroys unpushed work, and the run already has a designed path for losing its Sandbox (workspace.lost, ADR-0021) that the workflow's policy drives — not the CLI. - Hashing only the files each Dockerfile copies, or hashing the whole kit tree via git. The first desynchronizes silently; the second rebuilds every image on any edit anywhere. Per-image directory hashing sits between them and errs toward rebuilding.
- pnpm's hoisted node linker (
deploy --node-linker=hoisted), which emits no absolute path at all and so needs no seal. Verified to work and to leave the artifact all but identical (60 MB vs 62 MB, 3998 vs 4029 files). Rejected at first on exposure, not on merit:pnpm deploycalls itself experimental and jr2 already pinned--legacy, so a third deviation that changes thenode_moduleslayout bought nothing the seal did not. Adopted with ADR-0069: the Assembly must be flat so the Kit closure strips as top-level entries and no pnpm store name reaches the archive, so both bundle paths install hoisted now. The seal keeps its deletions; it rewrites nothing. - A
.dockerignorederived from the exclude set. Rejected: it deletes the shims, and pnpm's shimsexecthrough a relative$basedir— they WORK in the container. An Instance whose dependency ships a CLI would lose it. The four varying files are content the image should keep, holding a path that is simply wrong. - Freezing the staging path (a stable scratch dir instead of
mkdtemp). Rejected twice over: it freezes the wrong path rather than fixing it, and it makes the bundle a function ofTMPDIRand the instance's location — the same inputs would yield different bytes in CI than on a laptop, which is the property this decision exists to establish. It also collides when one Instance folder is converged concurrently. pnpm deployinside the Dockerfile, so the bundle is materialized at/instanceand no host path can leak. Rejected for now: it needs the whole monorepo plus the pnpm store in the build context (or network during the build for an installed Instance), and it moves the hash off the materialized bundle — the ground ADR-0019 chose deliberately, because hashing guessed inputs instead is howjr2 upcame to skip builds it needed.- Opt-in
--prune-images. Rejected:downis already the destructive, always-confirms command, and abandoned images are discovered at 100% disk rather than at the moment one would think to pass a flag.
Consequences
- The
@kindtier is rewritten, not renamed.deploy/harness-dev/dies; the scripted persona's behavior moves from MCP client calls to OpenAI-formattool_callson a fake provider (in-cluster, or host-side with a LANbaseUrl— the config already notes a LAN address works on kind). The tier gains real-Harness coverage and becomes a second pi canary beside the conformance suite (ADR-0027), so a pi bump can now break it too. stub-harness.tskeeps its job — the socket-free tier reaches it by explicitendpoint(ADR-0031). Only the containerized stub is retired.jr2 upin a kit checkout now needs docker for kit images, including a Go build for the operator. Hash-skip means that is a first-converge cost, not a per-converge one.- An air-gapped or mirror-only cluster still cannot pull published kit images. The answer is a registry prefix for kit refs, not per-image overrides — a different mechanism, deliberately deferred while nothing is published. (Resolved by ADR-0044: the prefix is
kitRegistry, re-homing refs from the canonicalghcr.io/snapwichhome, fed by a deliberate instance-less mirror.) - The seal deletes garbage at its source. Before it, every converge produced a new artifact under an unchanged address, so each one orphaned a whole Orchestrator image of the Instance — 465 MB on the
@kindinstance — which ADR-0039's sweep then collected. That was garbage produced by the builder on every run, not by iteration, and the collector was doing work that should never have existed. The same rule now keeps a no-op converge from publishing an Assembly: an unchanged tree has an unchanged hash, and the Orchestrator already holds it. jr2 upleans onpnpm deploy, which pnpm still labels experimental, at--legacyand hoisted. The seal does not deepen that exposure, but the assumption that staging is otherwise deterministic is now held by a test that stages one Instance into two directories and compares hashes, rather than by trust. A pnpm upgrade that bakes a path somewhere new fails that test instead of silently shipping two images under one tag. (Amended by ADR-0043: only a workspace-member instance leans on it. A standalone instance invokes whichever package manager wrote its lockfile, andpnpm deployis never reached.)justrecipes stop being load-bearing, and theimages:line infeatures/kind-instance/jr2.config.tsis deleted with the block.