ADR-0052 Architecture decision record
A Sandbox lands where an ordinary pod lands, and the cache follows it
ADR-0051 made a Sandbox schedulable on any node and put a Repo cache agent on every node as a DaemonSet that tolerates every taint. The grill of 2026-09-13 found the two halves disagreeing about which nodes matter: the Sandbox pod carries no tolerations and no selector, so it lands only where an ordinary pod lands, while the cache agent probes and clones on control-plane and GPU nodes no Sandbox can reach — and a present cache there scores a node the scheduler cannot use. Meanwhile ADR-0045 read a third set (every node not cordoned) to choose build platforms, and nothing in jr2.config.ts could say which nodes an Instance's Sandboxes may use. Which nodes are an Instance's Sandbox nodes, and who follows that set is one decision.
Decision
- A Sandbox node is where an ordinary pod lands. There is no jr2 node label. A node is a Sandbox node when it is not cordoned, matches the Instance's
sandbox.nodeSelector, and carries no taint the Instance'ssandbox.tolerationsdo not tolerate. With no config that is exactly the default scheduler's answer: taints already express "not for ordinary work" for every workload on the cluster, so jr2 reads them instead of asking for a second, jr2-only opt-in that every kind cluster and every fresh pool would have to perform beforejr2 upcould work. - The override is Instance-level and raw Kubernetes:
sandbox: { nodeSelector?, tolerations? }injr2.config.ts, pod-spec shapes verbatim, the stanceharness.envtakes forEnvVar. A node label or taint is a deployment fact a Machine cannot know (ADR-0050), so it lives in the file that holds deployment facts. Noaffinitykey: the operator owns the Sandbox's affinity (the soft preference toward nodes holding its Repo caches), and a user preference beside it would need a merge rule for a case no cluster has asked for. A requirement (nodeSelector) never conflicts with a preference, so the two sit side by side untouched. - One predicate, three readers. The Sandbox pod, the cache-agent DaemonSet, and
jr2 up's summary all use the samenodeSelectorandtolerations. The DaemonSet loses its blanketoperator: Existstoleration: one cache agent per Sandbox node, so a cache is only ever present where a Sandbox can land, and the affinity it feeds only ever names a usable node. The build set (ADR-0045) is the union of the nodes an ordinary pod lands on — the Orchestrator Deployment's own placement — and the Sandbox nodes, intersected with the supported set as before. - The Sandbox CR carries the placement.
Sandbox.specgainsnodeSelectorandtolerationsbesideresources,initContainers, andvolumes; the Orchestrator writes them from its baked config at provision and the operator copies them onto the pod, merging nothing. The operator stays config-blind (ADR-0001), and every Instance's placement is its own: the CR is namespaced, the DaemonSet is per Instance with a per-namespace hostPath, and the shared operator holds no placement state. Version skew is ADR-0019's contract: an older operator would place a pod without the new fields, and thejr2 upthat introduces them is the one that upgrades the operator. - An empty set warns; it never refuses.
jr2 upprints the Sandbox nodes it sees, and when there are none names each node with the taint or selector that excluded it and thesandbox.*line that would admit it — then converges. The set moves: a pool autoscales from zero, a node is cordoned mid-converge, a GPU box joins tomorrow. A refusal would turnjr2 upinto a point-in-time check of a moving set. The run-time truth is the operator's: aPendingSandbox reports the scheduler's ownFailedSchedulingmessage through its condition, sojr2 statusshows the taint or selector while the run waits (ADR-0048's pattern). - A Machine says nothing about placement — for now. A packaged
workspace()that needs a GPU cannot write the consumer's label (the encapsulation gap ADR-0049/0051 closed for Agents and Repos). The extension, when a Machine needs it, is a named class:workspace(body, { node: "gpu" })andsandbox: { classes: { gpu: { nodeSelector, tolerations } } }, refused byjr2 up's walk when unmapped — the Repo Slot pattern.sandbox.nodeSelectorandsandbox.tolerationsare that shape's default class, so the extension addsclassesbeside them and renames nothing; and because placement rides the CR, a per-Machine class needs no new plumbing. A class is placement only: how big a Sandbox is belongs to the Machine, not the cluster, and rides theworkspace()as its Size (ADR-0060).sandbox.resourcessits besidenodeSelectorandtolerationsas the Instance's default Size, for a Workspace that states none.
Considered options
- A jr2 label (
jr2.dev/sandbox=trueon nodes that opt in). Rejected: every cluster pays a labeling step before the firstjr2 up, a single-node kind cluster included, and a labeled node is a candidate for every Sandbox — a GPU node labeled for one Machine hosts all of them. Taints already say what the label would, for every workload at once. - A jr2 vocabulary (
sandbox.nodes: ["node-4"], orpool: "workers"). Rejected: simpler to type and wrong on the first autoscaled pool, where node names are not stable and "pool" is a cloud provider's word with a different label on each. - The operator reads placement from a per-Instance object (a ConfigMap, or the DaemonSet's own template) instead of the CR. Rejected: a second read per reconcile and a cross-object dependency the CR does not declare, for no gain — the CR already carries everything else the pod needs.
- Refuse
jr2 upon an empty set, the ADR-0051 stance for an unbound slot. Rejected: a slot is a static fact of the code; the node set is a fact of the cluster right now. - Keep the DaemonSet on every node. Rejected: a cache on a node no Sandbox reaches is a clone nobody reads, a probe that fails on a control-plane node's egress rules, and an affinity term the scheduler cannot honor.
Consequences
- ADR-0045 is rewritten in place: "schedulable nodes" is the union above, and the
platformsescape hatch's "polluted set" case narrows to an untainted pool of a foreign arch — a tainted GPU pool is no longer counted. - ADR-0051 is rewritten in place: the cache agent is a DaemonSet over the Sandbox nodes, not every node.
- The DaemonSet controller auto-tolerates
node.kubernetes.io/unschedulable, so a cache agent also sits on a cordoned Sandbox node. Harmless: the scheduler ignores the soft affinity toward a node it cannot use. - A Sandbox node of a NEW architecture that joins after a converge meets a Sandbox Image the cluster never built for it and fails with
exec format erroruntil the nextjr2 up— as any node did before this ADR;platformsis the answer for a pool that autoscales from zero. - The Orchestrator and operator Deployments keep their own placement;
sandbox.*names Sandboxes only. - A
NoExecutetaint an Instance tolerates on the Sandbox pod is tolerated verbatim: jr2 adds notolerationSeconds, so a Sandbox stays through the taint for as long as its Lease is renewed (ADR-0021).