ADR-0051 Architecture decision record
A Repo is a slot on the workspace() and a cache on the node
ADR-0049 moved Agents and the Sandbox Image into the Machine and left one dependency outside it: the Repo, catalogued in jr2.config.ts and named by string from a workspace() spec, with the name typed back through a Register interface (ADR-0050). The grill of 2026-09-13 found two faults. A packaged Machine still could not say which repository it works on, nor could a consumer bind one, except by the consumer's config holding the right NAME and the door threading it — the same encapsulation gap ADR-0049 closed for Agents. And the catalog's shape was dictated by storage: one read-only volume of default/ checkouts, which on a multi-node cluster pins every Sandbox to the node holding it. The two are one decision: what a Machine says about a repository, and what the cluster does with that.
Decision
A Repo is a slot on the
workspace(), besideimageanduser, keyed by the Machine's own word for it:tsexport const codeReview = workspace(body, { input, image: import.meta.resolve("./image"), repos: { target: open, // the consumer's — must be bound docs: { url: "https://github.com/acme/handbook.git", ref: "v3" }, // the package's own }, spec: ({ input }) => ({ branch: input.branch }), });A slot is in one of three states, and the walk tells them apart without evaluating anything: bound (a url, or
{ url, ref? }), open (theopensentinel — someone downstream binds it), or per-run (a mapper over the wrapper's door:feature: ({ input }) => ({ url: input.feature.repo, ref: input.feature.baseRef })).refis the base the branch worktree is cut from; absent, the repository's own default branch. The spec no longer carries repos — it is{ branch, workGroup?, reviewSha? }, or{ workGroup? }for a Workspace with no slot (below). The body's handles are keyed by slot (workspace.repos.target), and so are the in-pod paths (/work/<slot>/<branch>): a slot key is collision-free by construction and the same in every Instance that consumes the package, so a prompt can name a path and be right everywhere.The handles are the slot map and the branch — no slot is the
workdir.workspace.reposkeeps the slots in declaration order, and jr2 reads nothing into that order. Which checkout an Agent works in is a fact about that Agent's Turn, not about the Workspace: a body with abackendand afrontendslot frames one Agent with the first path and another with the second, and either may read the other's tree. A body that names no slot may give the order a meaning of its own and document it (task: the first slot is the one the coder edits). The order is the kit's promise — a slot key starts with a letter, because JS reads an integer-like key first wherever it was written — and the meaning is the Machine's. Rejected: aworkdirhandle, the first declared slot's worktree — it made a singular "primary repo" a property of every Workspace, which is wrong for a body whose Agents work in different slots or in more than one, and it hid a rule in key order that the body never chose.A Machine whose body names no slot declares the whole map Open:
repos: open. A body that enumerates whatever is attached —@jr2/machines'stask— knows nothing a slot name could say, and atarget: openon it would claim "one repository" where the body claims nothing. So the map is the Open part, one level up from a slot: the composer names every slot withcustomize, in the order the Machine documents. The body's handles are typedWorkspaced<…, string>, which is its statement that it names no slot;workspace()refuses a body that names one under an Open map, since the composer may never write that word. The walk reports an Open map as one Open part with no slot, andjr2 uprefuses it by the same route, with<slot>left in the line for the composer to fill. Rejected:customizeadding keys to a declared map — a slot a Machine never declared is a compile error on purpose (a typo cannot mint a clone), and a body that namedtargetcould say nothing about areferenceit never knew of; the map-level Open says what the body actually knows. Rejected: a second Machine of the consumer's own, wrapping the exported body with more slots — composition is the right act for a different BODY, but the same body with more checkouts beside it is a binding, andcustomizeis where bindings go.A Workspace with no Repo Slot leaves
reposout. A Workspace is a Sandbox bound to one unit of work, and a repository is something it may attach, not what makes it one. Some work has no repository: fetch data, write a report, have a second Agent edit it, upload it. Its steps share/work, as the steps of one GitHub Actions job share that job's folder; two Workspaces are two jobs and share nothing. Soworkspace(body, { input })with noreposis a Workspace with no Repo Slot: the attach is skipped (there is nothing to clone, and the pod'sfsGroupalready makes/workthe work group's), the body's handles are{ repos: {} }with no branch, typedWorkspaced<…, never>, and a Turn that frames nocwdand acommandboth work in/work. The spec has nothing to cut a branch in:specis optional, gives onlyworkGroup, and abranchor areviewShain it is refused — at the type, and when the run enters, since a value jr2 silently ignores is the failure the refusals exist for.repos: {}is refused with the pointer to leaving it out, so "none" has one spelling and an empty map never reads as one somebody forgot to fill. An Open map still askscustomizefor at least one slot: Open says the composer names the slots, and a body that enumerates its checkouts (task) has nothing to do with none. Two Effects need a slot and are refused there, byjr2 upand at invoke, with one reason: apushpublishes a slot's branch, and acommandwith an Effect secret runs against one commit of it (ADR-0067). A file-bodyhttpreads/workand stays./workis anemptyDir, so a Lost Sandbox takes everything with it — with a repository, pushed commits outlive the pod; here only what an Effect sent out does, and the body hearsworkspace.lostas any body does. Storage that outlives the pod, the job's "artifacts", is a decision of its own. Rejected: a placeholder repository bound to satisfy the rule — a clone nobody uses, a branch that means nothing, a Worktree that becomes every Agent's defaultcwd, and a cache entry to warm. Rejected: a new term for the repository-less Sandbox — the Glossary's Workspace never named a repository, so none was needed.The consumer binds with
customize, in the same call as Agents and the image, nested throughactorslike every other part:customize(codeReview, { repos: { target: "git@github.com:ourorg/app.git" } }). Any of the three forms is accepted, so a consumer can also bind a mapper over the child's door. A slot the Machine does not declare is a compile error (a phantom on the wrapper type, read throughpool()'sworkerandworkspace()'sbodyas the image is). A registered Machine with an open slot nobody bound is refused byjr2 up's walk, before it builds anything, naming the Machine, the slot, and thecustomizeline that fixes it. That is the one check that is converge-time and not compile-time: aworkflows/export has no type to hang it on (ADR-0050).The url is the identity. There is no repo name.
jr2.config.tslosesrepos;Register,RepoName,repoNames(), and theconstondefineConfigretire. ADR-0050's rule holds vacuously: the string a Machine writes is the thing, not a reference into someone else's file. Two spellings of one repository (https://,git@…:,ssh://, with or without.git) resolve to one identity — host plus path, scheme and user dropped — and therefore one cache. A door that offers a menu of repositories is the Instance's ownz.enumof urls.The cluster keeps a bare cache per node, not a volume. Each repository is a
Repocustom resource the operator reconciles (spec: url, refresh interval; status: per node — synced, generation, last error). A cache agent runs as a DaemonSet per Instance over its Sandbox nodes (ADR-0052) with a hostPath directory: it clones a repository onto its node the first time a Sandbox there needs it, fetches in place on the interval and on demand before an attach, and pins gc exactly as ADR-0004 requires. The Sandbox CR names the repositories it needs by identity; the operator mounts each node cache read-only, sets a soft node affinity toward nodes whose status holds them, and reportsReadyonly once every one is present and has been fetched since the CR asked. The agent works per Repo, four at once on a node (a kit value; a fetch is network and CPU beside the Sandboxes it serves), so one Repo's cold clone never holds another's on-demand fetch; every cache speaks git protocol v2, so a fetch advertises only the refs it asks for; and the refresh interval carries ±20% jitter, like the Lease, so R Repos on N nodes do not hit a remote on one beat (scaling review R13, 2026-09-30). The agent also consolidates a cache's packs without deleting an object (ADR-0004). The Orchestrator's attach then runs unchanged:git clone --sharedoff the mount, worktree beside it. A Sandbox that lands on a cold node pays one clone there, once — the same economics as an image pull, and the reason a Sandbox can run on any node.The Orchestrator creates
RepoCRs; it does not sync them. At boot it walks its registered Machines and creates one CR per bound identity, so a statically known repository is KNOWN before a run can ask: the cache agent on every node probes it (git ls-remote) as soon as the CR exists, so a wrong url or a credential that does not reach shows injr2 statusright after converge, not at the first run. Known is not warm: the clone happens on a node the first time a Sandbox there needs the repository, never on every node at boot — cloning a large repository onto every node that may never run a Sandbox for it is the cost the image-pull economics above exist to avoid. A per-run url creates its CR at first attach, and every later attach anywhere finds it. The boot restates a bound CR's url andsecretRefat every deploy; an attach restates thesecretRefof a CR nothing binds, since no boot will — so agit.credentialsentry fixed after a failed clone reaches the cache at the next run either way. Only an attach moves the eviction clock: a boot is not an attach.Credentials are matched by prefix and carried by the CR, the Argo and Flux shape.
jr2.config.tsholdsgit.credentials, a list of{ match, token?, sshKey? }:matchis a prefix on the identity (github.com/ourorg/, or*),tokennames an env var whose valuejr2 upwrites into that entry's own clone Secret,jr2-git-<hash of match>(labelled, and deleted when the entry no longer has one; an unset variable writes none), listing the Secrets it wrote ingit-clone.jsoninjr2-held, names only;sshKeynames a Secret holding a deploy key; the url's scheme picks which field applies, and the longest match wins. When the Orchestrator creates aRepoCR it resolves the entry and writes asecretRef, the name of a Secretjr2 upwrote or the user made — a token entry whose clone Secretgit-clone.jsondoes not list gets none, and the clone is anonymous; the cache agent reads only that Secret, and the operator matches nothing. The Orchestrator writes and reads no git credential (ADR-0068). The Secret uses Flux's key names (username/password;identity,identity.pub,known_hosts), so a Flux or Argo user reuses the Secret they have. The list is also the fence. A per-run url — a run input, a ticket field — is otherwise a way to spend the cluster's credential against any host: one that matches no entry is refused at attach, naming the list. A static binding is code the Instance typechecked and deployed, so it is admitted without a match and cloned anonymously; a private one then fails at the cache with git's error.jr2 initscaffolds one wildcard entry —{ match: "*", token: "JR2_GIT_TOKEN", sshKey: "jr2-git-ssh" }, today's two implicit defaults made visible, with a comment saying to narrow it before anything untrusted can start a run, and that it also pushes: apush()whose Repo Slot url it matches, a per-run url included, spends the same token or key — so the open fence is in the user's file and commit, never implied.An entry may carry a push credential and a signing key (ADR-0067), checked by shape when the config loads:
push?: { sshKey } | { token }, the credential apush()to a Binding under the entry spends — absent, the clone credential pushes, and an entry with no credential admits a clone, never a push; a token goes Basic over https through a credential helper only, which answers for the Binding's own host alone, and the push follows no redirect, so the token never reaches an origin outside the entry.sign?: { sshKey, name, email } | { gpgKey, name, email }, the key apush({ sign: true })signs with and the committer it signs as — explicit, so an auth key is never reused as a signing key; an ssh key is the default format, a GPG key is a Secret withprivate.ascand no passphrase.nameandemailare required: the Effector re-creates the commits, and a forge verifies a signature only when the committer email belongs to the account that holds the key. The author line stays the Agent's.committer?: { name, email }, who an unsignedpush({ squash: true })is committed by, at the Effector's clock (ADR-0067): a squash is a new commit, and withoutsignno signer names its committer;jr2 uprefuses an unsigned squash whose entry has neithersignnorcommitter.The pod's maker places the push credential, by reference, when the url is known. When a registered Machine pushes,
jr2 upwrites each entry's push and sign credentials into that entry's own Secret,jr2-git-push-<hash of match>(labelled, and deleted when the entry no longer has one), never per Workspace: the token from.envin Flux's key names (username,password); the keys of the Secret its ssh key names, copied as data and never decoded (identity,identity.pub,known_hosts); the signing key assign.identityorsign.private.asc; andentry.json, what the entry is (its match, what it pushes with, the committer it signs as). An entry with nopushfield pushes with its clone credentials, each over its own scheme, so the scaffold's wildcard holds both. An entry with nothing it can place — its token unset, its key's Secret absent — gets no Secret, andjr2 upsays why. It writesgit-push.jsoninjr2-heldbeside them: which entries have a Secret and what each can spend, names only. At provision the Orchestrator matches each pushed slot's url — static, per-run orcustomize()-bound, one path — against the fence (longest prefix, the attach match), and the Sandbox CR carries the matched entry's Secret name (gitCredentials, thesecretRefshape); the operator projects it into the Sandbox Effector alone (ADR-0005). The Orchestrator reads no value. The Effector holds only the entries named there and matches a push's url as the fence does, longest match first, and refuses a url outside the entry.A push is refused with a fix that works, at
jr2 upfor a static Binding and at provision for any url. One rule (pushRefusal), and the fix each refusal names is one the same rule then accepts: a url no entry matches (the entry to add, itsmatchthe url's host and directories, so always a prefix of the url); a local path (git's local transport runs that repository's config and hooks) orgit://; anhttp://url — a push credential goes over https or ssh only: a token over http is the token in the clear, and no ssh key is spent over http — told the url its entry can push to (https for a token, ssh for an ssh key) or, when no entry or no credential is there, the https url and the token to add; an entry that holds no credential the url's scheme can spend (a token over https, an ssh key over ssh): the clone field to add when it holds a clone credential and nopushfield, since apushfield would replace that credential, thepushfield when it holds none, and the other scheme's url when itspushfield already names the other credential, since apushfield names one; andsign: trueon an entry with nosignfield (the field to add).jr2 upreads a static Binding's entry as written, then refuses an https Binding whose entry's token variable is unset. A key's Secret, or a signing key's, that is absent atjr2 upis a warning, not a refusal: it may be written after (ADR-0047's prompt, a human); the entry's Secret is written without that key, and the provision refuses the push that needs it, naming the Secret to create andjr2 upto run again. The provision reads the same entry asjr2 upplaced it, so a per-run url is refused at attach for the same reasons, as a url outside the fence is, before anything is written. Accepted costs:jr2 up's report is exact for a static Binding and says "the entry its url matches" for a per-run slot; a wildcard entry gives a per-run push the catch-all credential, the fence's existing choice.Freshness degrades, absence does not. A fetch that fails on a warm cache lets the attach proceed on the objects it has, announced as stale with git's own error. A stale attach is stale until the next fetch anyone inside the pod runs: the worktree's
originasks the cache, and the cache asks the remote (ADR-0053) — no container the Agent executes in holds a credential (ADR-0005), and the same failure lets that fetch fall through to the cache's objects with a warning on stderr. A clone that fails on a cold node fails that provision pointedly, naming the repository and the error, and the CR status carries it forjr2 status(ADR-0048's pattern).Eviction is reachability plus age.
jr2 gcdeletes aRepoCR that no Machine of a held Assembly binds (the current one's, and every one a live run holds, ADR-0069) and no run has attached within its TTL; the cache agent removes the node copy on CR deletion, once no pod on that node mounts it.
Considered options
- Image volumes (each repository generation packed as an OCI artifact, mounted by kubelet): gives per-node cache, scheduling, and GC for free, and immutability makes the gc invariant structural. Rejected: a refresh is a new layer per fetch, so packs accumulate until a repack, and a repack is a full re-upload and a full re-pull on every node — the re-clone cost a mutable bare repo never pays. Immutability bought only what gc pinning already buys.
- A per-repo PersistentVolume. Rejected: an RWO volume still binds to one node at a time; it changes nothing about pinning, only the number of volumes.
- Keep the shared PVC, require RWX storage for multi-node. Rejected: scales the storage, not the fetch, and makes the kit's first cluster requirement a storage class most small clusters lack.
- A Flux-style
GitRepositoryserving an artifact over HTTP. Rejected: an artifact download is a clone per Sandbox — the cost the cache exists to avoid. - Urls kept in the spec, not the slot (the shape before this decision, with a url in place of a name). Rejected: the walk cannot see inside the spec, so a bound repository would be invisible to
jr2 upand to prewarm, andcustomizewould have nothing to bind.
Consequences
- The operator becomes git-aware, which ADR-0001 kept it from being: the
RepoCRD and the cache agent are its. The Sandbox CRD stays free of git semantics beyond a list of identities; clone and worktree remain the Orchestrator's post-Readystep (ADR-0004). workspace()'sreposnames at least one slot, or isopenwhole — in which case thecustomizethat names the map is held to the same rule — or is left out, for a Workspace with no Repo Slot; a Machine that composes no Sandbox has no repos to customize, and the data-plane switch ("does this Instance need Sandboxes") is "a registered Machine composes a Sandbox", read off the walk, not "config has repos". That switch decides whatjr2 upDEPLOYS for Sandboxes, the cache agent and the Sandbox Images; a deployed Orchestrator wires its kube client whatever its current Assembly composes, because a publish can bring the firstworkspace()without a roll (ADR-0069).- Every slot gets a branch worktree cut at the run's branch, a reference checkout included. A slot has no read-only mode; that is a decision of its own if it is ever wanted.
- The Console's start form loses the catalog dropdown: a per-run repository is a url field unless the Machine's door enumerates its own.
- The volume paths change: the in-pod source mount is
/repos/<key>where<key>is derived from the identity, and the pod-local layout is/work/<slot>/{default,<branch>}. Existing checkouts on a pre-0051 PVC are not migrated. - ADR-0047's deploy-key prompt reads ssh urls off the walk. A per-run ssh url first seen at attach was never prompted for; its first attach fails with the key hint, as any unregistered key does.
- A
customizeof a per-run slot with a static url is legal and makes it bound; a Machine that wants a slot to stay the run's declares it per-run and the consumer leaves it alone. Which slots a consumer may fix is the package author's call, expressed by the slot's state.