v3.5.8 - the transition that claims to be one #229

Merged
john merged 36 commits from cadence/v3.5.8 into main 2026-08-22 17:55:25 +00:00
Owner

Closes #145, #140, #139.

Four operations in this codebase wrote several files and reported the result as
if they had written one. An atomic rename protects a single file from torn bytes
and cannot make a transaction across two, so each of them could leave a
half-applied tree inside an ok:true envelope with nothing saying so.

Two phases, 36 commits off v3.5.7, three requirement ids all traced to a
verified phase (JRN-01, JRN-02, JRN-03). /cad-audit PASS on both arms,
13 of 13 acceptance criteria covered.

The shape was decided on evidence rather than on the issue text. renumber and
milestone-prune already refused whole and reported what had completed, by hand.
That is a refusal protocol, not a journal: it needs no on-disk state, no resume
path and no reader in /cad-health. Generalizing the two implementations that
already worked was the work, and the two operations that claimed atomicity
without having it were moved onto the result.

What changed

  • cadence-core/bin/lib/file-transition.mjs owns the ordered multi-file write.
    runTransition({steps, discipline, preflight}) returns
    {ok, refused, completed, failures}, with two disciplines drawn from behaviour
    that already shipped and a lazy pre-flight that returns on its first
    unsatisfied condition before any thunk runs. A HELPERS census row reddens if
    its body is copied under any name.
  • cmdPhaseDone validates every edit before the first write and names which
    documents landed. The "all-or-nothing" comment sitting above two separate
    renames is gone rather than qualified.
  • release-bump reads and decides its whole write set before the first
    atomicWrite, so a malformed sibling leaves the primary manifest at the old
    version under ok:false instead of shipping a half-bumped tree as success.
  • renumber and milestone-prune route their existing partial-state refusals
    through the primitive with partial-apply and partial-prune unmoved, pinned
    by tests that redden on a paraphrase.
  • A non-regular CHANGELOG.md refuses under unreadable-changelog instead of
    hanging on a FIFO or scaffolding a release heading over the whole history.
    Raised by the blocking risk_surface review on the phase-2 range.

Known gaps

The partial-flip and partial-bump arms ship probe-proven only, with no
committed regression test: every uid-independent way to force a write to fail
past the pre-flight was converted into a pre-write refusal by the work itself,
and D-02 forbids chmodSync. Treat those two envelopes as untested in CI.

planning.mjs's read(reqFile) still accepts any existing filesystem object, so
a FIFO at .planning/REQUIREMENTS.md hangs phase-done before its refusal can
run. Same class as the CHANGELOG.md blocker, one seam over, raised medium and
downgraded at adjudication.

The manifest is at 3.5.8; the v3.5.8 tag is cut on main after this merge.

Closes #145, #140, #139. Four operations in this codebase wrote several files and reported the result as if they had written one. An atomic rename protects a single file from torn bytes and cannot make a transaction across two, so each of them could leave a half-applied tree inside an `ok:true` envelope with nothing saying so. Two phases, 36 commits off `v3.5.7`, three requirement ids all traced to a verified phase (`JRN-01`, `JRN-02`, `JRN-03`). `/cad-audit` PASS on both arms, 13 of 13 acceptance criteria covered. The shape was decided on evidence rather than on the issue text. `renumber` and `milestone-prune` already refused whole and reported what had completed, by hand. That is a refusal protocol, not a journal: it needs no on-disk state, no resume path and no reader in `/cad-health`. Generalizing the two implementations that already worked was the work, and the two operations that claimed atomicity without having it were moved onto the result. ## What changed - `cadence-core/bin/lib/file-transition.mjs` owns the ordered multi-file write. `runTransition({steps, discipline, preflight})` returns `{ok, refused, completed, failures}`, with two disciplines drawn from behaviour that already shipped and a lazy pre-flight that returns on its first unsatisfied condition before any thunk runs. A `HELPERS` census row reddens if its body is copied under any name. - `cmdPhaseDone` validates every edit before the first write and names which documents landed. The "all-or-nothing" comment sitting above two separate renames is gone rather than qualified. - `release-bump` reads and decides its whole write set before the first `atomicWrite`, so a malformed sibling leaves the primary manifest at the old version under `ok:false` instead of shipping a half-bumped tree as success. - `renumber` and `milestone-prune` route their existing partial-state refusals through the primitive with `partial-apply` and `partial-prune` unmoved, pinned by tests that redden on a paraphrase. - A non-regular `CHANGELOG.md` refuses under `unreadable-changelog` instead of hanging on a FIFO or scaffolding a release heading over the whole history. Raised by the blocking `risk_surface` review on the phase-2 range. ## Known gaps The `partial-flip` and `partial-bump` arms ship probe-proven only, with no committed regression test: every uid-independent way to force a write to fail past the pre-flight was converted into a pre-write refusal by the work itself, and D-02 forbids `chmodSync`. Treat those two envelopes as untested in CI. `planning.mjs`'s `read(reqFile)` still accepts any existing filesystem object, so a FIFO at `.planning/REQUIREMENTS.md` hangs `phase-done` before its refusal can run. Same class as the `CHANGELOG.md` blocker, one seam over, raised medium and downgraded at adjudication. The manifest is at `3.5.8`; the `v3.5.8` tag is cut on `main` after this merge.
john added 36 commits 2026-08-22 17:55:09 +00:00
lib/file-transition.mjs runs a caller-owned step list under either loop
discipline and returns a completed/failed record with no envelope of its
own, so renumber and milestone-prune stop each carrying a hand-written
copy of that loop. Keys come back by identity and a caught error is
carried as thrown, because both callers render them differently.

The header records why this is a refusal protocol and not a journal:
renumber destroys phases/<at> before the first move so step one can never
be undone, prune is already resumable with zero on-disk state, and a
journal under a 0o555 .planning root fails EACCES before the transition
it was meant to record.
runTransition takes an ordered list of caller-declared conditions
evaluated before the first thunk. The first unsatisfied one ends the
call, names itself in refused, and leaves the conditions after it
unevaluated - ordering is the guarantee the stage exists to give.

A refusal is distinguishable from a step failure at the result level, so
a caller can tell 'nothing was attempted' from 'step one failed' without
re-deriving it. The module declares no conditions of its own and probes
no filesystem: a pre-flight that advertised more than a caller can
genuinely pre-check would talk callers out of hand-checking the tree.

The listing case is what pins the no-journal decision in code - a
refusal's before/after recursive listing of the fixture root must be
identical, so any marker, lock or temp path reddens it.
The hand-written try/catch-per-step loop is now one runTransition call
under the stop-at-first-failure discipline. The steps array, its order
and the comment explaining why that order differs from the printed ops
all stay where they are, and the partial-apply envelope keeps every byte:
the same completed op objects, the same failing op object, the same
detail expression and both hint sentences unchanged.

planning.test.mjs is untouched and outside this plan's lease, which is
what makes 'those cases pass unmodified' mechanical: 452 tests, 0
failures.
One step per completed phase keyed by the phase number, run under the
continue-past-failure discipline the seam already had: a missing
directory is still not a failure, the destination check still throws
rather than letting renameSync decide, and dirs.archived/deleted/missing
still fill in loop order.

The envelope stays here. failed is derived from the failures in loop
order as the bare number array the tests deep-equal, and the per-failure
warning is appended immediately after the call so warning order is
unchanged. The ARCHIVE.md residue write stays above this pass under its
lock, where RCL-07 put it.

milestone-prune.test.mjs is untouched and outside this plan's lease: 46
tests, 45 pass, 1 skip, 0 failures.
A ninth HELPERS row matching the body idiom of runTransition's loop -
the failure record keyed by the caller's own key, then the discipline
check - rather than an export name or a call site, since cmdRenumber and
cmdMilestonePrune now legitimately call the module. A paste-back under a
new name still carries the body, and a probe copy reddens the row naming
both files.

lib/file-transition.mjs joins the named-file list in the walk case, so a
rename or a move fails loudly instead of making the new row vacuous.
ROADMAP phase 1 SC2 asks for the partial-apply and partial-prune envelopes to
keep their hint text "pinned by a test that reddens on a paraphrase". The
envelope half shipped; the pin did not. planning.test.mjs guarded renumber's
two arms by keyword alone (/destroy/, /nothing was written/, two doesNotMatch),
every one of which a rewording satisfies, and milestone-prune.test.mjs carried
no hint assertion at all, so the three-line partial-prune hint was unguarded
end to end.

CONTEXT's AC1 and AC2 are why: both require these two files to pass unmodified,
which is how the refactor proved it changed no behaviour, and that forbade
adding the assertion in the only two files owning these commands' CLI fixtures.
PLAN task 3 saw the collision and substituted two `grep -c -F` invocations,
which settle the question once and never run again. The envelope proof is
banked against 61460139^..HEAD, so these three assertions are additive on top
of it rather than a revision of it.

Falsified before landing: paraphrasing "completed ops" to "finished ops" and
"only picks up the rest" to "just picks up the rest" in planning.mjs keeps
every keyword the old assertions check and now reddens exactly one case in
each suite. 452/452 and 45/45 green with planning.mjs restored.
Six criteria auto-verified by the deep pass with cited commands. The seventh
was its one gap: SC2's "pinned by a test that reddens on a paraphrase" had no
guard behind it, fixed in 62ed8f2d and retested to pass.
bump() split into a read-and-decide half that touches nothing on disk and a
write half that is one runTransition under stop-at-first-failure. The sibling
read and verdict and the changelog read plus its pure transform pass move above
the first atomicWrite, so a malformed member can no longer be discovered after
the primary manifest has already landed. The single hardcoded sibling path
becomes a declared one-entry const array (D-10); every write gate keeps its
meaning and every step is keyed by its repo-relative path.
D-07 splits D-08 rather than deleting it. A sibling that parses and is simply
not upgradeable keeps its ok:true siblings[] row; one this seam cannot READ now
refuses the whole run with reason unreadable-sibling-manifest, exit 1 and
nothing written, which is reachable because the write set is decided before the
first write lands. The seam header's code list and its D-08 exception paragraph
move in the same commit.
A local three-state read replaces readText at this call site, so present,
readable and regular-file is checked before the first write (D-09). Absent keeps
today's behaviour - no changelog step, changed:false, ok:true - while unreadable
refuses under its own code unreadable-changelog with nothing written, where the
''-on-failure contract used to scaffold a fresh changelog over a real release
history after the manifest had already been bumped. lib/seam-input.mjs is
untouched: its other callers keep the contract they depend on.
The transition's failure gets an envelope of this seam's own instead of falling
through to the dispatch catch, which emitted {"ok":false,"reason":"internal"}
with no manifest, siblings or changelog field at all. It is ok:false with
action:"partial" and reason partial-bump, and manifest.bumped, each siblings[]
row's bumped and changelog.changed are filled from what the transition COMPLETED
rather than from what was decided. Probe-proven against a forced changelog throw:
manifest.bumped true, changelog.changed false, the thrown message in detail,
exit 1. The header admits the second ok:false shape and keeps the sentence that
no ok:true refusal shape exists here.
milestone.md's bump halts go from three to four: the ok:false bullet is scoped to
action:"refuse" and gains unreadable-sibling-manifest and unreadable-changelog,
a new bullet carries the action:"partial" / partial-bump state and the three
fields naming what landed, and the siblings[] bullet drops the stale ordering
parenthetical for what that row now means - a sibling that was readable and
simply not upgradeable. weight-budgets.json is re-pinned 14222 to 14937 in the
same commit, and DOCS-CLAIMS rows MILESTONE-06 and MILESTONE-07 are restated at
the line numbers those bullets actually sit on.
readChangelog documented that the path must be a readable REGULAR file and
checked only that reading it did not throw. existsSync accepts a FIFO, a
character device and a directory alike, and readFileSync fails on none of the
first two: it blocks forever on a FIFO nobody writes to, and returns '' from
/dev/zero or /dev/null. The '' case is the one the D-09 arm exists to remove,
arriving through a successful read instead of a failed one, so it scaffolded a
fresh changelog over the release history without ever reaching the
unreadable-changelog refusal.

Check the shape with statSync().isFile() before reading, and pin the arm the
directory fixture cannot reach - a symlink to /dev/null, which reads cleanly
and is still not a changelog.
cmdPhaseDone decided "unreadable" from whether read() threw, so a FIFO at
.planning/REQUIREMENTS.md blocked the seam before its own refusal could run and
a character device read as '' - an empty requirements document the run then
reported in `wrote`. Classify the path by its SHAPE first, the check the gate
already required of readChangelog one seam over.
docs: close v3.5.8 - record the cycle, no next cycle opened
Some checks failed
test / node-test (git, 22) (pull_request) Successful in 29s
test / node-test (git, 24) (pull_request) Successful in 27s
test / node-test (other, 22) (pull_request) Successful in 1m1s
test / node-test (other, 24) (pull_request) Successful in 1m2s
test / node-test (planning, 22) (pull_request) Successful in 1m28s
test / node-test (planning, 24) (pull_request) Successful in 1m40s
test / node-test (prose, 22) (pull_request) Successful in 30s
test / node-test (prose, 24) (pull_request) Successful in 30s
test / node-test (review, 22) (pull_request) Successful in 17s
test / node-test (review, 24) (pull_request) Successful in 15s
test / node-test (routing, 22) (pull_request) Failing after 33s
test / node-test (routing, 24) (pull_request) Failing after 32s
test / self-verify (pull_request) Successful in 14s
test / typecheck (pull_request) Successful in 17s
25d774195f
john merged commit 81aaf6833c into main 2026-08-22 17:55:25 +00:00
Sign in to join this conversation.
No description provided.