A stale `cli/dist` is a build failure, not an operator's problem
Date: 2026-08-10 Status: open Supersedes: none Superseded-by: none — current
Context
Section titled “Context”cli/dist/ is gitignored and hand-built. Nothing rebuilds it, and nothing warns
when it is older than cli/src/. That gap has now cost stones twice:
- 2026-08-09 —
docs/findings/20260809-2125-emit-verbs-queued-stones-into-slug-named-directories.md. Emit verbs passed a registry slug where a repo root was expected, so 43 governance stones were queued into<slug>/.petrova/under the control plane, wherecairnet-flushnever looked. Fixed at source in #228 and lint-guarded. - 2026-08-10 —
docs/findings/20260810-1110-stones-stranded-again-a-day-after-the-fix.md. Nine more stones stranded the following morning, 08:52→11:10, all queued after #228 merged at 21:36 the night before. The source was correct the whole time. What ran was a build compiled around 2026-08-06.
The second incident is the one that matters here. #228’s fix was real, tested,
and lint-guarded, and it protected nothing, because a source-level guard
cannot protect a binary compiled before it existed. #232 pushed the guard down
into cairnet/outbox.ts at the layer that actually creates the directory, which
narrows the blast radius — but that guard, too, only runs if the build contains
it. Every defence we have is downstream of a build step that no one is obliged
to run.
scripts/doctor-all.sh already invokes cli/dist/index.js directly and already
refuses to proceed when that file is missing. It cannot currently tell the
difference between a missing build and a four-day-old one, and only the first is
loud.
Decision
Section titled “Decision”A stale cli/dist is treated as a build failure.
-
cli/package.jsongains apreparescript that runsnpm run build. npm runsprepareonnpm installand onnpm link, so any fresh checkout, any dependency install, and any vendored copy of this CLI gets a build that matches its own source. This closes the case that produced both incidents: a working tree whose source moved while its build did not. -
scripts/doctor-all.shrefuses to run against a stale build. It compares the newest mtime undercli/src/against the oldest mtime undercli/dist/and exits2— the existing setup-failure code — when source is newer, naming the remedy.doctor-all.shis the fleet-wide entry point, so this is where a stale build reaches the most repos in one go.
Both layers are deliberate. prepare fixes it automatically where npm is in the
loop; the doctor check catches the case where it is not — a tree built once and
then edited, which is precisely what happened on 2026-08-06→08-10.
Mtime comparison is chosen over a content hash because it is cheap, needs no manifest, and its failure mode is a false alarm that costs one rebuild. A hash would be exact and would require storing state that itself goes stale.
Consequences
Section titled “Consequences”For the build. npm install in cli/ now compiles. tsc on this package
takes a few seconds; that cost lands on install, not on every invocation.
prepare also runs on npm link, which is how the CLI is currently distributed
(cli/package.json bin: petrova), so linked installs self-heal.
For doctor-all.sh. A stale tree now exits 2 before touching any consumer
repo instead of silently running old logic across all eight. Exit 2 already
means “script-level setup failure” there, so no caller contract changes. The
check is skipped when cli/src/ is absent, so a dist-only distribution still
runs.
For the two incidents. Neither is retroactively fixed — the 51 recovered
stones remain queued and are drained by cairnet-flush separately. What changes
is that the class stops being silent: the next time source and build diverge,
the divergence itself is the error rather than the data loss it causes three
days later.
Residual risk, stated plainly. This does not make a stale build impossible.
Invoking node cli/dist/index.js directly, or running a vendored copy that was
never npm installed, still bypasses both layers. Closing that fully means
either committing dist/ or publishing to a registry, both of which are larger
decisions than this one. What is closed is the path that actually failed twice.
Alternatives considered
Section titled “Alternatives considered”- Commit
cli/dist/. Guarantees the build matches the tree at every checkout, and makes every source PR carry an unreviewable compiled diff. Rejected on review cost; revisit if the residual risk above bites. - Run
tsx src/index.tseverywhere and dropdist/entirely. Removes the staleness class outright, since there is no artifact to go stale. Rejected for now:bin,exports, andscripts/doctor-all.shall point atdist/, andapi/rpc.tsreaches into built output. Worth its own decision doc. - A pre-commit hook that rebuilds. Does not help — the stranded stones were emitted by an operator’s CLI run, not by a commit, and hooks do not travel with a vendored copy.
- Do nothing; rely on the #232 runtime guard. That guard is itself inside the artifact that goes stale. It is a good second layer and a poor only layer.
References
Section titled “References”docs/findings/20260809-2125-emit-verbs-queued-stones-into-slug-named-directories.mddocs/findings/20260810-1110-stones-stranded-again-a-day-after-the-fix.md- #228 (source fix + lint guard), #232 (runtime guard in
cairnet/outbox.ts) scripts/doctor-all.sh,cli/package.json
Sign-off
Section titled “Sign-off”- Subagent: Claude Opus 5 (this session, 2026-08-10)
- Human: pending
- Human countersign — that a
prepare-time rebuild and adoctor-all.shstaleness gate are the right two layers, and that the residual risk above (directnode dist/invocation, un-installed vendored copies) is accepted rather than closed.