ADR-0043 Architecture decision record
The kit is tested as installed: a local registry stands in for npm
jr2's product shape (ADR-0009) is a globally installed CLI and a standalone Instance folder that just works — scaffold anywhere (/tmp included), install with whatever package manager the user already has, jr2 up. None of that path existed: the scaffold wrote workspace:* deps only the kit's own pnpm workspace can resolve, the bundle step was pnpm deploy (workspace members only), every package was private: true, and installed mode's kit image refs name published tags that exist nowhere. Every test tier ran checkout mode, so the mode users will actually run was the one mode nothing exercised. This ADR fixes the loop that closes that gap — and the loop is permanent, not a pre-1.0 crutch: "test 1.3.0 before it exists on npm" is the same problem forever.
The rule: fake the registry, never the mechanism
The loop feeds the real machinery a local upstream — the same philosophy as ADR-0038's scripted model provider (only the LLM is faked; here, only the registries are local). Concretely: an ephemeral verdaccio npm registry per loop run; pnpm -r publish --registry into it; npm i -g @jr2/cli --registry into a temp npm prefix; jr2 init in a temp dir outside any checkout and any git repo; the user's own package manager installs; jr2 up converges in installed mode.
npm link was the tempting shortcut and is rejected for cause: a linked package is a symlink into the checkout, Node resolves modules by real path, so detectKitCheckout() finds the checkout and the CLI runs checkout mode — the branch under test never executes. Link also skips npm pack entirely (no files: check, no workspace:* rewrite), which is exactly the works-linked-broken-published failure class a release loop exists to catch. Tarball installs fail differently: npm cannot resolve inter-package deps (@jr2/cli → @jr2/orchestrator) to tarballs. A registry is the only stand-in that exercises the real chain.
An ephemeral registry deletes version bookkeeping. Fresh verdaccio per run means 0.0.0 publishes cleanly every time — no -dev.N stamping, no manifest mutation before publish (a loop that edits manifests tests edited manifests).
What publishes, and the guard
@jr2/cli, @jr2/orchestrator, @jr2/agent-protocol flip to private: false — the prod-resolution chain an instance pulls. @jr2/harness stays private: it reaches users as a Kit image, never via npm install (ADR-0027, ADR-0037). This amends ADR-0009's "all packages/* publish to npm" — that line wrote down a mechanism, not the intent.
Losing private: true loses the accidental-publish guard, so the guard moves into publishConfig:
"publishConfig": { "registry": "http://localhost:4873" }The manifest names the only registry it may publish to. publishConfig outranks CLI and env registry settings at publish time, so a stray --registry cannot leak past it, and a publish with no verdaccio up fails connection-refused. Publishing to npmjs becomes a deliberate, reviewable edit — changing that line is the "we are ready" commit — never an absence-of-flag accident. (Amended by ADR-0055: the same precedence that made the line a guard made it unreleasable — the edit broke every reader of the guard at once. The line is gone; the guard is that no dev box holds an npmjs credential, and the release job publishes by trusted publishing on a pushed tag.)
In the loop, Kit images need no registry at all on kind: build from the checkout, tag with the published names (jr2-harness:<version>), kind load — the same delivery checkout mode already uses. Registry-prefixed kit refs for non-kind clusters stay deferred (ADR-0038). (Amended by ADR-0044: kind load bypassed the pull path, so the mode users run had no tier — the @dist bring-up now adds a local OCI registry, seeds it with just kit-push, and scenarios pull Kit images through kitRegistry like any self-host.)
The binary is the kit's one .js file
Running the loop found what running checkout mode forever had hidden: Node's type stripping refuses files under node_modules, and an installed kit is nothing else — jr2 --help died on its own shebang. The image bundle already knew (its entrypoint runs through tsx, ADR-0038); the host-side binary had nowhere to learn it.
So bin/jr2.js replaces bin/jr2.ts: it registers a module.registerHooks load hook that erases types with ts-blank-space — the same erasure ADR-0034 already makes for the Console — and then imports ../src/cli.ts. What ts-blank-space cannot erase is what Node's own stripping rejects too, so this widens where the kit runs, never what it may be written in, and the repo stays zero-build. One entry for both worlds, for the same reason jr2 init grows no mode branch: a checkout-only .ts bin beside an installed-only .js bin would mean the binary users run is not the binary the tiers run.
The loop's own state — registry storage, npmrc, throwaway global prefix — therefore lives outside the checkout. A prefix under the kit root is npm link's rejected failure in another costume: detectKitCheckout() walks up from the CLI's real path, finds the kit above the prefix, and converges in checkout mode while reporting success. The publish script refuses a JR2_DIST_DIR inside the checkout, and the @dist converge asserts the mode line it expects.
The scaffold pins the kit that scaffolded it
jr2 init renders "@jr2/orchestrator" and "@jr2/cli" at the exact running KIT_VERSION — exact, not caret, because 0.x minors break (the same reasoning that pinned pi exact, ADR-0027). One template serves both worlds; init grows no checkout/installed branch, because a branch means the tested output and the shipped output diverge. templates/default carries the same literal, so the byte-for-byte mirror test survives and becomes the version-bump tripwire. The checkout resolves the exact version to its own packages via linkWorkspacePackages: true; kit inter-deps stay workspace:* (publish rewrites them at pack). The scaffold names no package manager — no packageManager field, a PM-neutral install hint — and instances upgrade the kit by editing two dep lines, which pre-1.0 is a feature. (Amended by ADR-0056: the two lines must name one number — a global jr2 hands off to the Instance's own @jr2/cli, and a CLI whose orchestrator is not the Instance's refuses.)
The bundle installs from the lockfile, with the lockfile's own package manager
The instance-image bundle (ADR-0038) is keyed off the instance's own shape — and not off the mode jr2 up detects for its Kit image refs. Asked in order: does the instance carry a lockfile of its own; if not, is there a pnpm-workspace.yaml at or above it. The instance's shape, not the CLI's provenance, is what says whether pnpm deploy can run at all, and the two keys disagree in both directions: a checkout CLI legitimately drives a standalone instance (a developer's /tmp folder), and an installed kit legitimately drives an instance nested in the user's own pnpm monorepo — which carries no lockfile of its own, because the workspace root holds it.
Amended: the key was first written as the workspace file alone — "is there a pnpm-workspace.yaml above me" — which is a fact about an ancestor, not the instance. A standalone instance committed inside a checkout whose pnpm-workspace.yaml does not list it went to pnpm deploy, which answers a --filter naming no member with "No projects matched the filters" and exit 0: no bundle, and the failure surfaced as an ENOENT from the seal. The lockfile is asked first because a member never has one and a standalone instance always does; and the deploy branch now checks the bundle exists, naming both ways out (join the workspace, or commit a lockfile) when it does not.
- Workspace member:
pnpm deploy --legacy, hoisted (node-linker=hoisted) like the standalone path, so the Assembly of ADR-0069 is one flat tree whichever path staged it. Its job — materializing workspace symlinks — only exists in a workspace. pnpm is a contributor prerequisite (like go for the operator), and in the one installed-mode shape that reaches this branch it is a prerequisite the user already met: they wrote thepnpm-workspace.yamlthat selects it. - Standalone: stage a copy of the instance (minus
node_modules/,.jr2/,.git/, and the credential files.env*/.npmrc—jr2 upreads.envhost-side into the Secrets it writes for the pods that spend each value, ADR-0019 and ADR-0068, so a copy would bake a key into an image layer and into the tag addressing it) and run a frozen install from the lockfile, dispatched on which lockfile is present:package-lock.json→npm ci --omit=dev;pnpm-lock.yaml→pnpm install --prod --frozen-lockfile(withnode-linker=hoisted, so the bundle is flat real files regardless of PM);bun.lock[b]→bun install --production --frozen-lockfile. A private registry reaches that install through the environment (npm_config_registry) or the user-level npmrc, never through image content.
The lockfile — not the user's node_modules/ — is the input, and that is forced, not stylistic: the GitOps/CI path (ADR-0008's instance repo, the planned jr2 build) runs from a clean checkout where no node_modules exists, a copied tree bakes in accidents rather than declarations, and prod-pruning a copied tree means reimplementing resolution. Lockfile-as-input is also ADR-0019's derivability rule applied to dependencies: the deployed bundle is a function of what up can see committed. The lockfile is therefore part of the instance contract; no lockfile is a hard error naming the supported three, two lockfiles is an ambiguity error.
The package-manager "dependency matrix" collapses to nothing: lockfiles are proprietary formats, so supporting any PM mechanically means invoking the binary that speaks the lockfile present — and that binary's presence is guaranteed by the very thing that selects it (the user wrote the lockfile with it). jr2 itself depends on no package manager.
v1 ships npm, pnpm, and bun. Yarn is deliberately out: one filename hides two incompatible generations (classic --frozen-lockfile vs berry --immutable), and berry defaults to PnP — no node_modules at all, which the image's resolution model cannot host. A yarn.lock gets a named rejection ("use npm, pnpm, or bun"), and adding it later is one dispatch row plus its tests, no design change.
The loop's two faces
@dist: a new opt-in e2e tag beside@kind(docker + kind + verdaccio), whose Rule is literally ADR-0009's claim: a globally installed CLI converges a standalone instance. Publish happens once per suite run; scenarios isolate by namespace; scenarios cover npm and pnpm instances (bun rides unit coverage on the dispatch until the tier needs it), scaffolded in temp dirs with no git, converged, blown away.- The manual loop: a
justrecipe leaves verdaccio and the temp global install standing, so a developer plays the way a user lives —jr2 init /tmp/anything && npm install && jr2 up, entirely outside the repo.
Consequences
- ADR-0009's consequences: the instance-facing packages publish to npm; the Harness ships in a Kit image.
- No
@distscenario deploys a Custodian.pingruns on no Harness pod, so the tier checks that the image map re-homes the Custodian's Pinned image ontokitRegistry(ADR-0059) and pulls nothing. The fixture seeds the registry withjust kit-push, which builds Kit images only; a scenario that ran a Harness pod would need the Envoy image mirrored there too (jr2 kit pushdoes it). - Scaffolded instances pin exact and bump manually; a
jr2 upgradeverb can exist later if that ever hurts. - The checkout's
pnpm-workspace.yamlgainslinkWorkspacePackages: true;templates/defaulttradesworkspace:*for the exact version literal. - The npmjs
@jr2scope must be claimed early regardless of readiness — the localhost guard protects against accidents, not squatters. jr2 upstill shells out to pnpm in one installed-mode shape: an instance nested in the user's own pnpm workspace. That is not the product dependency creeping back, because the thing that selects pnpm is a file the user wrote with pnpm — the same self-selecting argument the lockfile dispatch already rests on.- The published packages carry
files:allowlists, and the over-include half is a unit assertion (packages/cli/test/publish.test.ts). Afiles:list fails loudly only when it OMITS something, so the@disttier is structurally incapable of catching a package that ships its test tree — and these are prod deps, unpacked into every Assembly. - The staged bundle drops
node_modules/.pnpm-workspace-state.jsonbeside.modules.yaml(ADR-0038's seal): it is alastValidatedTimestampand nothing else, so leaving it in re-addressed every pnpm bundle on every converge — a pod-template change, and a snapshot restore for every live run, on a no-opup. - Everything the loop needs in-tree (public flags, localhost
publishConfig, exact-pin scaffold, lockfile dispatch) is the real shipping configuration. Nothing anywhere special-cases "testing" — which is the point.