Skip to content

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

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-09docs/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, where cairnet-flush never looked. Fixed at source in #228 and lint-guarded.
  • 2026-08-10docs/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.

A stale cli/dist is treated as a build failure.

  1. cli/package.json gains a prepare script that runs npm run build. npm runs prepare on npm install and on npm 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.

  2. scripts/doctor-all.sh refuses to run against a stale build. It compares the newest mtime under cli/src/ against the oldest mtime under cli/dist/ and exits 2 — the existing setup-failure code — when source is newer, naming the remedy. doctor-all.sh is 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.

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.

  • 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.ts everywhere and drop dist/ entirely. Removes the staleness class outright, since there is no artifact to go stale. Rejected for now: bin, exports, and scripts/doctor-all.sh all point at dist/, and api/rpc.ts reaches 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.
  • docs/findings/20260809-2125-emit-verbs-queued-stones-into-slug-named-directories.md
  • docs/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
  • Subagent: Claude Opus 5 (this session, 2026-08-10)
  • Human: pending
  • Human countersign — that a prepare-time rebuild and a doctor-all.sh staleness gate are the right two layers, and that the residual risk above (direct node dist/ invocation, un-installed vendored copies) is accepted rather than closed.