# 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](0069-the-orchestrator-is-a-kit-image-and-an-instance-publishes-its-assembly.md)) 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](0069-the-orchestrator-is-a-kit-image-and-an-instance-publishes-its-assembly.md)).
- 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.
