MAINT-05 fix round 2: the refusal has to survive the NEXT start, and both marker guards have to be visible
checks / unit (pull_request) Successful in 5m14s

REJECTED because the fix round's own load-bearing sentence was FALSE, and the
whole suite was blind to that. `_verify_moves()` produced the red the reviewer
reproduced - that half stood - but DECISIONS §2 claimed "`ok` gates the marker,
so a misdirected migration is not stamped and the next start runs it again ...
a re-run is recoverable". THE REFUSAL LASTED EXACTLY ONE START.

MEASURED, Controller level, ONE persisted store, roster ["garage","shed"], flat
last_completed 2026-08-07, call site still handing `inverter-a` on both starts:

    construction 1: ok=False misdirected=4 moved=4 kept=0   marker=<absent>
    construction 2: ok=True  misdirected=0 moved=0 kept=4   marker=1
    history parked under unit/inverter-a/...; marker stamped after ONE restart.

WHY. `_verify_moves()` graded only `rep.moved`. On start 2 the same wrong call
site finds the four keys already present, so rule 1 routes every one of them to
`rep.kept`, `rep.moved` is EMPTY, the roster loop iterates over nothing,
`misdirected` is empty and `ok` is True. Start 3 then takes the
`from_version >= MAINT_SCHEMA_VERSION` early return FOREVER - including after
the call site is fixed. Loud-and-recoverable became silent-and-permanent after
one restart: the exact state store.py's header says must never happen, reached
by the fix written to prevent it.

WHY THE CHECK MISSED IT - the recurring defect, this time INSIDE the remedy.
Part 9b's recovery check was `migrate_maint_keys(_m5, ROSTER[0], ROSTER)` - the
CORRECTED name. It measures recovery along the one path where the operator has
ALREADY fixed the bug. Nothing in the 19 files called `migrate_maint_keys()`
twice with the SAME WRONG `unit`. The check was true and passed for the wrong
reason.

WHAT CHANGED

1. app/store.py `_verify_moves()` grades `set(rep.moved) | set(rep.kept)`.
   Sound by construction: every entry in both lists is a `unit_key(unit,
   legacy)` built from the SAME `unit` argument, so grading `kept` grades the
   same namespace decision `moved` does. A correct call site's `kept` entries
   sit under a roster name and pass; a wrong one cannot launder its namespace by
   having already written it. `migrate_unit_keys()` passes `roster=None` and
   returns before the loop, so it is untouched.

2. test_maintenance.py part 9c - THE CHECK THAT WAS MISSING. Two starts, SAME
   wrong unit, ONE store: the marker is still withheld on the second, the
   refusal names the same roster, the flat history is still there to recover
   from, and a third start with the CORRECTED name still gets a run (which a
   stamped marker would have made impossible). Its PRECONDITION asserts the
   SHAPE of the second report - `moved` empty, `kept` full - so it cannot
   quietly revert to grading 9b's path. Counterfactual beside it: the same two
   calls with the right name are accepted, then skipped as `already at schema`.

3. test_maintenance.py part 9d - THE OTHER GUARD ON THE SAME LINE, and a
   survivor nothing in the repository could see. `if rep.ok:` is
   `not lost and not misdirected`; every check that reached the marker did so on
   a store whose `kept` was empty, so

       if rep.ok:        ->      if rep.ok or rep.kept:        (+12 B)

   stamped the marker on a migration that LOST A MONTH OF HISTORY - defeating
   the older, settled `lost` guard as well as the new one - and ALL 19 SUITES
   WERE GREEN on it (measured at 65cddf3, purged, :ro). 9d's fixture is the one
   shape that separates the guards: two keys already namespaced (`kept`
   non-empty, the mutant's `or` fires) and two whose write the store drops
   (`lost` non-empty), `misdirected` empty throughout.

4. FOUND HERE AND CLOSED: THE SAME MUTATION ONE FUNCTION UP. `migrate_unit_keys()`
   - SAFETY-02's LATCH migration - gates its own marker the same way, and every
   check that reached it ran on a store with an empty `kept`. `if rep.ok or
   rep.kept:` at store.py:307 SURVIVED ALL 19 FILES. It stamps `schema_version`
   on a migration that DROPPED THE LATCH: the guard is off, the file still reads
   `sign_tripped: true`, and the store now says the migration finished, so the
   next start skips the one thing that would re-arm it. test_inverter.py part 10
   now drops the `sign_tripped` write BESIDE an already-namespaced
   `sign_reason`, with the accepted-baseline counterfactual. No source change -
   the shipped gate was right; nothing could see it. Mutation table 34 -> 35.

5. DECISIONS/MAINT-05 §2 CORRECTED. The paragraph that stated the false
   guarantee now states what was measured, why grading `moved` alone could not
   hold, and why the union is sound. §7 records the marker-gate hole found in
   `migrate_unit_keys()` and that it was closed. MUTATION.md gains habit 9 -
   "exercise a refusal a SECOND time with the SAME wrong input, not the
   corrected one" - and two survey rows.

MUTATION LEDGER - 11 mutations, ALL RED on the final tree, every run on a fresh
copy with the bytecode cache purged (entry count printed before and after) and
the tree mounted :ro so none could be written. Two were survivors BEFORE this
commit and both are recorded as such. Full transcript on the PR.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BwRLw7eKYj2byVdwB6kbjU
This commit is contained in:
2026-09-04 19:40:51 +02:00
co-authored by Claude Opus 5
parent 65cddf35f1
commit 905b6fd2b3
6 changed files with 416 additions and 14 deletions
+47 -2
View File
@@ -373,6 +373,16 @@ def _verify_moves(store, rep: MigrationReport, roster=None) -> None:
next start runs the migration again. A re-run is recoverable, by rule 1; a
silent skip is the state store.py's header says must never happen.
⚠️ AND THE GRADED SET IS `moved` UNION `kept`, WHICH IS WHAT MAKES THAT
PARAGRAPH TRUE ON THE SECOND START AS WELL AS THE FIRST. Graded over `moved`
alone the refusal lasted exactly one start: the wrong call site is still
wrong next time, finds the keys it wrote already present, and rule 1 routes
every one of them to `kept` - so `moved` is empty, this loop sees nothing,
`ok` comes back True and the marker is stamped on a migration that has
reached no readable key. The measurement is in the comment on the loop
itself. `test_maintenance.py` section 9c calls this twice with the SAME
wrong `unit` over one store, which is the shape nothing in the suite had.
`roster=None` means "not graded", and the one caller that passes it is
`migrate_unit_keys()`, which runs BEFORE `build_roster()` for the reason its
own docstring gives - the `Inverter` objects restore their latch in their
@@ -396,7 +406,32 @@ def _verify_moves(store, rep: MigrationReport, roster=None) -> None:
names = [str(n) for n in roster]
# `unit_key()` builds `unit/<unit>/<key>`, so the unit is field 1. Split
# through the same spelling the writer used rather than re-deriving it.
for target in sorted(rep.moved):
#
# ⚠️ `moved` UNION `kept`, AND THE UNION IS THE WHOLE OF THE SECOND FIX
# ROUND. Grading only `moved` made the refusal last EXACTLY ONE START.
# Measured at Controller level over one persisted store, roster
# ["garage", "shed"], flat `last_completed` 2026-08-07, with the call site
# still handing `inverter-a` on both starts:
#
# construction 1: ok=False misdirected=4 moved=4 kept=0 marker=<absent>
# construction 2: ok=True misdirected=0 moved=0 kept=4 marker=1
#
# On start 2 the four keys the WRONG start 1 wrote are already present, so
# rule 1 routes every one of them to `rep.kept`, `rep.moved` is EMPTY, this
# loop iterates over nothing, `misdirected` is empty and `ok` is True - so
# the caller stamps the marker and start 3 takes the `from_version >=
# SCHEMA` early return FOREVER, including after the call site is fixed.
# Loud-and-recoverable became silent-and-permanent after one restart: the
# exact state this module's header says must never happen, reached by the
# fix that was written to prevent it.
#
# The union is sound by construction rather than by hope: every entry in
# both `rep.moved` and `rep.kept` is a `unit_key(unit, legacy)` built from
# the SAME `unit` argument, so grading `kept` grades the same namespace
# decision `moved` does. A correct call site puts its `kept` entries under a
# roster name and they pass; a wrong one cannot launder its namespace by
# having already written it.
for target in sorted(set(rep.moved) | set(rep.kept)):
parts = target.split("/")
unit = parts[1] if len(parts) > 2 else ""
if unit in names:
@@ -443,7 +478,9 @@ def migrate_maint_keys(store, unit: str, roster) -> MigrationReport:
marker. Handing the roster in means the read-back is graded against the
keys the MACHINES read, and a `unit` that is not among these names comes
back `misdirected`: not `ok`, marker NOT written, migration runs again next
start. See `_verify_moves()` for the install this was measured on.
start - and again the start after that, because `_verify_moves()` grades
`kept` as well as `moved`. See `_verify_moves()` for the install this was
measured on, and for what grading `moved` alone cost on start 2.
THE THREE RULES ARE `migrate_unit_keys()`'s, SHARED IN CODE rather than
restated: `_move_flat_keys()` is rule 1 (never overwrite a namespaced value)
@@ -481,6 +518,14 @@ def migrate_maint_keys(store, unit: str, roster) -> MigrationReport:
_move_flat_keys(store, unit, MAINT_UNIT_KEYS, rep)
_verify_moves(store, rep, roster=names)
# ⚠️ `rep.ok` AND NOTHING ELSE - NOT `ok or kept`, NOT `not lost`, NOT
# `not misdirected`. BOTH guards gate this line and each one is pinned by a
# check that the other cannot satisfy: section 9c refuses a store whose
# failure is `misdirected` with `lost` empty, and section 9d refuses one
# whose failure is `lost` with `misdirected` empty AND `kept` non-empty -
# the mutation `if rep.ok or rep.kept:` stamps the marker on a migration
# that lost a month of history, and until section 9d existed all 19 suites
# were green on it.
if rep.ok:
store.set(MAINT_SCHEMA_KEY, MAINT_SCHEMA_VERSION)
else: