diff --git a/goodwe_controller/CHANGELOG.md b/goodwe_controller/CHANGELOG.md index 1dde8f0..c24a8ac 100644 --- a/goodwe_controller/CHANGELOG.md +++ b/goodwe_controller/CHANGELOG.md @@ -62,6 +62,39 @@ two power-moving phases on one grid connection pulling against each other. Every two-pack fixture that could see this now names its first pack `garage`; with them all called `inverter-a`, `self.inverters[0].name` and `UNIT_A` were the same string and the swap left all 19 suites green. +* **AND THE REFUSAL NOW SURVIVES THE NEXT START** (second fix round). The fix + above graded only the keys it had just *written*, so it refused exactly once: + measured at Controller level over one persisted store, roster + `["garage", "shed"]`, with the call site still wrong on both starts — + construction 1 `ok=False misdirected=4 moved=4 kept=0 marker=`, + construction 2 `ok=True misdirected=0 moved=0 kept=4` and the marker + **stamped**. On the second start rule 1 finds the wrong keys already there and + routes all four to `kept`, so `moved` is empty and the roster loop grades + nothing — and start 3 skips the migration forever, *including after the call + site is fixed*. Loud-and-recoverable became silent-and-permanent after one + restart. `_verify_moves()` now grades `set(moved) | set(kept)`; both lists are + built from the same `unit` argument, so grading `kept` grades the same + namespace decision. `test_maintenance.py` part **9c** drives two starts with + the same wrong unit over one store — the shape nothing in the suite had, since + 9b measured recovery along the path where the operator has *already* fixed the + call site. +* **AND THE MARKER GATE IS PINNED ON BOTH GUARDS.** `if rep.ok:` is + `not lost and not misdirected`, and the mutation `if rep.ok or rep.kept:` + stamped the marker on a migration that **lost a month of history** with all 19 + suites green. Part **9d** is the fixture that separates the two guards: two + keys already namespaced (`kept` non-empty) and two whose write the store + drops (`lost` non-empty), `misdirected` empty throughout. +* ⚠️ **AND THE SAME MUTATION ONE FUNCTION UP WAS A SURVIVOR, FOUND HERE AND + CLOSED.** `migrate_unit_keys()` — SAFETY-02's **latch** migration — gates its + own marker on `rep.ok` too, and every check that reached that gate ran on a + store where nothing was namespaced yet, so `rep.kept` was empty in all of + them. `if rep.ok or rep.kept:` there stamps `schema_version` on a migration + that **dropped the latch**: the guard is off, the file still says + `sign_tripped: true`, and the next start skips the migration that would have + re-armed it. Measured a survivor of all 19 files; now red on + `test_inverter.py` part 10, which drops the `sign_tripped` write beside an + already-namespaced `sign_reason`. No source change — the shipped gate was + right; nothing could see it. * ⚠️ **FOUND, RECORDED, NOT FIXED (SECOND ONE).** `migrate_unit_keys()` — SAFETY-02's latch migration — has the identical hole and it costs a **guard** rather than a history. It runs before `build_roster()` (each `Inverter` diff --git a/goodwe_controller/DECISIONS/MAINT-05-per-pack-maintenance.md b/goodwe_controller/DECISIONS/MAINT-05-per-pack-maintenance.md index ab18205..546af20 100644 --- a/goodwe_controller/DECISIONS/MAINT-05-per-pack-maintenance.md +++ b/goodwe_controller/DECISIONS/MAINT-05-per-pack-maintenance.md @@ -120,13 +120,76 @@ reported as `misdirected` — a **different** failure from `lost`: not information, whatever its value. So anything in it fails `ok` on its own. And **`ok` gates the marker**, so a misdirected migration is not stamped and the -next start runs it again. That is the decision the fix round was asked to -record, and the argument for it is asymmetric: a re-run is recoverable — rule 1 -makes it a no-op where keys did land, and rule 2 left the flat keys in the file -to recover *from* — while a silent skip is not recoverable at all, because the -store now claims the migration happened. `test_maintenance.py` part 9b measures -the recovery rather than asserting it: the same store, re-run with the roster's -own first name, comes back with its month of history. +next start runs it again. The argument for it is asymmetric: a re-run is +recoverable — rule 1 makes it a no-op where keys did land, and rule 2 left the +flat keys in the file to recover *from* — while a silent skip is not recoverable +at all, because the store now claims the migration happened. + +### ⚠️ THE PARAGRAPH ABOVE USED TO END HERE, AND AS IT STOOD IT WAS FALSE + +**The refusal lasted exactly one start. The second start stamped the marker.** +That is a correction to what the first fix round wrote down, not a new +qualification of it, and it is recorded here because a decision document that +states a guarantee the code does not provide is worse than one that says +nothing. + +`_verify_moves()` graded only `rep.moved`. 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= (withheld) +construction 2: ok=True misdirected=0 moved=0 kept=4 marker=1 (STAMPED) +history parked under unit/inverter-a/…; marker stamped after ONE restart. +``` + +On start 2 the same wrong call site finds the four keys it wrote already +present, so **rule 1** — never overwrite a namespaced value — routes every one +of them to `rep.kept`. `rep.moved` is empty, the roster-grading loop iterates +over nothing, `misdirected` is empty, `ok` is True, the marker is stamped, and +start 3 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: exactly the state `store.py`'s header +says must never happen, reached by the fix written to prevent it. + +**The remedy is one line**, and it is sound by construction rather than by +argument: grade `set(rep.moved) | set(rep.kept)`. 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 are 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 unaffected. + +**Why the check missed it, which is the part worth keeping.** 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*. The real failure is a call site that is still wrong on the next start, +and nothing in the 19 files called `migrate_maint_keys()` twice with the **same +wrong** `unit`. The check was true, passed, and could not reach the failure its +sentence named — MUTATION.md's third defect, this time sitting inside the fix +written to close it. Part **9c** now drives two starts with the same wrong unit +over one store, asserts the marker is still withheld on the second, and asserts +the *shape* of the second report (`moved` empty, `kept` full) so it cannot +quietly revert to 9b's path. + +### The marker gate is `rep.ok` and nothing weaker — both guards, each pinned + +`if rep.ok:` is `not lost and not misdirected`, and until this round only the +`misdirected` half was reachable by a check that also had a non-empty `kept`. +The mutation + +``` +if rep.ok: -> if rep.ok or rep.kept: +``` + +stamps 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**. Part **9d** is the fixture that separates them: two keys +already namespaced (`kept` non-empty, so the mutant's `or` fires) and two keys +whose write the store drops (`lost` non-empty, so `ok` is False), with +`misdirected` empty throughout so the section cannot pass on the other guard by +accident. Measured differential on it: shipped → marker absent; mutant → marker +`1`, both reporting `ok=False lost=2 kept=2`. **Why the fixtures had to change, and this is the part that got the card rejected.** Nothing in the repository could see whether any of the above was @@ -297,3 +360,29 @@ It is pinned as a running check (`test_maintenance.py` part 13) rather than left as a comment. ⚠️ **Its assertions state the WRONG behaviour on purpose** — read them as the specification of a defect; the card that fixes it should expect them to go red and should replace them. + +### And its MARKER GATE had the round-3 hole too — found here, closed here + +Separate from the namespace hole above, and **fixed** rather than recorded, +because closing it cost one test fixture and no source change. +`migrate_unit_keys()` gates `schema_version` on `rep.ok` exactly as +`migrate_maint_keys()` gates its own marker, and every check that reached that +gate ran on a store where **nothing** was namespaced yet — so `rep.kept` was +empty in all of them and + +``` +if rep.ok: -> if rep.ok or rep.kept: +``` + +**survived all 19 files**. What it does is stamp the schema marker on a +migration that DROPPED THE LATCH: the guard is off, the state file still reads +`sign_tripped: true`, and because the store now says the migration finished, the +next start skips the one thing that would have re-armed it. DEV-02's own worst +case, filed under *done*. + +`test_inverter.py` part 10 now drops the `sign_tripped` write **beside an +already-namespaced `sign_reason`** — `lost` non-empty *and* `kept` non-empty, +which is the one shape none of the existing fixtures had — and asserts the +marker is withheld, with the accepted-baseline counterfactual beside it (habit +7). The shipped gate was correct throughout; what changed is that a check can +now see it. diff --git a/goodwe_controller/MUTATION.md b/goodwe_controller/MUTATION.md index 843b8f8..a4bd351 100644 --- a/goodwe_controller/MUTATION.md +++ b/goodwe_controller/MUTATION.md @@ -135,13 +135,14 @@ manufactures them from a **fixture**: the check runs, the assertion is evaluated, the sentence above it is true — and the numbers it compares are arranged so that the failure it describes could not have changed the answer. -It is not hypothetical and it is not rare. Seven instances in two weeks; four +It is not hypothetical and it is not rare. EIGHT instances in two weeks; four were found by review, two - CFG-04's fixture pair - by an author's own mutation -battery, which is the cheaper place, and one by review again, in the one +battery, which is the cheaper place, one by review again, in the one place nobody had thought to point the rule at: not a check in the suite, but a -COMMITTED EVIDENCE ARTIFACT four documents cite. +COMMITTED EVIDENCE ARTIFACT four documents cite - and the eighth INSIDE THE FIX +WRITTEN TO CLOSE THE SEVENTH. -⚠️ **AND IT HAS NOW APPEARED IN FIVE CONSECUTIVE CARDS.** The seventh instance, +⚠️ **AND IT HAS NOW APPEARED IN SIX CONSECUTIVE CARDS.** The seventh instance, MAINT-05's, is the one that says why: the defect is not carelessness about fixtures, it is that **a check and the thing it checks were built from the same constant**. Ask of every difference-claim not "is this assertion true" but @@ -196,6 +197,22 @@ same place twice, the check is decoration. one — grade against something the thing under test did not supply, here the ROSTER the machines read. + ⚠️ **AND THE FIX ITSELF CARRIED THE DEFECT, WHICH IS THE SIXTH CARD IN A ROW + AND THE FIRST TIME IT HAS APPEARED *INSIDE* A REMEDY.** The fix round'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*, and nothing in the 19 files called + `migrate_maint_keys()` twice with the **same wrong** `unit`. So the refusal + the card was rejected twice to obtain lasted **exactly one start**: measured + at Controller level over one persisted store, roster `["garage", "shed"]` — + construction 1 `ok=False misdirected=4 moved=4 kept=0 marker=`, + construction 2 `ok=True misdirected=0 moved=0 kept=4 marker=1`. On start 2 + rule 1 routes the wrongly-written keys to `rep.kept`, `rep.moved` is empty, + the roster loop iterates over nothing, and the marker is stamped on a + migration that reached no readable key — after which the early return skips it + forever, *including after the call site is fixed*. The check was true and + passed for the wrong reason. Habit **9** below is what falls out of it. + * **CFG-04's `tools/supervisor_shape_probe.py` §B** — and this one is the rule reaching outside the suite. The probe is the committed evidence that `config.yaml`'s `inverters: []` default is load-bearing, and four documents @@ -270,6 +287,18 @@ Cheap habits fall out of it, and all of them are now used in this repository: red. `inverters: []` was deleted from `config.yaml` and the probe re-run against `ghcr.io/home-assistant/amd64-hassio-supervisor:2026.08.0` for exactly that reason. +9. **Exercise a refusal a SECOND time with the SAME wrong input — not the + corrected one.** A guard that refuses once and then admits the same bad + caller is not a guard, it is a delay, and the second call is a *different + code path* whenever the first call left state behind. MAINT-05's roster + grading iterated the keys it had just **written**; on the second start the + same wrong caller found them already present, they moved to the + `already namespaced` list, and the loop graded an empty set. Re-running with + the **fixed** argument — which is what the check did — measures the operator's + recovery, never the persistence of the refusal. Ask of every refusal: *what + does this do when the caller has not been fixed and simply runs again?* And + assert the **shape** of that second report, not only its verdict, so the + check cannot silently revert to grading the first path. And the mutation-table consequence: **a variant is not covered because a near-miss of it is.** DEV-02's table listed the *actuator* variant of the @@ -326,6 +355,8 @@ commit and PR bodies. | **TARIFF-01** reviewer survivor: `record_block()` ignoring its `policy` argument (0dbd485) | **re-verified — still a survivor** | re-run here through `tools/mutation_run.sh` against the 15 suites that were on the record, all green with `billed_w(block, policy)` mutated to `billed_w(block, POLICY_NET)`. The recorded diagnosis holds: every block reaching the store is single-phase, where NET, SUM and MAX collapse. **Now CLOSED by TEST-13** (`test_tariff.py` section 7), which round-trips section 7's own unbalanced `(3000, 500, -200) W` block THROUGH the store under each policy - three distinct banked figures, so a hardcoded policy takes at least two of them red. Third instance of "a stimulus evaluated where the quantity it grades is degenerate", after WITNESS-02's span normalisation and TARIFF-01's own DST window. | | **SAFETY-25** 8 mutations against `test_peakguard.py` part 10, then 9 more after review, all red | **not at risk** | each run against a tree copied fresh with `__pycache__` excluded and mounted `:ro`, so no `.pyc` existed to go stale - the same structural immunity recorded for CI above. Byte deltas recorded on the PR; none is `+0 B`. All are **reds**, which is the direction this defect cannot fabricate. ⚠️ The second round exists because the first round's mutations were all aimed at DISARMING the guard, and the claim part 10 actually made was about the guard being FIXED - the reviewer built the fix and the suite stayed green. Bytecode was never the issue there; an unaimed battery was. | | **SAFETY-03** reviewer survivor: `abs()` dropped from `clamp_to_rating`'s limit (6aabb83) | **re-verified — still a survivor** | re-run the same way, all 15 green. Also as recorded: unreachable under the `int(100, 5000)` schema. | +| **MAINT-05 round 3**, `migrate_maint_keys()` marker gate `if rep.ok:` → `if rep.ok or rep.kept:` (`+12 B`) | **was a survivor — now CLOSED** | measured against head `65cddf3` under the purge, tree mounted `:ro`, `19/19 PASS`. It stamps the schema marker on a migration that LOST state, defeating the older `lost` guard as well as the new `misdirected` one, because every check that reached the marker did so on a store whose `kept` list was empty. Closed by `test_maintenance.py` **9d**; the mutation is now red on three named checks. | +| **MAINT-05 round 3**, `migrate_unit_keys()` marker gate `if rep.ok:` → `if rep.ok or rep.kept:` (`+12 B`) | **found here — SAFETY-02's half, now CLOSED** | the SAME mutation one function up, on the LATCH migration rather than the history one. Measured a survivor against this branch under the purge, `:ro`, `19/19 PASS`, then closed by `test_inverter.py` part 10's new fixture — a store with `unit/inverter-a/sign_reason` already present (so `kept` is non-empty) whose `sign_tripped: true` write is dropped. Shipped: marker withheld. Mutant: marker stamped on a **disarmed latch**, and the next start skips the migration that would have re-armed it. | | **TARIFF-01**'s other three survivors, **SAFETY-03**'s `describe()` survivor, **TARIFF-02**'s M3/M5/M7, **SAFETY-18**'s three | **still unverified** | recorded before the purge discipline, with no byte delta on the record, so a `+0 B` same-second revert cannot be excluded. All are survivors, which is the direction this defect fabricates. TARIFF-02's and SAFETY-18's were **closed by later commits whose claims are reds**, so what is unverified is the *diagnosis*, not the current state of the code. | **Nothing here says a past result is wrong.** The two survivors re-run above diff --git a/goodwe_controller/app/store.py b/goodwe_controller/app/store.py index d1a4d24..324f270 100644 --- a/goodwe_controller/app/store.py +++ b/goodwe_controller/app/store.py @@ -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//`, 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= + # 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: diff --git a/goodwe_controller/test_inverter.py b/goodwe_controller/test_inverter.py index 0ee1c63..e24dbb8 100644 --- a/goodwe_controller/test_inverter.py +++ b/goodwe_controller/test_inverter.py @@ -572,6 +572,58 @@ check("...and a store that only fails to persist a DEFAULT is not reported as " "dismiss before the real one arrives", migrate_unit_keys(LosesTheLatch(), UNIT_A).ok is True) +# ⚠️ AND THE MARKER GATE, ON A STORE WHERE SOMETHING WAS ALREADY NAMESPACED. +# FOUND BY MAINT-05's ROUND-3 BATTERY, AND IT WAS A SURVIVOR OF ALL 19 FILES. +# `migrate_unit_keys()` gates `SCHEMA_KEY` on `rep.ok`, and every check above +# reaches that gate on a store where NOTHING was already under `unit/...` - so +# `rep.kept` is empty in all of them, and the mutation +# +# if rep.ok: -> if rep.ok or rep.kept: +# +# stamped the marker on a migration that DROPPED THE LATCH, with the whole suite +# green. That is a disarmed safety guard plus a store that says the migration +# finished, so the next start skips the one thing that would have re-armed it - +# DEV-02's own worst case with the paper trail filed under "done". +# +# The fixture is the one shape that separates them: `sign_reason` ALREADY +# namespaced (so `kept` is non-empty and the mutant's `or` fires) and the +# `sign_tripped: true` write dropped on the floor (so `lost` is non-empty and +# `ok` is False). `migrate_unit_keys()` is passed no roster, so `misdirected` +# cannot be what refuses this - it is the `lost` guard or nothing. +_kept_reason = "SIGN_DISAGREEMENT: already namespaced by a later version" +_broken_kept = LosesTheLatch({ + "sign_tripped": True, + "sign_reason": "SIGN_DISAGREEMENT: measured", + unit_key(UNIT_A, "sign_reason"): _kept_reason}) +_rep_kept = migrate_unit_keys(_broken_kept, UNIT_A) +print(f" a dropped latch write beside an already-namespaced reason: " + f"{_rep_kept.explain()}") +print(f" lost={len(_rep_kept.lost)} kept={len(_rep_kept.kept)} " + f"ok={_rep_kept.ok}; marker " + f"{_broken_kept.data.get(SCHEMA_KEY, '<>')!r}") +check("PRECONDITION: this fixture reaches the marker gate through the `lost` " + "guard with a NON-EMPTY `kept`, which is the combination none of the " + "checks above has. One key dropped, one key left alone, `ok` False. If a " + "later edit empties `kept` here the mutation goes invisible again, so the " + "shape is asserted rather than assumed", + _rep_kept.ran and len(_rep_kept.lost) == 1 and len(_rep_kept.kept) == 1 + and not _rep_kept.ok + and _broken_kept.data[unit_key(UNIT_A, "sign_reason")] == _kept_reason) +check("⚠️ AND THE STORE IS NOT MARKED MIGRATED. The key that was ALREADY there " + "does not buy a marker for the latch that was not: stamping here would " + "tell the next start that the migration which dropped `sign_tripped` had " + "finished, so it would never run again and the guard would stay off on an " + "install whose file still reads `sign_tripped: true`", + SCHEMA_KEY not in _broken_kept.data) +check("...and the counterfactual, one property different: the SAME store shape " + "on a store that does not drop writes IS marked migrated. So the refusal " + "above is about the latch that was lost, not about `kept` being non-empty", + migrate_unit_keys( + MemStore({"sign_tripped": True, + "sign_reason": "SIGN_DISAGREEMENT: measured", + unit_key(UNIT_A, "sign_reason"): _kept_reason}), + UNIT_A).ok is True) + _broken2 = LosesTheLatch({"sign_tripped": True, "sign_reason": "SIGN_DISAGREEMENT: measured"}) _c2d, _ = built(store=_broken2) @@ -2149,6 +2201,16 @@ MUTATIONS = [ ("validate.py allowing two roster entries with the SAME name", "part 10: 'two entries with the SAME name are refused' - one Store " "namespace for two units is DEV-02's defect rebuilt in YAML"), + ("`migrate_unit_keys()`'s marker gate `if rep.ok:` -> `if rep.ok or " + "rep.kept:` - the schema marker stamped on a migration that DROPPED THE " + "LATCH, because one key happened to be namespaced already", + "part 10: 'AND THE STORE IS NOT MARKED MIGRATED'. ⚠️ THIS ONE SURVIVED " + "ALL 19 FILES and was found by MAINT-05's round-3 battery, not by this " + "card: every migration check here reached the gate on a store where " + "NOTHING was namespaced yet, so `rep.kept` was empty in all of them and " + "the `or` never fired. Stamping there disarms the latch AND tells the " + "next start the migration finished, so it never re-arms - DEV-02's own " + "worst case filed under 'done'"), ] # ⚠️ FOUR OF THESE SURVIVED THE FIRST TIME AND THE CHECKS WERE CHANGED, NOT THE # TABLE - two in DEV-02, two more in CFG-04, and the CFG-04 pair are recorded in @@ -2167,7 +2229,7 @@ for what, caught in MUTATIONS: check(f"all {len(MUTATIONS)} defeats of this card are caught by a named check " f"above. A guard whose checks survive its own defeat is not a guard, and " f"this repository has three separate records of exactly that", - len(MUTATIONS) == 34) + len(MUTATIONS) == 35) print() diff --git a/goodwe_controller/test_maintenance.py b/goodwe_controller/test_maintenance.py index 9ed9b47..8f84aea 100644 --- a/goodwe_controller/test_maintenance.py +++ b/goodwe_controller/test_maintenance.py @@ -806,6 +806,148 @@ check("...and a roster of ONE still returns before any of this, so the " is False) +# --------------------------------------------------------------------------- # +print() +print("9c. THE REFUSAL HAS TO SURVIVE THE NEXT START: the same wrong call " + "site, twice, over ONE store") + +# ⚠️ WHY 9b WAS NOT ENOUGH, AND IT IS THIS FILE'S OWN RECURRING DEFECT ONE +# LEVEL UP. 9b's recovery check re-runs the migration with `ROSTER[0]` - the +# CORRECTED name - so it measures recovery along the one path where the +# operator has ALREADY fixed the call site. The real failure is a call site that +# is STILL WRONG on the next start, and until this section nothing in the 19 +# files called `migrate_maint_keys()` twice with the SAME wrong `unit`. 9b was +# true and passed for the wrong reason. +# +# WHAT THAT HID. Measured at Controller level over one persisted store, roster +# ["garage", "shed"], flat `last_completed` 2026-08-07, call site handing +# `inverter-a` on both starts: +# +# construction 1: ok=False misdirected=4 moved=4 kept=0 marker= +# construction 2: ok=True misdirected=0 moved=0 kept=4 marker=1 +# +# `_verify_moves()` graded only `rep.moved`. On start 2 the wrong call site +# finds the keys it wrote already present, rule 1 routes all four to `rep.kept`, +# `rep.moved` is EMPTY, the roster loop iterates over nothing, `misdirected` is +# empty and `ok` is True - so the marker is stamped and start 3 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 fix grades `set(rep.moved) | set(rep.kept)`. +_m7 = flat_store() +_rep7a = migrate_maint_keys(_m7, UNIT_A, ROSTER) +_rep7b = migrate_maint_keys(_m7, UNIT_A, ROSTER) +print(f" start 1: ok={_rep7a.ok} misdirected={len(_rep7a.misdirected)} " + f"moved={len(_rep7a.moved)} kept={len(_rep7a.kept)} marker=" + f"{_m7.data.get(MAINT_SCHEMA_KEY, '<>')!r}") +print(f" start 2: ok={_rep7b.ok} misdirected={len(_rep7b.misdirected)} " + f"moved={len(_rep7b.moved)} kept={len(_rep7b.kept)} marker=" + f"{_m7.data.get(MAINT_SCHEMA_KEY, '<>')!r}") +check("PRECONDITION, SO THIS SECTION CANNOT PASS FOR THE WRONG REASON: start 2 " + "goes down the KEPT path, which is the path no other check in this file " + "reaches. Not one key is written a second time - all four are already " + "there from start 1, so `moved` is EMPTY and `kept` holds every one of " + "them. If a later edit makes start 2 re-WRITE them, this goes red rather " + "than the claim below quietly reverting to 9b's", + _rep7b.ran and _rep7b.moved == {} + and sorted(_rep7b.kept) == sorted(unit_key(UNIT_A, k) + for k in MAINT_UNIT_KEYS)) +check("⚠️ THE MARKER IS STILL WITHHELD ON THE SECOND START. This is the whole " + "section: a refusal that lasts exactly one start is not a refusal, it is " + "a delay - the next start stamps the marker on a migration that has " + "reached no readable key, and every start after that skips the migration " + "entirely", + MAINT_SCHEMA_KEY not in _m7.data) +check("...and it is refused for the SAME reason as the first time, graded " + "against the same roster: four keys under a namespace nothing on this " + "site reads. `ok` False, `misdirected` full, `lost` empty - the caller " + "was wrong, the store was not", + not _rep7b.ok and not _rep7b.verified and not _rep7b.lost + and len(_rep7b.misdirected) == len(MAINT_UNIT_KEYS) + and repr(ROSTER) in _rep7b.explain()) +check("...and the history is still THERE to recover: rule 2 left every flat key " + "in the file across both starts, and the roster's first pack still reads " + "nothing - which is what makes this loud rather than lost", + all(_m7.data[k] is not None for k in MAINT_UNIT_KEYS) + and _m7.data["last_completed"] == "2026-08-07T12:00:00+00:00" + and unit_key(ROSTER[0], "last_completed") not in _m7.data) +_rep7c = migrate_maint_keys(_m7, ROSTER[0], ROSTER) +print(f" start 3, call site FIXED: {_rep7c.explain()}") +check("⚠️ AND BECAUSE THE MARKER WAS NEVER STAMPED, THE FIXED CALL SITE STILL " + "GETS A RUN. A stamped marker would have made this an early return " + "forever - the operator fixes the bug and the migration never happens " + "again. Here the third start migrates the month into `garage` and stamps", + _rep7c.ran and _rep7c.ok + and _m7.data.get(unit_key(ROSTER[0], "last_completed")) + == "2026-08-07T12:00:00+00:00" + and _m7.data[MAINT_SCHEMA_KEY] == MAINT_SCHEMA_VERSION) + +# The counterfactual, so this is not a check satisfied by a validator that +# refuses every second call: the same two calls with the RIGHT name. +_m7ok = flat_store() +_rep7ok_a = migrate_maint_keys(_m7ok, ROSTER[0], ROSTER) +_rep7ok_b = migrate_maint_keys(_m7ok, ROSTER[0], ROSTER) +check("...and the counterfactual: the SAME two calls with the roster's own " + "first name are accepted - stamped on the first, and the second is the " + "`already at schema` early return, not a refusal. So 9c is about the " + "NAME surviving into the second start, not about second calls", + _rep7ok_a.ok and _m7ok.data[MAINT_SCHEMA_KEY] == MAINT_SCHEMA_VERSION + and _rep7ok_b.ran is False and "already at schema" in _rep7ok_b.explain()) + + +# --------------------------------------------------------------------------- # +print() +print("9d. THE OTHER GUARD ON THE SAME LINE: a migration that LOST STATE is " + "not stamped either, even when some keys were already in place") + +# ⚠️ WHAT THIS PINS, AND IT IS A SURVIVOR THE SECTIONS ABOVE CANNOT SEE. +# `migrate_maint_keys()` gates the marker on `rep.ok`, which is BOTH guards: +# `not lost and not misdirected`. Every check in this file that reaches the +# marker reaches it through `misdirected` (9b, 9c) or through a store where +# NOTHING was already namespaced (part 9's `LosesTheHistory`, whose `kept` is +# empty). So the mutation +# +# if rep.ok: -> if rep.ok or rep.kept: +# +# stamped the marker on a migration that lost a month of history - defeating the +# OLDER, settled `lost` guard as well as the new `misdirected` one - and all 19 +# suites stayed green on it. Measured differential on this exact fixture: +# shipped -> marker absent; mutant -> marker 1, both reporting ok=False lost=2. +# +# The fixture is the one shape that separates them: TWO keys already namespaced +# (so `kept` is non-empty and the mutant's `or` fires) and TWO keys whose write +# the store drops (so `lost` is non-empty and `ok` is False). `misdirected` is +# empty throughout - the unit handed in IS the roster's first name - so this +# section cannot pass on the `misdirected` guard by accident. +_m8 = LosesTheHistory(dict(flat_store(**{ + unit_key(ROSTER[0], "phase"): CHARGE, + unit_key(ROSTER[0], "phase_started"): "2026-09-03T22:00:00+00:00"}).data)) +_rep8 = migrate_maint_keys(_m8, ROSTER[0], ROSTER) +print(f" two keys already namespaced, two dropped on write: {_rep8.explain()}") +print(f" ok={_rep8.ok} lost={len(_rep8.lost)} kept={len(_rep8.kept)} " + f"misdirected={len(_rep8.misdirected)}; marker " + f"{_m8.data.get(MAINT_SCHEMA_KEY, '<>')!r}") +check("PRECONDITION: this fixture reaches the marker through the `lost` guard " + "and ONLY that one - two keys in `kept`, two in `lost`, `misdirected` " + "empty. If it ever stops having a non-empty `kept` the mutation below " + "goes invisible again, so the shape is asserted rather than assumed", + _rep8.ran and len(_rep8.kept) == 2 and len(_rep8.lost) == 2 + and not _rep8.misdirected and not _rep8.ok) +check("⚠️ THE MARKER IS NOT STAMPED ON A MIGRATION THAT LOST A MONTH OF " + "HISTORY, and the two keys that DID land do not buy it one. `rep.ok` is " + "the gate and nothing weaker: stamping here would tell the next start a " + "migration finished that dropped half of what it was moving", + MAINT_SCHEMA_KEY not in _m8.data) +check("...and the counterfactual, one property different: the SAME fixture on a " + "store that does not drop writes IS stamped. So the refusal above is " + "about the state that was lost, not about `kept` being non-empty", + migrate_maint_keys( + MemStore(dict(flat_store(**{ + unit_key(ROSTER[0], "phase"): CHARGE, + unit_key(ROSTER[0], "phase_started"): + "2026-09-03T22:00:00+00:00"}).data)), + ROSTER[0], ROSTER).ok is True) + + # --------------------------------------------------------------------------- # print()