The chronicle of every settled battle in Plenum (smart-thermostat-with-vents) — major investigations, dead ends, rejected approaches, and reverts, each as symptom → root cause → evidence → fix → status. Load when you suspect a bug was seen before, when someone proposes re-fixing or weakening something (deadband in at-target, frontend toStorage on payloads, default-mapped MCP port, per-selector screenshot masks), when you need the incident behind a guard/test name, or before filing an issue th...
Scanned 9/11/2026
Install to Claude Code
npx -y skills add dhruvb14/smart-thermostat-with-vents --skill plenum-failure-archaeology --agent claude-codeInstalls into .claude/skills of the current project.
Are you the author of Plenum Failure Archaeology?
Add the live security badge to your README — it updates automatically with every re-scan.
[](https://www.skillsdirectory.com/skills/dhruvb14-plenum-failure-archaeology)More formats (shields.io, HTML) on the badges page.
---
name: plenum-failure-archaeology
description: >-
The chronicle of every settled battle in Plenum
(smart-thermostat-with-vents) — major investigations, dead ends, rejected
approaches, and reverts, each as symptom → root cause → evidence → fix →
status. Load when you suspect a bug was seen before, when someone proposes
re-fixing or weakening something (deadband in at-target, frontend toStorage
on payloads, default-mapped MCP port, per-selector screenshot masks), when
you need the incident behind a guard/test name, or before filing an issue
that might duplicate #26–#369 history. NOT for live triage of a new symptom
(use plenum-debugging-playbook) or for the invariant statements themselves
(plenum-architecture-contract).
---
# Plenum Failure Archaeology
Every major battle this project has fought, organized by campaign,
chronological within each. Purpose: **nobody re-fights a settled war, and
nobody "fixes" something that is a deliberate decision.** Each entry is
symptom → root cause → evidence → fix → status, with the guard or test that
now pins the behavior named exactly as it exists in the repo (verified
2026-07-04, v0.22.1).
**When NOT to use this skill:**
- You have a live symptom to diagnose *now* → `plenum-debugging-playbook`.
- You want the invariant/contract itself (°F storage, write boundary) → `plenum-architecture-contract` (the #231 contract is owned there; only the incident story lives here).
- You are about to make a change and need the gates → `plenum-change-control`.
- You need the HVAC physics behind a guard (short-cycling, dead-heading, delta math) → `hvac-zoning-reference`.
**Reading a status tag:**
- **Settled — do not reopen**: root-caused, fixed, and pinned by a named guard/test. Re-proposing the rejected alternative is a regression, not an idea.
- **Partially addressed — see #N**: the acute bug is fixed; a broader follow-up is tracked in an open issue.
Jargon used once: *golden* = committed reference screenshot for visual
regression; *hold* = the min-runtime phase where a finished cycle is kept
running to satisfy `min_cycle_runtime_min`; *write boundary* = the backend
POST/PUT/PATCH handlers where display-unit temps become °F.
---
## Campaign 1 — Early engine correctness era (v0.4–v0.7, issues #26–#86)
The engine's first months in a real house. Theme: control decisions derived
from *live HA state at the wrong moment* instead of from persisted intent.
**#26 — vents never closed on `heat_cool` thermostats.** Symptom: 2–3 h
cycles, zero `close_cover` calls, rooms blowing past target. Root cause:
`_read_hvac_mode()` fell back to `"heating"` when `hvac_action="idle"` — and
the HVAC goes idle *exactly* when the room reaches target, so the at-target
comparison inverted at the only moment it mattered. Fix: cycle direction is
locked at cycle start (`_cycle_mode`), never re-read mid-cycle from
`hvac_action`. Same issue: UTC timestamps rendered as local (frontend now
appends `"Z"` and converts). Settled — do not reopen.
**#29 — heat/cool oscillation at cycle boundaries.** After #26's fix made
cycles terminate, each *new* cycle picked its direction from live
`hvac_action`, which reflects post-termination drift → cool cycle, heat
cycle, cool cycle, forever. Fix: `_infer_mode_from_room_temps()` — direction
comes from room temps vs targets; all-rooms-in-deadband returns `"off"` (no
cycle). Settled — never derive cycle direction from `hvac_action`.
**#32 — full engine audit #1.** Decisions that still stand: every vent
decision writes to the event log regardless of dev mode; setpoint calls pass
`hvac_mode` explicitly (the `heat_cool` inference path was eliminated);
cycles end by setting setpoint = ambient in the cycle's own mode;
`min_setpoint`/`max_setpoint` were **repurposed** from setpoint-clamps into
the comfort-envelope bounds (clamping ambient resets had prevented HVAC from
stopping). Settled.
**#38 — engine heated a 74 °F room toward a 72 °F target.** Root causes:
rooms with unavailable sensors were silently skipped from the mode vote, and
nothing cross-checked the vote against the thermostat's own ambient. Fix:
thermostat-ambient fallback when room sensors are unreadable + an ambient
sanity check on the inferred mode. Settled.
**#48 — full engine audit #2.** Three fixes: (1) setpoint anchored to
thermostat ambient (`min(setpoint, ambient − overshoot)` for cooling) so a
hallway probe cooler than the bedrooms can't make the HVAC think it's
already satisfied; (2) explicit mode guard — unexpected `hvac_mode` values
log an error instead of silently defaulting to heat; (3) mixed heat/cool
votes no longer drag losing-side rooms into the wrong cycle
(`_filter_rooms_for_mode`). Settled.
**#51 — cycles stuck "Active" after system stop.** `_abort_cycle` cleared
in-memory state even when `db.close_cycle_log` failed, orphaning
`ended_at IS NULL` rows. Fix: close-ordering hardened + safety nets
(`close_open_cycle_logs` at cycle start, on restore, and in the scheduler's
`_reset_and_reevaluate`). Settled.
**#54 — dev-mode cycles lingered ACTIVE across toggles.** Only
`set_system_enabled(False)` cleaned up; the other three system/dev
transitions did nothing (engine gate is `system_enabled OR dev_mode`, so a
fast dev-off→system-on flip was never observed disabled). Fix: **all four**
transitions run `Scheduler._reset_and_reevaluate()` (force-abort every
engine, close orphans, immediate re-tick) — see `scheduler.py` calls at the
enabled/dev-mode setters. Related: #35 made system-off abort immediate
rather than waiting ≤60 s for the next tick. Settled.
**#67 — idle-room vents left open when a new cycle starts.**
`_terminate_cycle` re-opens ALL zone vents (correct idle state); the next
cycle only opened active-room vents and never closed the rest. Fix:
`_close_idle_room_vents()` at fresh cycle start; pinned by
`tests/integration/test_idle_room_vents_closed_on_cycle_start.py` and
`test_idle_vents_closed_on_restore.py`. Settled.
**#70 — cycles ended at the deadband boundary (72 °F target "reached" at
73 °F).** Deliberate design decision, not a bug fix: `_is_at_target` is a
**pure target comparison — no deadband**. Deadband gates cycle *start/join*
only. Do not "optimize" by re-adding deadband to the completion check; that
re-introduces #70. Settled — do not reopen.
**#82 — "Closed idle room X vents" logged, vents physically still open.**
Two call sites bypassed the `control_method` dispatcher and called
`close_cover` directly — a no-op for `set_position`/`set_tilt_position`/
`toggle` vents (Flair's common config, introduced by #57). Fix: all close
paths route through `VentController._invoke_close`; reconciler also watches
idle-room vents during RUNNING. Pinned by
`tests/integration/test_idle_vent_close_dispatch.py` and
`test_vent_types.py`. Settled — never call `ha.close_cover` directly.
**#86 — cycle never terminated after all rooms reached target.** After a
vent closes, the room drifts back past target; the `elif not at_target`
branch in `_monitor_rooms` had no `vent_closed_at` guard, so post-closure
drift blocked `all_at_target` forever (48 min of all-vents-closed runtime
observed). Fix: a room whose vent is already closed is "served" — its drift
cannot block termination (`vent_closed_at is None` guards throughout
`_monitor_rooms`). Settled.
Also from this era: **#65** — `datetime.now()` (local) in `room_manager`
mixed with `utcnow()` everywhere else shifted presence-holdover display;
everything is naive-UTC now. Settled.
---
## Campaign 2 — The unit-system saga (#123 → #231 → parity system)
The project's costliest bug class. The invariant that resulted (°F-only
storage, conversion at exactly one boundary) is stated in
`plenum-architecture-contract`; this is how it was learned.
**#123 — the feature.** All temps stored °F at 2 dp (no migration); unit
priority: `TEMPERATURE_UNIT` env → HA `/api/config` → last-known DB value;
unit changes require restart + `UnitChangeBanner` ack. Settled design.
**#231 — the double conversion (landmark).** Symptom: typing `16` °C for
`min_setpoint` stored **141.44 °F** (16→60.8→141.44). Root cause: frontend
called `toStorage()` on the outgoing payload AND the backend's `_to_f()`
converted again. Both sides' tests passed because each asserted its own
(contradictory) contract — the frontend test expected a pre-converted body,
the backend test expected a display-unit body. Fix: conversion belongs to
the backend only; frontend forms hold display units and POST them raw.
Guards created: the 3-file `TEMPERATURE_FIELDS` parity system
(`backend/api/routes.py` dict ↔ `e2e/tests/temperature-fields.ts` ↔
`// @covers:` tags in `e2e/tests/temperature-units.spec.ts`) enforced by
`backend/tests/test_temperature_field_parity.py`, plus the dual-unit (F/C)
round-trip E2E matrix — the only test that would have caught it. Settled —
do not reopen; never call `toStorage`/`toStorageDelta` on an outgoing
payload.
**The °C sibling bugs (pre-launch audit, all HIGH/MEDIUM):**
- **#280** — the *engine* read climate-entity temps raw and sent °F
setpoints to metric HA (which reads them as °C → clamps to ~35 °C =
runaway heating hazard). Tests masked it by mocking pre-converted values.
Fix: `HAClient._setpoint_to_native()` converts outgoing setpoints/ranges;
climate reads normalized like sensors (`ha_client.py`, comments cite
"Issue #280"). Settled.
- **#281** — auto-detect could never return Celsius: HA returns
`unit_system` as an *object* (`{"temperature": "°C", ...}`), the code
compared `== "metric"`; AND `run.sh` defaulted blank to `"F"` which the
scheduler treats as a hard override lock. Both fixed
(`ha_client.py` reads `unit_system["temperature"]`; `run.sh` defaults
blank → auto). Unit-test fixtures had mocked the wrong shape — lesson:
mock the real payload shape. Settled.
- **#284** — MCP write tools bypassed the write-boundary conversion entirely
("set the bedroom to 21" in a °C home stored 21 °F ≈ −6 °C). Fix: MCP
tools convert via the shared helpers (`mcp_tools/_units.py`,
`units.to_f`). Settled.
- **#288** — banner dismiss was undone every 60 s tick; and the banner stuck
after the restart that applied the change. Fix: `ack_unit_change` records
the acknowledged HA unit; startup clears the flag when units match.
Settled.
- **#291/#292** — charts applied the **absolute** °F→°C conversion to
**deltas**: a 2 °F overshoot rendered "-16.7 °C"; degree-minutes were
mislabeled ×1.8. This is CLAUDE.md pitfall #4 exactly (absolute conversion
subtracts 32; deltas must not). Fixed in commit `e3a9473`. Settled.
- **#293** — `buildUnitContext(unit)` rebuilt every render → new function
identities → form-reset effects fired on unrelated re-renders, discarding
edits and even regressing saved values. Fix:
`useMemo(() => buildUnitContext(unit), [unit])` (`App.tsx`). Settled **for
that trigger only** — see #597, which closed the rest of the family. Do not
read this entry as "prop-derived form resets are solved".
- **#597** — the same clobber through the other two triggers the #293 memo
never touched. (a) `ThermostatCard`'s reset effect keyed on `config`
**identity**, and `load()` (a delete, a registration, an Eco suspend) hands
every surviving card a brand-new object with byte-identical content — so
editing card A and then removing card B silently discarded A's edit.
(b) The reset lived in a **passive effect**: React commits the DOM and then
schedules passive effects as a separate host task, so the reset could flush
*after* an input's `onChange` had queued its update, and a plain-object
`setForm` replaces a queued functional update. That is what made
`Thermostats.test.tsx` flaky — 8 of 400 isolated edit-then-save exposures,
every failure having the mount effect still pending when the query resolved.
Fix: re-derive on derived **content**, **during render**, with a
`lastDerivedForm` state cell + `sameDerivedForm` (`Object.is`, union of
keys, `FORM_UNOWNED_FIELDS` excluding the read-only `eco_suspend_until`).
Beware the near-misses: comparing derived-vs-**current form** does not fix
it (the user's edit is *why* they differ), and skipping only the effect's
first run still clobbers under `<StrictMode>`. Settled.
- **#600** — the *other* edit-clobber shape, filed out of the #597 sweep and
deliberately not the same mechanism: a **mount fetch racing typing**, with no
prop and no recurrence. `SensorStalenessCard` rendered its editable input
immediately from `useState<number | null>(null)` and filled it when
`getSensorStaleness()` resolved, so anything typed first was replaced. The
window opened at the exact moment the page-level gate cleared and the page
looked interactive. Two consequences beyond the lost keystrokes: the
`.catch` arm fabricated a hardcoded `30` with **nothing surfaced**, so an
operator running a non-default threshold saw `30` and one Save reset the
engine's #211 staleness guard to it; and `save()`'s `if (value === null)
return` made a Save inside the window a completely silent no-op (0 coverage
hits — a guard whose only job was papering over the race). Fix: the loading
gate CLAUDE.md pitfall #9 already prescribed — seed the state with the
backend's own default (never `null`), a separate `loading` cell starting
`true`, `.then` sets the value **and** clears the gate while `.catch` clears
the gate and posts a warning that the shown `30` is a default, and no input
exists until the gate clears. `RetentionSettings` (`Logs.tsx`) was the
precedent **for the loading gate only** — it had none of the `.catch` half
at the time, which is the bug #605 then filed and fixed. Two near-misses
the issue measured so nobody re-derives them: a `useState` dirty flag does
**not** work (the `[]`-deps effect closes over `dirty === false`, so the
value is still clobbered — it needs a ref), and a
"render, type, assert" test is **non-discriminating** because the mocked
promise may resolve before the typing; the test has to own the resolver.
Settled.
- **#605** — the `.catch` half of #600's pattern, missing in the very
component #600's entry (and CLAUDE.md pitfall #9) cited as its precedent.
`RetentionSettings` (`Logs.tsx`, the Logs page's Retention tab) had the
loading gate right — seeded with the backend's own `7`/`30`, a separate
`loading` cell, no input until it cleared — but its `.catch` cleared the
gate and **recorded nothing**, so a form showing 7/30 after a failed
`GET /api/settings/log-retention` was pixel-identical to one showing a
genuinely-default install. Save stayed live and `save()` POSTs the whole
`form`, so one click wrote 7/30 over a configured 90/365 — and
`Scheduler._purge_old_logs` is a hard `DELETE` that runs on a 24 h interval
and again on every startup, so the event logs and cycle history outside the
new window are gone with no undo (and the operator gets a green "Saved!").
Editing only one field still shipped the other's fabricated default. Blast
radius is history and diagnostics only — nothing here reaches HVAC control,
which is why this was Medium where its #600 sibling (which could reset the
#211 staleness guard) was higher. Fix: the sibling's shape, not a second
one — a dedicated `loadFailed` cell set in `.catch`, the `cancelled`
cleanup flag, and a warning naming both fabricated numbers. Settled
decisions, do not re-fight: `loadFailed` is deliberately **not** the
component's `error` cell (`save()` clears that one, and the warning must
survive the Save it exists to warn about); **only a successful write**
clears it (a rejected Save leaves the numbers fabricated); and Save is
deliberately **warn-only** rather than blocked or confirm-gated, matching
`SensorStalenessCard` — the issue left that judgement to the implementer
and the test asserts the whole-form POST body rather than pretending the
write was blocked. Guards: `Logs.coverage.test.tsx` pins the warning
sentence as one text node (copy duplicated in the test so a reword is a
deliberate two-file change), its survival across a failed Save, its
disappearance after a good one, the POST body, and that the inputs lock
while the write is in flight. One near-miss worth keeping: an
**unmount-ordering** test does not pin the `.catch` cancellation guard —
after unmount both setStates are no-ops either way, so it passes with the
guard deleted (#602's "tests that cannot fail" class). The ordering that
discriminates is `<StrictMode>`'s cancelled first GET **rejecting after**
the live second GET landed a real 90/365: unguarded, the page then paints
"the numbers below started as the 7-day … defaults" over correctly-loaded
values. Considered and deliberately not taken: POSTing only the fields the
operator actually edited while `loadFailed` is set (both `setLogRetention`'s
type and `set_log_retention` in `routes.py` accept a partial body). It would
soften the collateral clobber, but it invents a second pattern the sibling
does not have, makes the warning's "Saving overwrites both fields" false,
and is a behaviour change #605 did not sanction — file it as its own issue
if the whole-form write ever bites someone. Settled.
**Consolidation (#251):** the four helpers moved from `routes.py` into
`smart_vent/backend/units.py` (`to_f`, `delta_to_f`, `from_f`,
`from_f_delta`), also used by HA ingest and MCP. (CLAUDE.md said `routes.py`
until corrected on 2026-07-05 — trust `units.py`.)
**Sentinel lesson (`.jules/sentinel.md`, 2026-05):** setpoint bounds
validation must run on the **normalized °F value** (40–90 °F), not the raw
display input — bounds on an unknown-unit raw value are meaningless. Pinned
by `tests/integration/test_sentinel_validation.py`. Settled.
---
## Campaign 3 — Safety hardening wave (#208–#213, #267, #367)
A deliberate pre-launch review: "which protective interlocks are missing or
untested?" Every guard below is a **one-way ratchet** — never weaken one to
fix a comfort complaint (inferred house rule; consistent with every fix in
this campaign).
**#208 — no short-cycle protection.** Grep for `min_runtime|lockout|compressor`
returned zero hits; cycles could start/stop within one tick. Fix:
`ThermostatConfig.min_cycle_runtime_min` / `min_cycle_offtime_min` — hold a
finished cycle open until minimum runtime; refuse restarts during off-time
lockout. Pinned by `tests/integration/test_short_cycle_protection.py`.
Follow-up **#215**: the "trigger changed" teardown tore down a healthy cycle
for a same-direction target change (68→72 mid-heat = full furnace
stop/start, then the new lockout blocked reheat). Fix: **update in place**
when direction is unchanged; teardown reserved for direction flips (see the
`#215 in-place update` comments in `cycle_engine.py`, pinned by
`test_trigger_change_characterization.py`). Settled.
**#209 — AC could run in cold weather.** Outdoor temp was plumbed but used
only for diagnostics; a mixed-vote tie ("ties go to cooling") could start a
compressor at 40 °F outside (coil icing / slugging). Fix:
`cooling_lockout_below_f` (nullable), enforced at mode-inference; pinned by
`tests/integration/test_outdoor_cooling_lockout.py`. Settled.
**#210 — the last-vent bypass could dead-head the air handler.** The
deliberate `min_open_vents` bypass for the final closing vent must always be
paired with immediate termination + reopen. Fix/pin:
`min_open_vents_fraction` (default 0.333 of zone vents) and
`tests/integration/test_airflow_floor_bypass.py` (ordered close→reopen
assertions). Settled.
**#211 — stale sensors drove control.** Battery Zigbee sensors drop off the
mesh but keep their last numeric state; a 3-h-old "70 °F" closed vents on a
80 °F room. Fix: `get_numeric_state` freshness check against
`last_updated` (missing timestamp = infinitely stale), stale-sensor warning
events + `StaleSensorsBanner`; pinned by
`tests/integration/test_sensor_staleness.py`. Settled.
**#212 — the cycle timeout had no end-to-end test.** "A protective
interlock with no test is one that can silently regress." Fixed with
`tests/integration/test_cycle_timeout.py` (over-timeout terminates with
`ended_reason="timeout"`, under-timeout does not). Settled.
**#213 — unsafe defaults.** `max_vent_closed_min=0` and
`reconciliation_interval_min=0` shipped OFF. Principle adopted: *defaults
must be thermodynamically sound; don't rely on the user*. As of this
writing (re-verified 2026-09-01, v0.35.0) both fields default back to `0`
(off) in `models.py`, documented there as deliberate — `max_vent_closed_min`
"0 = no limit (feature off by default)", `reconciliation_interval_min`
"0 = disabled" — see `plenum-config-and-flags` for the current default
catalog and guards. Settled.
**#267 — unavailable thermostat suspended ALL safety monitoring.**
`_do_tick` returned early on unavailability, skipping timeout, the
closed-vent watchdog, and reconciliation — HVAC running, vents closed,
forever. The docstring *claimed* an unavailability abort existed; the code
path didn't (`_abort_cycle(safe_close=True)` was dead code). Fix: tolerate
transient blips, abort after `unavailable_abort_after_min` (default 5)
consecutive unavailable ticks, reopen vents (cover entities are independent
of the climate entity). Pinned by
`tests/integration/test_thermostat_health.py`. Settled.
**#367/#368 — comfort envelope not enforced with zero active rooms
(production incident, 2026-06-28).** After a vacation hold expired with no
room demand, the upstairs zone sat at 81 °F against a 77 °F `max_setpoint`
for 2+ hours: the cap lived only in `_suppression_vote()`, reachable only
from the active-room path, and `_do_tick` exits early on
`active_rooms=0`. Fix is two-layer: `_add_safety_rooms` pulls any
sensor-breaching room into the cycle with `source="safety"` (target one
deadband inside the bound, for hysteresis), plus a thermostat-ambient
backstop for zones with no readable room sensor. Pinned by
`tests/integration/test_safety_room_protection.py` and
`test_safety_setpoint_backstop.py`. Settled.
---
## Campaign 4 — Min-runtime hold / overflow conditioning arc (#237 → #305)
**#237 — vent thrashing during the hold (+ the overflow idea).** When rooms
hit target before minimum runtime, `_enter_min_runtime_hold()` re-opened
vents once — but the next `_monitor_rooms` tick had no idea a hold was in
progress and closed them again: open→close→open against a running
compressor. Fix: the hold became **persisted state**
(`CycleLog.in_min_runtime_hold`, `models.py`) that gates the close loop.
Same issue birthed **overflow conditioning**: surplus runtime is dumped into
non-active rooms via three candidate tiers — full design in
`docs/overflow-conditioning.md` (the doc of record; don't re-derive tiers
from code). Settled.
**#254 — overflow rooms were invisible in cycle history.** Their temps were
buried in vent-event reason strings. Fix: `room_cycle_states.role`
discriminator (`'active'` | `'overflow'`; note `'safety'` is a *source* on
`ActiveRoom`, not a role — `cycle_engine.py:3013`), one collapsed
row per overflow room, surfaced in the Logs cycle detail. Settled.
**#268 — the anti-thrash gate had zero real coverage (test-quality
lesson).** `test_hold_gate_skips_close_loop` passed via the wrong branch —
its setup triggered hold-*entry*, so the continuation gate never executed;
coverage was green while the #237 guard was unguarded. Fix: a test where a
room drifts off target *during* the hold. Lesson: **verify which branch a
safety test actually executes** (coverage line data, not test names).
Companion #269 covered the untested in-tick system-disabled/vacation abort
backstops. Settled.
**#300 — ON CONFLICT dropped `role`/`joined_at`.** An overflow room promoted
to active silently kept `role='overflow'` (excluded from metrics, mislabeled
in UI); the restore path leaked `_overflow_room_states` into the next cycle.
Fix: upsert sets `role=excluded.role`,
`joined_at=COALESCE(existing, excluded)` (`db.py`); pinned by
`tests/test_room_cycle_state_upsert.py`. Settled.
**#305 — overflow candidate selection ignored `deadband_override`** (the
per-room deadband from #277). Fix: `_effective_deadband(room, tc.deadband)`
applied inside the candidate loop (`room_manager.py`). Settled.
---
## Campaign 5 — The pre-launch audit wave (#280–#305, ~25 latent bugs)
One systematic front-end + back-end logic audit (2026-06) filed ~25 issues in
a week; all are fixed. The °C ones (#280/#281/#284/#288/#291/#292/#293) are
in Campaign 2; #300/#305 in Campaign 4. The rest, one line each — all
**settled** unless noted:
| # | Symptom → fix (guard) |
|---|---|
| #282 | MCP server crashed at startup: `@server.tool()` doesn't exist on the low-level `mcp.server.Server`; coverage exclusions meant no test ever imported it → rewritten on `FastMCP` (`mcp_server.py`), smoke-tested by `tests/test_mcp_server.py`. (Post-mcp-SDK-v2-migration, this class is `MCPServer` — v2's rename of `FastMCP` — not a further rewrite of the #282 fix.) Lesson: a coverage-excluded module is an untested module. |
| #283 | `connectWS` reconnected after intentional close → zombie sockets, N+1 duplicated feed handlers → liveness flag in the closure (`api.ts`, `let closed = false`). |
| #285 | Deleting a thermostat's last room dropped the engine mid-cycle, leaving HVAC at the overshoot setpoint forever → `force_abort(reason="thermostat removed")` + close logs before `del` (`scheduler.py`). |
| #286 | `_tick_engine` pre-tick DB calls sat outside the try; a locked DB silently killed a zone with zero log output → whole body inside try. |
| #287 | Presence was edge-triggered only; mmWave sensors holding continuous `on` lost their holdover and the occupied room went idle → holdover refreshed from current sensor state each tick (`tests/integration/test_presence_holdover_refresh.py`). |
| #289 | Per-room time-to-target averaged cycles outside the date range (filter in `LEFT JOIN ON`, CASE didn't gate on join success) → `cl.id IS NOT NULL` gate (`db.py`). |
| #290 | Overshoot histogram mixed thermostat-level samples into every room's extremes → per-(cycle,room) fallback only when no room-level samples. |
| #294 | Vacation modal `datetime-local` min computed in UTC blocked valid times west of UTC → local-component string via `toDatetimeLocalString` (`VacationModeModal.tsx`). |
| #295 | Several numeric config fields stored unvalidated (a string `cycle_timeout_hours` then crashed every tick — silently, per #286) → `_validate_thermostat_numeric()` in `routes.py`. Note: `@request_schema` decorators remain documentation-only by design; handlers self-validate. |
| #296 | Idle in-deadband engine re-sent setpoint=ambient every 60 s (cloud-thermostat rate-limit hazard) → skip within `_SETPOINT_DRIFT_TOLERANCE_F` (`tests/integration/test_idle_setpoint_churn.py`). |
| #297 | HA WS client busy-looped on clean close and could hang forever in an unguarded handshake → min reconnect delay, `heartbeat=`, `wait_for` on handshake (`ha_client.py`). |
| #298 | `/api/backup` leaked a full DB temp copy on every download (disk + data exposure) → temp file unlinked on the success path too (`routes.py`). |
| #299 | `event_log` trim off-by-one deleted the oldest row even under the cap (`id <=` → `id <`), pinned by `tests/test_event_log_trim.py`. |
| #301 | Vent timeline printed naive-UTC verbatim → localized (commit `6a81447`). |
| #302 | Offset pagination duplicated feed rows as new events arrived → de-dupe by seen ids on merge (`Logs.tsx`). |
| #303 | Dev-mode "Clear" repopulated 3 s later → cleared-before-id watermark (`DevMode.tsx`). |
| #304 | Fire-and-forget startup tasks held only by weak refs (GC could eat unit resolution) → `Scheduler._bg_tasks` strong-ref set with done-callback discard. |
Meta-lesson from this wave: the audit found bugs precisely where tests
mocked the convenient shape (#280, #281, #282) or where coverage was
excluded/green-but-wrong (#268, #282). When auditing, start from "what does
the real payload/state look like."
---
## Campaign 6 — E2E / visual-regression campaign (#175 → #182 → #369)
**#175** introduced Playwright + docker-compose visual regression with
goldens committed to git. It promptly proved **flaky beyond rescue by
masking** (**#182**): live temps, countdowns, engine feeds, and Recharts
float jitter differed between the update pass and the verify pass;
per-selector masks were whack-a-mole. The suite was **disabled** (PR #180)
for months — the dead end to remember. The rescue (these became the project's
hard E2E rules, since restated in CLAUDE.md and superseded in mechanism by
#366 below):
- **Determinism by construction**: `isCI` (`VITE_APP_VERSION === "CI"`) +
`<Frozen>` in `frontend/src/ci.tsx` — freeze anything that changes between
two renders of the same fixture; never freeze static fixture values. One
`import.meta.env` branch, tested both ways in `ci.test.tsx`.
- **The 158 °F golden bug**: HA YAML with no `unit_system` defaults to
metric, so a `target_temp: 70` fixture was 70 °C and Plenum's *correct*
normalization displayed 158 °F. Fix: the HA fixture pins
`unit_system: us_customary`; the matrix varies only Plenum's display unit.
Dual-unit goldens (`-Fahrenheit-`/`-Celsius-` filenames) catch conversion
regressions in both directions.
- Golden auto-commit *needed* `max-parallel: 1` (legs pushed to the same ref)
and a forced checkout — until #366 replaced that design with parallel legs
+ a single fan-in commit job; high-DPI mobile needs a per-spec
`maxDiffPixels` bump (metrics: 800) instead of masks.
**#329** — a *distinct* flake: `global-setup.ts` room-creation clicks raced
a list re-render ("element detached from the DOM"), reddening unrelated PRs
(#315/#324/#326). Fixed alongside the #330-wave CI work (commit `fbe9801`).
Lesson: not every red E2E is #182 — check *where* it failed first.
**#366** moved the visual-regression suite **into
`container-ci.yml`** (parallel goldens-F/goldens-C legs + a `commit-goldens`
fan-in job) and deleted `e2e.yml`. (CLAUDE.md referenced the standalone
`e2e.yml` until corrected on 2026-07-05 — the suite lives in `container-ci.yml`.)
**#369** — the fan-in job couldn't push regenerated goldens:
`actions/checkout` leaves detached HEAD, so `git push origin "$BRANCH"` had
no local refspec after the rebase. Fix: `git push origin HEAD:"$BRANCH"`
(commit `c26d395`). Settled — if golden commits fail with "src refspec …
does not match any", this is it; don't re-invent the checkout dance.
---
## Campaign 7 — CI build-once consolidation (#5, #43, #141, #330–#337)
**#5** (early): PR builds and GHCR pushes split into separate jobs so PR
runs have no `packages: write` at all — structural enforcement over a
runtime `if:`. Settled principle: permissions, not conditionals.
**#43**: release-PR contributor lookup leaked a GitHub 422 JSON error body
verbatim into CHANGELOG/PR bodies — a three-bug chain (`+`/`[` in bot
emails; `gh api` writes 4xx to *stdout*; `$(A || B)` captures A's stdout
anyway). Fix in `release-pr.yml`: skip bot emails, gate on exit code with
`if RAW=$(...)`, `jq '// empty'`. Settled — reuse that exact pattern for any
`gh api` in a workflow.
**#141**: nine releases in one day (v0.9.4–v0.9.12) from a
merge→break→hotfix-direct-to-main loop. Outcome: branch protection,
`/api/healthz` + real smoke test, single-source deps in `pyproject.toml`,
`RELEASE.md`. (The `validate-release.yml` manual dry-run workflow was also
added at the time, but was removed 2026-07 once `container-ci.yml` grew its
own required, automatic checks on release PRs — everything the dry-run caught
was already caught there.) Settled process (details owned by
`plenum-ci-and-release`).
**#330–#333 → #334**: the same image was built ~4× per PR (one under QEMU
arm64) with no shared cache. Consolidated into `container-ci.yml`'s single
`build` job pushing `ghcr.io/<repo>:ci-<sha>`, reused by smoke, round-trip
F/C, and (post-#366) the visual legs. **#337** follow-ups: fork-PR artifact
handoff (`docker save`/`load` — fork tokens can't push), nightly `ci-*` tag
pruning (`.github/workflows/ci-image-cleanup.yml`), and release PRs building
the real `:<version>` once in container-ci (required check is now **Build
(PR validation)**, not the old docker.yml job). Settled.
---
## Campaign 8 — Infra & security
**Security alert #4 — CWE-209 exception detail in API responses.** Route
handlers embedded `str(exc)` in error bodies. Rule (now in CLAUDE.md, and
the top of every reviewer's checklist): `log.exception("…context…")` then a
generic `error("Failed to <action>", status=5xx)` — never `{exc}` in a
response. `tests/integration/test_security.py` exists; grep before merging
any new `except` in `routes.py`. Settled — this is a hard rule, not a style
preference.
**#298 / #295** (see Campaign 5 table) were the other security-adjacent
audit finds: backup temp-file data left on disk, and unvalidated numeric
config inputs.
**MCP (#372/#375, shipped v0.22.0):** MCP now runs over HTTP on port 9099
behind a settings toggle (503 until enabled). Its earlier failures (#282
startup crash, #284 unit bypass) are settled above. Authentication for the
web UI and MCP endpoint was the project's next open problem after this —
**#373 has since shipped** (plus OIDC single sign-on, #464); see
`plenum-auth-campaign` for the design history and `docs/auth.md` for current
behavior. Settled.
---
## Campaign 9 — Notable reverts
**Commit `c65d35d` — "Revert MCP port to null" (#387, reverting #385,
2026-07-01).** #385 had default-mapped `9099/tcp: 9099` in `config.yaml`
because the port row wasn't appearing in existing installs' HA
**Configuration → Network** section. The revert's finding: the row was
missing only because **HA Supervisor re-reads an add-on's `config.yaml`
(including ports) when the version changes on Update** — no default mapping
needed; a `null` mapping still shows an empty, fillable row after the next
version bump. So the port is back to `null` (unpublished by default) and
`docs/mcp.md` documents the update-to-see-the-row behavior. At the time,
the MCP endpoint was unauthenticated (#373 was still open), so not
auto-publishing it was also the safer default (that rationale is inferred;
the documented one is the Supervisor behavior) — #373 has since shipped, but
the port-mapping decision itself doesn't depend on that. **Settled — do not
re-add a default host mapping for 9099/tcp**; if a user reports "no
9099/tcp row", the answer is "update the add-on", not a config.yaml change.
Honorable mention: `min_setpoint`/`max_setpoint`'s original meaning
(overshoot-setpoint clamps) was deliberately **abandoned** in #32 and the
fields repurposed as the comfort envelope — any proposal to clamp the
commanded setpoint with them again is re-fighting #32.
---
## Campaign 10 — Platform mishaps (Home Assistant quirks)
**#74 — blank screen under HA ingress.** Ingress serves at
`/api/hassio_ingress/<TOKEN>/`; React Router had no `basename`, so no route
matched. `api.ts` already computed the ingress base — the router didn't.
Fix: same regex feeds `<BrowserRouter basename=…>` (`main.tsx`). Settled —
any new absolute frontend path must survive the ingress prefix.
**#92 — add-on data "missing" on HAOS.** Docs pointed at paths that don't
exist; the real bind mount is
`/mnt/data/supervisor/addons/data/<repo_id>_<slug>/`, invisible to the
Samba `addon_configs` share because the manifest only mapped `data`. Fix:
`config.yaml` adds `addon_config:rw` with `DATA_DIR=/config` (config.yaml:43,
run.sh:82) + a
one-shot `run.sh` migration; docs corrected with `docker inspect` recipes
(`docs/backup-restore.md`). Settled.
**#89 — rename to Plenum.** DB migrated `flair.db` → `app.db` via one-shot
startup rename; the **add-on slug stayed `flair_replacement` on purpose**
(changing it orphans every install's data dir) and the GitHub repo name
stayed. Settled — do not "clean up" the slug.
---
## Provenance and maintenance
Mined 2026-07-04 from the closed-issue corpus (48 bug + 58 feature bodies),
the git log, repo `CLAUDE.md`, and direct code verification at v0.22.1.
Guard/test names above were verified to exist on disk on that date. Volatile
facts and how to re-check them:
- Guard tests still present:
`ls smart_vent/backend/tests/{test_temperature_field_parity.py,test_event_log_trim.py,test_room_cycle_state_upsert.py} smart_vent/backend/tests/integration/{test_short_cycle_protection.py,test_cycle_timeout.py,test_sensor_staleness.py,test_thermostat_health.py,test_safety_room_protection.py,test_safety_setpoint_backstop.py,test_airflow_floor_bypass.py,test_outdoor_cooling_lockout.py,test_presence_holdover_refresh.py,test_sentinel_validation.py,test_idle_setpoint_churn.py}`
- Conversion helpers live in `smart_vent/backend/units.py` (post-#251):
`grep -n "def to_f" smart_vent/backend/units.py`
- Visual-regression E2E lives inside `.github/workflows/container-ci.yml`
(post-#366; no `e2e.yml`): `ls .github/workflows/`
- MCP port default is `9099/tcp: null` (post-`c65d35d`):
`grep -n "9099" smart_vent/config.yaml`
- #367 backstop still wired: `grep -n "_add_safety_rooms" smart_vent/backend/engine/cycle_engine.py`
- Open issues referenced as "partially addressed": #21
(versioned DB migrations), #23, #25, #173 — re-check with
`gh issue list --state open` before citing them as still open. (#373 auth
shipped since the initial mining pass — re-verified 2026-09-01 against
v0.35.0 — and is no longer listed here; see `plenum-auth-campaign`.)
- The local clone is shallow (~51 commits as of 2026-07-04); hashes cited
(`c65d35d`, `c26d395`, `e3a9473`, `6a81447`, `fbe9801`, `bd560de`,
`a0e9d04`) are within it; older incidents are cited by issue number only.
- Spot-checked 2026-09-01 against v0.35.0: guard tests in the list above
still on disk, `9099/tcp: null` unchanged, `_add_safety_rooms` still
wired. Three "current state" claims had drifted and were corrected in
place: the Campaign 8 MCP paragraph (auth was open, now shipped), the
#213 entry (`max_vent_closed_min`/`reconciliation_interval_min` default
back to `0`/off in current `models.py`, not non-zero), and the #282 row
(the class is now `MCPServer`, mcp SDK v2's rename of `FastMCP`).
Is this your skill, or is something wrong with this listing? Request removal or report an issue. Author removals are honored within 72 hours.
No comments yet. Be the first to comment!