Skip to content

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(), beside image and user, keyed by the Machine's own word for it:

    ts
    export 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 (the open sentinel — someone downstream binds it), or per-run (a mapper over the wrapper's door: feature: ({ input }) => ({ url: input.feature.repo, ref: input.feature.baseRef })). ref is 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.repos keeps 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 a backend and a frontend slot 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: a workdir handle, 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's task — knows nothing a slot name could say, and a target: open on 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 with customize, in the order the Machine documents. The body's handles are typed Workspaced<…, 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, and jr2 up refuses it by the same route, with <slot> left in the line for the composer to fill. Rejected: customize adding 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 named target could say nothing about a reference it 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, and customize is where bindings go.

  • A Workspace with no Repo Slot leaves repos out. 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. So workspace(body, { input }) with no repos is a Workspace with no Repo Slot: the attach is skipped (there is nothing to clone, and the pod's fsGroup already makes /work the work group's), the body's handles are { repos: {} } with no branch, typed Workspaced<…, never>, and a Turn that frames no cwd and a command both work in /work. The spec has nothing to cut a branch in: spec is optional, gives only workGroup, and a branch or a reviewSha in 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 asks customize for 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, by jr2 up and at invoke, with one reason: a push publishes a slot's branch, and a command with an Effect secret runs against one commit of it (ADR-0067). A file-body http reads /work and stays. /work is an emptyDir, 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 hears workspace.lost as 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 default cwd, 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 through actors like 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 through pool()'s worker and workspace()'s body as the image is). A registered Machine with an open slot nobody bound is refused by jr2 up's walk, before it builds anything, naming the Machine, the slot, and the customize line that fixes it. That is the one check that is converge-time and not compile-time: a workflows/ export has no type to hang it on (ADR-0050).

  • The url is the identity. There is no repo name. jr2.config.ts loses repos; Register, RepoName, repoNames(), and the const on defineConfig retire. 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 own z.enum of urls.

  • The cluster keeps a bare cache per node, not a volume. Each repository is a Repo custom 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 reports Ready only 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 --shared off 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 Repo CRs; 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 in jr2 status right 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 and secretRef at every deploy; an attach restates the secretRef of a CR nothing binds, since no boot will — so a git.credentials entry 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.ts holds git.credentials, a list of { match, token?, sshKey? }: match is a prefix on the identity (github.com/ourorg/, or *), token names an env var whose value jr2 up writes 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 in git-clone.json in jr2-held, names only; sshKey names a Secret holding a deploy key; the url's scheme picks which field applies, and the longest match wins. When the Orchestrator creates a Repo CR it resolves the entry and writes a secretRef, the name of a Secret jr2 up wrote or the user made — a token entry whose clone Secret git-clone.json does 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 init scaffolds 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: a push() 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 a push() 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 a push({ 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 with private.asc and no passphrase. name and email are 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 unsigned push({ squash: true }) is committed by, at the Effector's clock (ADR-0067): a squash is a new commit, and without sign no signer names its committer; jr2 up refuses an unsigned squash whose entry has neither sign nor committer.

  • The pod's maker places the push credential, by reference, when the url is known. When a registered Machine pushes, jr2 up writes 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 .env in 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 as sign.identity or sign.private.asc; and entry.json, what the entry is (its match, what it pushes with, the committer it signs as). An entry with no push field 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, and jr2 up says why. It writes git-push.json in jr2-held beside 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 or customize()-bound, one path — against the fence (longest prefix, the attach match), and the Sandbox CR carries the matched entry's Secret name (gitCredentials, the secretRef shape); 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 up for 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, its match the 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) or git://; an http:// 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 no push field, since a push field would replace that credential, the push field when it holds none, and the other scheme's url when its push field already names the other credential, since a push field names one; and sign: true on an entry with no sign field (the field to add). jr2 up reads 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 at jr2 up is 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 and jr2 up to run again. The provision reads the same entry as jr2 up placed 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 origin asks 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 for jr2 status (ADR-0048's pattern).

  • Eviction is reachability plus age. jr2 gc deletes a Repo CR 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 GitRepository serving 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 up and to prewarm, and customize would have nothing to bind.

Consequences ​

  • The operator becomes git-aware, which ADR-0001 kept it from being: the Repo CRD 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-Ready step (ADR-0004).
  • workspace()'s repos names at least one slot, or is open whole — in which case the customize that 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 what jr2 up DEPLOYS 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 first workspace() 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 customize of 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.

The docs of Kit version 0.5.1. Each page is also at its .md URL.