diff --git a/CLAUDE.md b/CLAUDE.md
index 9f0c65a8..96854362 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -22,7 +22,7 @@ See `docs/architecture.md` for system design. This file contains only rules and
- **Robust to any input.** A running device tolerates any sequence of UI actions or API calls: add, delete, replace, or reconfigure any module in any order, at any grid size, and it keeps running. Degraded or idle is acceptable; crashed is not. This robustness is a defining strongpoint of projectMM, and it's guarded by the test framework, not by hope: a discovered crash drives a new test that pins the fix (see the Hard Rule). Out of scope: power loss, malformed OTA, brown-out, and other physical/electrical faults the firmware can't intercept; this principle is about what the software accepts as input.
- **No reboot to apply a configuration change.** Every setting takes effect live, on the next render tick — change a pin map, a strand length, an output protocol, a mic pin or rate, anything, on a running device and it just works. There is no init-once-at-boot step, and no *config* change requires a restart, which sets projectMM apart from most LED-controller firmware (where a pin or protocol change means a reboot). Like robustness, this is a defining strongpoint, and it falls out of the architecture for free rather than being hand-built per module: any control whose change reshapes derived state routes through the generic `onBuildState()` rebuild sweep, so drivers, the audio peripheral, effects, layouts, modifiers and network I/O all inherit it. When adding a feature, don't reach for a reboot/restart to apply config; make the change live. Full mechanism + rationale: [architecture.md § Live reconfiguration](docs/architecture.md#live-reconfiguration-every-change-applies-without-a-reboot). The one exception is what you'd expect: a *firmware* OTA flash swaps the binary and needs the usual power cycle — that's not a configuration change, and (like power loss and brown-out) it's the same physical-fault boundary the robustness principle draws.
- **Domain-neutral core.** Separate core infrastructure from the light domain as much as practical. When mixing is necessary, use domain-neutral naming so the code stays open to future separation.
-- **Present tense only.** Code, comments, and documentation describe the system as it is now. No changelogs, no roadmaps. History lives in git commits. This bans not just future-tense ("will be", "planned") but **absence-narration**: phrases like "no longer", "anymore", "formerly", "used to", "X was removed", or "there's no longer a Y" describe a *change from a past state* a present-tense reader never saw — state what *is*, not what stopped being. (The test: "there is no MCLK pin" is a present-tense *property* — keep it; "there's no SET_DEVICE_MODEL RPC anymore" narrates a removal — cut it, just describe the path that exists.) Exceptions: `docs/backlog/` (forward-looking) and `docs/history/` (backward-looking) — and `decisions.md` lessons, which legitimately contrast before/after because the contrast *is* the lesson.
+- **Present tense only.** Code, comments, and documentation describe the system as it is now. No changelogs, no roadmaps. History lives in git commits. This bans not just future-tense ("will be", "planned") but **absence-narration**: phrases like "no longer", "anymore", "formerly", "used to", "X was removed", or "there's no longer a Y" describe a *change from a past state* a present-tense reader never saw — state what *is*, not what stopped being. (The test: "there is no MCLK pin" is a present-tense *property* — keep it; "there's no SET_DEVICE_MODEL RPC anymore" narrates a removal — cut it, just describe the path that exists.) Exceptions: `docs/backlog/` (forward-looking) and `docs/history/` (backward-looking) — and `docs/adr/` + `lessons.md`, which legitimately contrast before/after because the contrast *is* the record (an ADR's Context is the situation that forced the decision; a lesson's before-state is what makes it a lesson).
## Hard Rules
@@ -56,7 +56,7 @@ The design rationale for each rule below lives in [docs/architecture.md](docs/ar
Then check the recommendation against [§ Principles](#principles) (minimalism, data over objects, concrete first) and propose it as a question, not a fait accompli. The product owner picks; the agent implements only what was picked. If the picked option turns out to need a follow-up change (e.g. an updated naming convention to make the new layout consistent), surface that *before* starting the move so it's a single coherent refactor, not three round-trips.
-**Plan before implementing.** Use `/plan` mode before every feature. Review plans for: unnecessary files, inheritance where structs suffice, modifications outside the relevant directory. Reject and regenerate bad plans. **Save every approved plan** to `docs/history/plans/` named `Plan-YYYYMMDD -
.md` (ISO-8601 date order so the directory sorts chronologically, e.g. `Plan-20260620 - Improv-as-REST.md` for 2026-06-20), as the first implementation step. The plan is the design record that complements `decisions.md` (the lesson record): the plan says what we set out to build and why; decisions.md captures what we learned doing it. **These saved plans are a reference archive for the product owner — agents WRITE a plan when creating one, but do NOT read the existing plan files for context unless the product owner explicitly points to one** (they're under the "Never automatically" rule below alongside the rest of `docs/history/`). Plans are **kept, not pruned** — they are the permanent design-intent record. When a plan's design ships (or doesn't), mark its outcome in the filename with a trailing parenthetical — `… (shipped).md` once it lands, `… (attempted, abandoned).md` if it was tried and dropped — so the directory shows at a glance what's done; an unmarked plan is still in flight. The **one exception** to "kept, not pruned": a multi-phase effort's per-phase plans may be **consolidated into a single `… (shipped).md` record** once the *whole* effort lands, provided that record preserves the design-intent arc (what each phase set out to do + the outcome, including any dead-ends). This isn't losing design intent — it's the same subtraction the rest of the process rewards, applied to a set of plans whose story is now one story. Consolidate only *shipped/settled* phases, never in-flight ones.
+**Plan before implementing.** Use `/plan` mode before every feature. Review plans for: unnecessary files, inheritance where structs suffice, modifications outside the relevant directory. Reject and regenerate bad plans. **Save every approved plan** to `docs/history/plans/` named `Plan-YYYYMMDD - .md` (ISO-8601 date order so the directory sorts chronologically, e.g. `Plan-20260620 - Improv-as-REST.md` for 2026-06-20), as the first implementation step. The plan is the design record that complements `lessons.md` (the lesson record): the plan says what we set out to build and why; lessons.md captures what we learned doing it. **These saved plans are a reference archive for the product owner — agents WRITE a plan when creating one, but do NOT read the existing plan files for context unless the product owner explicitly points to one** (they're under the "Never automatically" rule below alongside the rest of `docs/history/`). Plans are **kept, not pruned** — they are the permanent design-intent record. When a plan's design ships (or doesn't), mark its outcome in the filename with a trailing parenthetical — `… (shipped).md` once it lands, `… (attempted, abandoned).md` if it was tried and dropped — so the directory shows at a glance what's done; an unmarked plan is still in flight. The **one exception** to "kept, not pruned": a multi-phase effort's per-phase plans may be **consolidated into a single `… (shipped).md` record** once the *whole* effort lands, provided that record preserves the design-intent arc (what each phase set out to do + the outcome, including any dead-ends). This isn't losing design intent — it's the same subtraction the rest of the process rewards, applied to a set of plans whose story is now one story. Consolidate only *shipped/settled* phases, never in-flight ones.
**Use `uv` for every Python invocation.** Never type `python` or `python3` directly; always go through `uv run` (e.g. `uv run moondeck/build/build_desktop.py`, `uv run python -c "…"`). This applies to shell commands, CMake `add_custom_command` / `execute_process`, documentation examples, and anything that shells out. In CMake, resolve `find_program(UV_EXECUTABLE NAMES uv REQUIRED HINTS "$ENV{USERPROFILE}/.local/bin" "$ENV{HOME}/.local/bin")` once and use `${UV_EXECUTABLE} run python …` thereafter. Reason: uv manages the project venv and is the project standard ([moondeck/MoonDeck.md](moondeck/MoonDeck.md)); bare `python3` isn't on PATH on Windows (and macOS Python Launcher pops a Store prompt). If you catch yourself about to type `python`, stop and prefix with `uv run`.
@@ -153,7 +153,7 @@ The "this is now trunk" moment. Where the wider hygiene checks live, because onc
1. All commit gates passed on every commit in the PR.
2. PR feedback addressed (CodeRabbit + human review).
-3. **Carry forward lessons**: if the branch produced a hard-won lesson, a proven pattern, or a non-obvious decision worth keeping, note it in `docs/history/decisions.md` as part of the branch's commits, so the lesson lands in `main` with the code that proved it. Do this on the branch before the merge commit.
+3. **Carry forward lessons**: if the branch produced something worth keeping, record it in the right home as part of the branch's commits, so it lands in `main` with the code that proved it (do this on the branch before the merge commit). A **debugging lesson or gotcha** (a bug, its cause, the fix) goes in `docs/history/lessons.md`. A genuine **architectural decision** (chose approach A over B/C) is a new immutable ADR in `docs/adr/` (`NNNN-.md`, Nygard format; supersede, never edit). A lesson that has hardened into a **general rule** goes to CLAUDE.md or `coding-standards.md`, not a history file.
4. **Documentation sync**: every new module / control / API endpoint has matching docs (`docs/moonmodules/*.md`, `docs/testing.md`, `docs/architecture*.md`).
5. **Reviewer agent**: trigger this **first** so it runs while the other checks (docs sync, carry-forward lessons, conditional gates) proceed in parallel. Opus reviewer over the **whole branch diff** (`git diff main...HEAD`). Scope: domain boundary, **common patterns first** (flag any new convention (naming scheme, file shape, build flag, control mechanism, UI affordance) that isn't recognisable from a widely-used project / framework / canonical resource; bespoke choices must carry a stated reason at the introduction site, see the principle in § Principles), **unnecessary abstractions** (no-op / pass-through wrappers that only rename or re-namespace an existing function, single-call-site indirection that would read clearer inlined, names that obscure where the real code lives), **duplicated patterns** (same logic in multiple places that belongs in a base class or shared function), hot-path violations, spec conformance, bloat, platform boundary. Architectural drift is more visible across N commits than across one: "three commits each added a wrapper" reads as a pattern that one commit hides. Findings either get fixed in additional branch commits before merge, or are accepted with a one-line reason in the PR description. CodeRabbit complements this: CodeRabbit handles line-level bugs in the PR; the Reviewer agent handles architectural drift.
6. **PR title and description**: review and update if the work done differs from what the PR title/description says. The description is the permanent record of what landed and why; it should reflect the actual diff, not the original intent.
@@ -179,7 +179,7 @@ The "end users will use this" moment. Per-release criteria are defined by the pr
5. **Changelog / release notes**: drafted in the GitHub release body. Skip only for unreleased pre-1.0 tags.
6. **Cross-platform smoke**: run scenarios on every supported platform (today: PC + ESP32; later: + Teensy, RPi), if the release claims new platform support or the version bumps a major or minor.
-7. **Principles audit**: sweep `docs/` (except `docs/backlog/` and `docs/history/`) and `src/` for forward-looking language ("roadmap", "will be", "planned", "in the future", "currently lacks", `TODO`, `FIXME`) and other violations of § Principles. Acceptable hits carry a one-line justification; the rest get rewritten present-tense or moved to `docs/backlog/` / `docs/history/`. The reviewer agent can run this end-to-end. Skip only for releases where the diff against the previous tag is doc-empty.
+7. **Principles audit**: sweep `docs/` (except `docs/backlog/`, `docs/history/`, and `docs/adr/`) and `src/` for forward-looking language ("roadmap", "will be", "planned", "in the future", "currently lacks", `TODO`, `FIXME`) and other violations of § Principles. Acceptable hits carry a one-line justification; the rest get rewritten present-tense or moved to `docs/backlog/` / `docs/history/`. The reviewer agent can run this end-to-end. Skip only for releases where the diff against the previous tag is doc-empty.
What the agent reads:
- Always: `CLAUDE.md`, `architecture.md`
@@ -201,9 +201,12 @@ docs/
backlog-core.md ← to-build list, core / infrastructure domain (+ UI)
backlog-light.md ← to-build list, light domain (drivers, effects, preview, sensors)
backlog-mixed.md ← to-build list, items spanning both domains
+ adr/ ← architecture decision records (Nygard format; immutable, superseded-not-edited)
+ README.md ← ADR index
+ NNNN-.md ← one decision each (Status / Context / Decision / Consequences)
history/ ← backward-looking: accumulated wisdom
README.md ← index: what's here + cross-repo trends + digest prompt
- decisions.md ← actions, lessons, proven patterns
+ lessons.md ← debugging lessons + gotchas (bug → cause → fix; pruned as absorbed)
plans/ ← approved feature plans (Plan-YYYYMMDD - .md; PO reference, agents don't auto-read)
*-inventory.md ← prior-project surveys (v1, v2, moonlight)
.md ← friend-repo monthly activity digests (FastLED, WLED, …)
diff --git a/docs/adr/0001-persistence-pod-memcpy-not-json.md b/docs/adr/0001-persistence-pod-memcpy-not-json.md
new file mode 100644
index 00000000..88102e59
--- /dev/null
+++ b/docs/adr/0001-persistence-pod-memcpy-not-json.md
@@ -0,0 +1,18 @@
+# 1. Persist POD module state with memcpy, not JSON
+
+Status: Accepted
+
+## Context
+
+Plan-09 attempted ~1700 LOC of JSON-based persistence for module state. It was fully abandoned. The premise "persistence is JSON" was assumed without justification: neither human-readability nor manual editability were real requirements. The JSON design spawned ~15 helpers (`rebuildControls`, `LoadAllFn`, `applyNode`, `serializeNode`, `cleanupTmpLeafCb_`, and more), which was the system signalling the design was too elaborate for the job. It forced a Scheduler reorder (3→5 phases) that bred secondary bugs (a duplicate-children bug, a MAC→deviceName guard, multiple "device shows nothing" failures), and needed five defensive null guards that masked an allocate-new-before-free fragmentation invariant.
+
+## Decision
+
+Persist POD module state with a single `memcpy(file, this + sizeof(MoonModule), classSize - sizeof(MoonModule))`, loading it back before any module's `setup()` / `onBuildControls()` by memcpy into member memory directly. Plan-10 took this path and shipped.
+
+## Consequences
+
+- Save and restore are one line each; no serializer/deserializer helper sprawl.
+- Loading before `setup()` means no Scheduler phase reorder and none of its secondary bugs.
+- POD-only: state is a flat memory image, so there is no schema-versioned migration across a struct-layout change (a future need would be its own decision, not a reason to pay for JSON now).
+- The lesson that generalised: question a format premise before building to it; suspicious helper proliferation is a design smell; fix an invariant, do not paper it with per-call-site guards.
diff --git a/docs/adr/0002-adaptive-memory-degradation-cascade.md b/docs/adr/0002-adaptive-memory-degradation-cascade.md
new file mode 100644
index 00000000..0d7c8077
--- /dev/null
+++ b/docs/adr/0002-adaptive-memory-degradation-cascade.md
@@ -0,0 +1,23 @@
+# 2. Adaptive allocation with a degradation cascade
+
+Status: Accepted
+
+## Context
+
+The light pipeline runs on devices from a no-PSRAM ESP32 (~180 KB free internal heap) to a PSRAM-rich P4. A fixed buffer scheme either wastes memory on small installs or fails to fit large ones. The pipeline has intermediate buffers (mapping LUT, driver output buffer) that a 1:1 unshuffled layout does not need at all.
+
+## Decision
+
+Allocate intermediate buffers on demand, only when the pipeline actually needs them, and degrade under memory pressure rather than fail:
+
+- The **mapping LUT** is built only when modifiers exist and the layout is not a plain grid and heap remains after reserving `HEAP_RESERVE` (32 KB) for stack/HTTP/WiFi.
+- The **driver output buffer** is built only when a LUT is actually allocated.
+- When memory is insufficient, degrade in order: full pipeline → skip the output buffer (map inline) → skip the LUT (forced 1:1) → reduce layer dimensions (halve to a floor of 8×8).
+
+Each level is observable (`degraded()`, `lutSkipped()`, `outputBufferSkipped()`). Every allocation is predict-then-measure: predict from dimensions + channels + modifiers, compare the heap delta, and flag >5% variance as a leak.
+
+## Consequences
+
+- A 1:1 unshuffled layout allocates zero intermediate buffers; ArtNet reads the layer buffer directly. Maximum LED count at minimum memory.
+- The device stays running under pressure (degraded is acceptable, crashed is not), instead of failing an allocation outright.
+- The mechanism and buffer-type detail live in [architecture.md § Memory strategy](../architecture.md#memory-strategy); this ADR records the decision to make allocation adaptive rather than fixed.
diff --git a/docs/adr/0003-layer-buffer-persists-frame-to-frame.md b/docs/adr/0003-layer-buffer-persists-frame-to-frame.md
new file mode 100644
index 00000000..b183698c
--- /dev/null
+++ b/docs/adr/0003-layer-buffer-persists-frame-to-frame.md
@@ -0,0 +1,17 @@
+# 3. The layer buffer persists frame-to-frame
+
+Status: Accepted
+
+## Context
+
+An early design cleared the render buffer before every effect frame, on the reasoning "the buffer is the effect's to fill, every time." This silently broke every persistence effect: a scroll reads the prior column and shifts it (reading a wiped buffer, only the fresh pixel survived); a trail calls `fadeToBlackBy` to decay the previous frame (fading zeroes never forms a trail); Game-of-Life reads its prior cell state (gone). The symptom that surfaced it: FreqMatrix lit only one row, and ~13 effects' `fade` controls did nothing.
+
+## Decision
+
+The render buffer is **not** cleared each frame. `Layer::loop()` leaves the previous frame's pixels in place, zeroed once on allocation/resize, then persistent, matching the FastLED / WLED / MoonLight convention (their `leds[]` / segment / VirtualLayer buffers all persist). Each effect owns its background inside its own `loop()`: a full-grid effect overwrites every pixel, a trail calls `fadeToBlackBy`, a sparse effect that wants a clean frame calls `draw::fill` itself. There is no per-effect "persist" flag (a flag would be bespoke). `fadeToBlackBy` is a Layer operation collected once per frame: effects register an amount, the Layer keeps the MIN across them and applies one pass at the next frame's start.
+
+## Consequences
+
+- Persistence effects (scroll, trail, Game-of-Life) work; multiple effects on one layer deliberately interact through the shared persistent buffer.
+- N fading effects cost one buffer pass, not N, and never fade each other's fresh pixels.
+- A state-advancing effect (Game-of-Life) separates when the simulation steps (gated on `bpm`) from when it paints (frame rate); its state lives in the persistent buffer. `unit_Layer_persistence` and `unit_GameOfLifeEffect` pin it.
diff --git a/docs/adr/0004-composable-modifiers-physical-to-logical.md b/docs/adr/0004-composable-modifiers-physical-to-logical.md
new file mode 100644
index 00000000..65d098ff
--- /dev/null
+++ b/docs/adr/0004-composable-modifiers-physical-to-logical.md
@@ -0,0 +1,17 @@
+# 4. Build the map physical→logical so modifiers compose
+
+Status: Accepted
+
+## Context
+
+Modifiers needed to chain (Region, then Multiply, then Rotate). The old interface `mapToPhysical(logicalCoord) → [physical indices]` (a logical→physical fan-out) did not compose: stages emitted flat indices, not coordinates, and chaining would need a product-of-`maxMultiplier` fan-out ceiling, the exact `uint16` overflow that black-screened the high-fan-out MultiplyModifier.
+
+## Decision
+
+Invert the map build to **physical→logical**, adopting MoonLight's proven model written in projectMM's own code. Each modifier is an in-place coordinate fold with three hooks: `modifyLogicalSize`, `modifyLogical` (returns false to reject a coordinate), and `modifyLive` (for per-frame transforms like Rotate). The Layer walks the *physical* lights and folds each through the chain. Because a scatter onto arbitrary logical keys does not fit `setMapping`'s in-order contract, the build is a textbook counting-sort CSR construction (count, prefix-sum, scatter, replay) entirely on the cold path.
+
+## Consequences
+
+- Fan-out becomes free: N physical lights folding onto one logical cell *is* the fan-out, with no fan-out list, no product ceiling, no overflow. `destinationCount ≤ driverCount` is now a hard invariant; the `maxMultiplier`/scratch-buffer machinery was deleted.
+- The per-frame read is byte-identical; the hot path is untouched.
+- Static folds (mask/tile/crop) happen at build time; only rotation gathers per frame, so a static-only chain pays nothing per frame.
diff --git a/docs/adr/0005-set-control-primitive-on-scheduler.md b/docs/adr/0005-set-control-primitive-on-scheduler.md
new file mode 100644
index 00000000..923119a5
--- /dev/null
+++ b/docs/adr/0005-set-control-primitive-on-scheduler.md
@@ -0,0 +1,16 @@
+# 5. A generic set-control primitive on the Scheduler
+
+Status: Accepted
+
+## Context
+
+Adding IR remote control raised: how does one module drive another module's control? The wrong answer is a bespoke seam per target (`Drivers::adjustBrightness`, then `setPalette`, and N more one-offs). The right primitive already existed half-hidden: the WLED-app bridge set brightness via `applySetControl(module, control, value)`, the generic find → validate → apply → `onUpdate` → persist → conditional-`buildState` path, but it was private on HttpServerModule.
+
+## Decision
+
+Lift `setControl` onto the `Scheduler` (which owns the module tree and the persistence hook), and make HttpServer's `applySetControl` a thin result→HTTP-status mapper. Every input (IR, HTTP, Improv, the WLED bridge) composes against one control-agnostic primitive. A factory-created module reaches it through `Scheduler::instance()` (the `FilesystemModule::instance_` pattern), so no per-module inject is needed.
+
+## Consequences
+
+- Adding a new input transport never adds control plumbing; HttpServer shrank ~54 lines.
+- The rule that generalised: when a second caller wants a capability a transport implemented privately, the capability belongs in core (the tree owner), and the transport keeps only its status-mapping.
diff --git a/docs/adr/0006-device-discovery-udp-mdns-advertise-only.md b/docs/adr/0006-device-discovery-udp-mdns-advertise-only.md
new file mode 100644
index 00000000..dfd5809a
--- /dev/null
+++ b/docs/adr/0006-device-discovery-udp-mdns-advertise-only.md
@@ -0,0 +1,17 @@
+# 6. UDP presence for discovery, mDNS advertise-only
+
+Status: Accepted
+
+## Context
+
+The DevicesModule refactor reached for a UDP beacon, swung to "mDNS is the standard, use it for discovery too," then, after bench measurement, landed elsewhere. A blocking mDNS PTR query for a service the device *also advertises* exhausts the IDF mDNS pool (`Cannot allocate memory` with megabytes free) and the device's own advertisement vanishes from peers. The "use the standard" instinct was right about announce-to-foreign-apps and wrong about discover-peers; only measuring the wire separated them.
+
+## Decision
+
+Use **UDP presence for discovery** (where we control both ends, or a foreign system already broadcasts, e.g. WLED's 44-byte packet on UDP 65506), and **mDNS advertise-only** (only where a foreign app requires it, e.g. the native WLED app finds us solely via mDNS `_wled._tcp`). The `DevicePlugin` seam is transport-agnostic (`discoveryPort()` / `classifyPacket(...)`), not mDNS-shaped.
+
+## Consequences
+
+- The mDNS-pool exhaustion is structurally impossible: discovery no longer queries, mDNS shrinks to advertise-only.
+- Two directions of one interop can use different transports: we discover WLED over UDP 65506; WLED's app discovers us over mDNS.
+- The lesson that generalised: read the prior art's *actual* behaviour rather than assuming it; a self-inflicted bug misattributed to "the network" or "the chip" wastes hours (the WLED app discovering WLED on classic ESP32s proves classic mDNS propagates, so an invisible projectMM device is our bug).
diff --git a/docs/adr/0007-moonlive-expressions-host-bound-functions.md b/docs/adr/0007-moonlive-expressions-host-bound-functions.md
new file mode 100644
index 00000000..cad42619
--- /dev/null
+++ b/docs/adr/0007-moonlive-expressions-host-bound-functions.md
@@ -0,0 +1,17 @@
+# 7. MoonLive is expressions + host-bound functions, not statement shapes
+
+Status: Accepted
+
+## Context
+
+The MoonLive compiler was first built around a fixed *statement shape*, `setRGB(idx, r, g, b)`, with per-slot parser rules (the index could be `random16`, colours were literal-only) and an RGB-specific `Store` op baked into the core. Three product-owner remarks exposed one root flaw: `random16` worked only in the index slot; `random16(255)` capped at a byte (validators conflated ranges); and the core compiler was light-domain-specific.
+
+## Decision
+
+Adopt the ESPLiveScript / ARTI model: the core knows only **expressions plus a generic call mechanism**; the host registers its functions in a builtin table. Every argument parses as an expression (a literal or a nested call), so `setRGB(random16(256), random16(256), 30, 0)` works and a number is a `uint16`. The LED names and RGB meaning live only in the light-domain registration (`MoonLiveBuiltins_light.h`); the core sees a neutral `BuiltinTable` of `{name → Call(fn ptr) | Inline(opcode tag)}`, where a buffer writer is `Inline` (the hot-path fast path) and a pure helper is `Call`.
+
+## Consequences
+
+- A capability the language "can't express in slot Y" is fixed by real expressions, not a per-slot special case.
+- Domain-neutrality is testable: a test asserts the core with an empty builtin table knows no functions.
+- Two codegen contracts fell out and generalise to any per-ISA backend: a value live across a call must survive it (save/restore the caller-saved set), and register budget is real on the MCU, so a tree-walk register stack with a free-list allocator keeps N calls at a handful of registers, not 2N (this surfaced on the P4 RISC-V backend, the first target where a 4-call statement exhausted the pool).
diff --git a/docs/adr/0008-board-injection-name-only-http-fanout.md b/docs/adr/0008-board-injection-name-only-http-fanout.md
new file mode 100644
index 00000000..235fb3c4
--- /dev/null
+++ b/docs/adr/0008-board-injection-name-only-http-fanout.md
@@ -0,0 +1,16 @@
+# 8. Board injection: SET_BOARD name-only, controls over HTTP
+
+Status: Accepted
+
+## Context
+
+The web installer's catalog ships per-board control values the orchestrator pushes to a fresh device during provisioning. Every per-board control shipped today applies post-association (`Network.txPowerSetting`, Ethernet pin maps), so the ~1 s window between WiFi association and HTTP fan-out running at a wrong setting is acceptable. It would not be acceptable for a pre-association control: country code (governs which channels are scanned), antenna selector (wrong RF path at init makes the device deaf), or pre-association TX-power (some chips need the cap before the first probe).
+
+## Decision
+
+`SET_BOARD` over Improv-Serial carries only the board name (one vendor RPC, one Text control); every other field ships via HTTP after WiFi association. If a pre-association control is ever added, **do not extend SET_BOARD's wire format** (that couples unrelated controls to the board-name lifecycle and hides the timing constraint). Use one of two explicit escape hatches instead: a second vendor Improv RPC (`SET_`) dispatched before `SEND_WIFI_CREDENTIALS` for user-configurable pre-association controls, or a board-specific sdkconfig fragment baking the value into firmware for truly board-static values.
+
+## Consequences
+
+- The board-name lifecycle stays decoupled from control values; the timing contract is explicit rather than buried in a growing wire format.
+- The implicit option "just push it earlier in SET_BOARD" is ruled out, it tangles the lifecycle.
diff --git a/docs/adr/0009-docs-generated-technical-plus-summary.md b/docs/adr/0009-docs-generated-technical-plus-summary.md
new file mode 100644
index 00000000..bf1a526d
--- /dev/null
+++ b/docs/adr/0009-docs-generated-technical-plus-summary.md
@@ -0,0 +1,17 @@
+# 9. Two doc surfaces: generated technical + hand-written summary
+
+Status: Accepted
+
+## Context
+
+Per-module `.md` files were being hand-maintained and hand-shrunk, drifting from the code they described. The technical facts (controls, ranges, members) already lived in the `.h`, so keeping a parallel prose copy was duplication that rotted.
+
+## Decision
+
+Every module has exactly two reader surfaces. The `.h` is the single home of technical content (`///` comments); a Doxygen→moxygen pipeline **generates** one technical page per module. A thin hand-written summary page (a table, end-user facing) is the only prose that stays. The prior art is docs.rs / Sphinx-autodoc / Doxygen (a hand-written guide over a generated API reference). The full model lives in [coding-standards.md § Documentation model](../coding-standards.md#documentation-model).
+
+## Consequences
+
+- A technical fact is stated once (in the `.h`) and never re-typed in prose.
+- The pipeline exposed traps that became build rules, each worth keeping: batch the external tool (one Doxygen pass + one `moxygen`, not 132 per-header invocations, ~150 s → ~7 s); write generated files only on content change (an unconditional write into a watched dir is an infinite rebuild loop); a present-but-failing generator must fail loud, not return empty; and a doc path duplicated in `main.cpp` `registerType` drifts silently, so `check_specs.py` gates that every docPath resolves to a real page + anchor.
+- The verification rule that generalised: a generated artifact's ground truth is the rendered output, so verify an anchor against the built HTML (`grep id=` the `.html`), never a re-derived slug.
diff --git a/docs/adr/0010-integration-identity-stable-hardware-id.md b/docs/adr/0010-integration-identity-stable-hardware-id.md
new file mode 100644
index 00000000..ce7b4d22
--- /dev/null
+++ b/docs/adr/0010-integration-identity-stable-hardware-id.md
@@ -0,0 +1,16 @@
+# 10. Integration identity is a stable hardware id, never the editable name
+
+Status: Accepted
+
+## Context
+
+The MQTT module first derived its topic prefix from the user-editable `deviceName` (`projectMM/`). On the bench a rename (ShellyOne → ShellyTwo) instantly repointed every topic and orphaned the Homebridge config: the hub kept publishing to the old topics while the device listened on new ones, showing "not responding." WLED, Tasmota, ESPHome, and HA MQTT discovery are unanimous, the machine-facing identity anchors to a stable hardware id (WLED's `wled/` is rename-stable; HA discovery *requires* a stable `unique_id` and forbids the device name / hostname as identity).
+
+## Decision
+
+Any control-plane identity an external system binds to (an MQTT topic prefix, an HA discovery `unique_id`, an API key path) derives from an immutable hardware id, never the editable `deviceName`. MQTT topics derive from `projectMM/`; the friendly `deviceName` rides a separate retained `/name` topic as a published-but-non-identifying field.
+
+## Consequences
+
+- A device rename updates only the display label; every external integration stays bound to the stable id.
+- The rule applies to any future integration, not just MQTT: derive the identity from something immutable, keep the human name a separate non-identifying field. Restated as an invariant in [architecture.md § Device name](../architecture.md#device-name-one-identity-every-network-name-derives-from-it).
diff --git a/docs/adr/0011-data-exchange-pull-and-prepare-pass-not-pubsub.md b/docs/adr/0011-data-exchange-pull-and-prepare-pass-not-pubsub.md
new file mode 100644
index 00000000..06af4641
--- /dev/null
+++ b/docs/adr/0011-data-exchange-pull-and-prepare-pass-not-pubsub.md
@@ -0,0 +1,24 @@
+# 11. Inter-module data and events: pull + prepare-pass, not pub/sub
+
+Status: Accepted
+
+## Context
+
+Modules need two things from each other: one module reads another's data on the hot path (an effect reading a sensor frame, a driver reading a layer buffer), and a change in one module triggers derived-state rebuilds in others (a control edit resizing buffers, a tree mutation re-resolving links). The obvious general-purpose answer is a publish/subscribe event bus with a registry and listener lifecycles.
+
+## Decision
+
+Do not use pub/sub. There is one producer per data kind and the consumer explicitly wants that specific data, so the registry overhead and listener-lifecycle complexity buy nothing. Use three narrower mechanisms instead:
+
+- **Shared-struct pull** for hot-path data: the producer owns a small POD struct overwritten in place each tick; the consumer holds a `const Foo*` (set at wiring time) and reads it per frame. Lock-free for a small POD (a half-updated read self-corrects next tick); a large frame buffer uses the two-core double-buffer swap instead, not this pull.
+- **Push to a domain-neutral sink** when a producer hands bytes to a generic core service: the core defines a narrow interface (`BinaryBroadcaster`), the producer pushes, the sink knows nothing about the payload.
+- **A framework-driven prepare-pass** for derived-state rebuilds: a three-tier split (`onUpdate` per-control, a `controlChangeTriggersBuildState` gate, `onBuildState` rebuild) where the coordinator walks every module, gated by per-module metadata. This is the recognised layout/prepare-pass pattern (JUCE `prepareToPlay`, UIKit `layoutSubviews`, WPF `AffectsMeasure`), not an event bus: the publisher tells the coordinator to run the pass, the coordinator walks every module.
+
+Direct method calls cover the one remaining case (a producer notifying one known consumer): the producer holds a pointer set at wiring time and calls it.
+
+## Consequences
+
+- No registry, no subscription, no listener lifecycles to leak; each mechanism costs only what its case needs.
+- Every change costs proportionally: an in-place tweak is tier-1 only; a shape change ripples through the tree-wide sweep; a structural mutation always rebuilds.
+- The mechanism is core and domain-neutral; the light pipeline consumes it (mapping-LUT rebuild, buffer pull) without the core knowing about lights.
+- Pub/sub becomes the right pattern only if multiple unknown subscribers per event ever appear; projectMM has none. The mechanisms are described as current behaviour in [architecture.md § Data exchange](../architecture.md#data-exchange-between-modules) and [§ Event triggering](../architecture.md#event-triggering-between-modules).
diff --git a/docs/adr/README.md b/docs/adr/README.md
new file mode 100644
index 00000000..d2ed139a
--- /dev/null
+++ b/docs/adr/README.md
@@ -0,0 +1,23 @@
+# Architecture Decision Records
+
+An [ADR](https://github.com/joelparkerhenderson/architecture-decision-record) captures one significant architectural decision: the context that forced a choice, the option taken, and the consequences that followed. Format is [Michael Nygard's classic](https://cognitect.com/blog/2011/11/15/documenting-architecture-decisions.html): **Title, Status, Context, Decision, Consequences**.
+
+These records are **immutable**. A decision that changes is not edited in place, a new ADR supersedes it and both link, so the reasoning trail stays honest. This is the difference from the [lessons log](../history/lessons.md): lessons are debugging war-stories, pruned as they are absorbed; ADRs are decisions, kept as an append-only record. The forward-looking counterpart, what we set out to build, is the [plan archive](../history/plans/README.md).
+
+Agents do not read this directory automatically, only when a decision's rationale is in question (the same rule as `history/` and `backlog/`).
+
+## Index
+
+| # | Decision | Status |
+|---|----------|--------|
+| [0001](0001-persistence-pod-memcpy-not-json.md) | Persist POD module state with memcpy, not JSON | Accepted |
+| [0002](0002-adaptive-memory-degradation-cascade.md) | Adaptive allocation with a degradation cascade | Accepted |
+| [0003](0003-layer-buffer-persists-frame-to-frame.md) | The layer buffer persists frame-to-frame | Accepted |
+| [0004](0004-composable-modifiers-physical-to-logical.md) | Build the map physical→logical so modifiers compose | Accepted |
+| [0005](0005-set-control-primitive-on-scheduler.md) | A generic set-control primitive on the Scheduler | Accepted |
+| [0006](0006-device-discovery-udp-mdns-advertise-only.md) | UDP presence for discovery, mDNS advertise-only | Accepted |
+| [0007](0007-moonlive-expressions-host-bound-functions.md) | MoonLive is expressions + host-bound functions | Accepted |
+| [0008](0008-board-injection-name-only-http-fanout.md) | Board injection: SET_BOARD name-only, controls over HTTP | Accepted |
+| [0009](0009-docs-generated-technical-plus-summary.md) | Two doc surfaces: generated technical + hand-written summary | Accepted |
+| [0010](0010-integration-identity-stable-hardware-id.md) | Integration identity is a stable hardware id | Accepted |
+| [0011](0011-data-exchange-pull-and-prepare-pass-not-pubsub.md) | Inter-module data/events: pull + prepare-pass, not pub/sub | Accepted |
diff --git a/docs/architecture.md b/docs/architecture.md
index 5bddabb2..854d2c67 100644
--- a/docs/architecture.md
+++ b/docs/architecture.md
@@ -47,7 +47,6 @@ Coding conventions live in [coding-standards.md](coding-standards.md); how to bu
- [Scaling to available memory](#scaling-to-available-memory)
- [Multi-device sync](#multi-device-sync)
- [Web UI](#web-ui)
- - [What we leave undesigned](#what-we-leave-undesigned)
## The problem
@@ -133,7 +132,7 @@ Control values and each module's `enabled` flag are persisted to flash so settin
- **Conditional controls**: every conditional control is always bound; the module sets a `hidden` flag (`controls_.setHidden(i, …)`) to tell the UI not to render it. The load path can therefore find persisted values regardless of the live conditional state.
- **Code-wired children survive a stale file**: some children aren't created by the user; `main.cpp`'s boot wiring attaches them (`ImprovProvisioningModule` under `NetworkModule`; `NetworkSendDriver`, `PreviewDriver` under their parents). Each such child calls `markWiredByCode()` after `addChild()`, a one-bit flag meaning *"I belong here because the code put me here, not because a saved file or a user asked for me."* The problem it solves: persistence reconciles the live tree to match the saved JSON, so a child that exists in code but is absent from an older saved file (written before that child was added) would be trimmed on load. The flag tells the apply step to keep it. Children added through the HTTP API or recreated from JSON stay unmarked; those follow the file's tree shape exactly, so UI deletes still take effect.
-How persistence reaches the Scheduler without the Scheduler depending on it: the Scheduler exposes a **function-pointer hook** (`setLoadAllHook`) that the load phase calls if set. FilesystemModule registers its load routine through that hook at startup; the Scheduler never includes or names FilesystemModule (no circular dependency, and persistence is fully optional). With no FilesystemModule registered the hook is null, the load phase is a no-op, and the system runs with member-initialised defaults.
+Persistence reaches the Scheduler through a **function-pointer hook** (`setLoadAllHook`) the load phase calls if set: FilesystemModule registers its load routine there at startup, so the Scheduler never names FilesystemModule (no circular dependency, persistence stays optional; a null hook means defaults-only). The choice of a flat POD image over a JSON format, and of load-before-setup, is [ADR-0001](adr/0001-persistence-pod-memcpy-not-json.md).
## Parallelism
@@ -160,7 +159,7 @@ No registry, no subscription, no event bus. The consumer reads the latest value
**Push through a domain-neutral sink.** When the producer should hand bytes to a generic core service rather than expose a struct, the core defines a narrow interface and the producer pushes to it. The producer owns the data and its wire format; the core sink (the interface's implementer) knows only "take these bytes and do my generic job"; it has zero knowledge of what the bytes mean or which domain produced them. `BinaryBroadcaster` (`HttpServerModule` implements it: "broadcast these bytes to all WebSocket clients") is the example; the producer side lives in the light domain (see [§ The pipeline](#the-pipeline)).
-Both shapes extend without ceremony to any future producer/consumer pair (a sensor module owning a state struct, an effect reading it through a `const Foo*` set at wiring time; or a module pushing bytes to a core sink). Neither is pub/sub: there's one producer per data kind and the consumer explicitly wants that specific data: the registry overhead and listener-lifecycle complexity of pub/sub buy nothing.
+Both shapes extend to any future producer/consumer pair (a sensor owning a state struct read through a `const Foo*`; a module pushing bytes to a core sink). Neither is pub/sub, and the reasons this project chose pull + a prepare-pass over an event bus are [ADR-0011](adr/0011-data-exchange-pull-and-prepare-pass-not-pubsub.md).
## Event triggering between modules
@@ -172,11 +171,11 @@ A control changes, or the module tree is mutated (a child added, deleted, replac
`Scheduler::buildState()` fires from two triggers: a tier-2 gate returning true after a control change, **and** any tree mutation (HTTP add/delete/replace/move handlers all call it unconditionally, since a structural change is rare and unambiguously needs a rebuild). Both triggers funnel through the same sweep; each module's `onBuildState` is idempotent (e.g. an effect only reallocs when its grid count actually changed), so over-rebuilding is wasted work, not a correctness hazard.
-This is the recognised layout/prepare-pass pattern: JUCE's `prepareToPlay` and UIKit's `layoutSubviews` work the same way: a framework-driven sweep over every object of the primary type, gated by per-object metadata (WPF's `AffectsMeasure`, here `controlChangeTriggersBuildState`). Not pub/sub: the publisher (HttpServerModule, or the mutation site) explicitly tells the coordinator to run the pass; the coordinator explicitly walks every module. The light domain consumes this mechanism for its mapping rebuild (see [§ Mapping and blending](#mapping-and-blending)) but the mechanism itself is core and applies to any module with derived state.
+This is the recognised layout/prepare-pass pattern (JUCE `prepareToPlay`, UIKit `layoutSubviews`, gated by per-object metadata like WPF's `AffectsMeasure`, here `controlChangeTriggersBuildState`); the pull-and-prepare-pass-not-pub/sub decision is [ADR-0011](adr/0011-data-exchange-pull-and-prepare-pass-not-pubsub.md). The light domain consumes it for the mapping rebuild ([§ Mapping and blending](#mapping-and-blending)); the mechanism itself is core.
### Live reconfiguration: every change applies without a reboot
-A direct consequence of the three tiers above is a property worth naming, because it sets projectMM apart from most LED-controller firmware (where changing a pin map, strand length, or output protocol means editing a config and **rebooting**): **every MoonModule reconfigures itself live the instant a control changes — no *configuration* change requires a restart to take effect.** A pin edit, a leds-per-pin edit, a swap of output protocol, a mic pin or sample-rate change — each flows control-write → `onUpdate` (tier 1) and, when it changes the module's shape, → `Scheduler::buildState()` → `onBuildState()` (tier 3), which tears down and rebuilds exactly the derived state that changed: an LED driver re-targets its RMT channels / DMA bus onto the new GPIOs, an audio module re-inits its I²S channel on the new pins, an effect re-sizes its buffer, the Layer rebuilds its mapping LUT. The hot-path render loop reads the rebuilt state on its very next tick. This holds for **all** module types — drivers, the audio peripheral, effects, layouts, modifiers, network I/O — because the rebuild chain is core, not per-module. It composes with the [robustness rule](#robustness): because any change can be applied at any time in any order, a running device tolerates being reconfigured arbitrarily and keeps running (degraded or idle, never crashed). The one thing that still needs a power cycle is a *firmware* OTA flash — a binary swap, not a configuration change, and the same physical boundary the robustness rule draws (power loss, OTA, brown-out are out of scope there too).
+A property that falls out of the three tiers, and sets projectMM apart from most LED-controller firmware (where changing a pin map, strand length, or protocol means a **reboot**): **every MoonModule reconfigures live the instant a control changes — no configuration change needs a restart.** A pin, leds-per-pin, protocol, or mic-rate edit flows control-write → `onUpdate` (tier 1) and, when it changes shape, → `onBuildState()` (tier 3), which rebuilds exactly the derived state that changed (an LED driver re-targets its RMT/DMA onto the new GPIOs, an audio module re-inits I²S, an effect re-sizes, the Layer rebuilds its LUT); the render loop reads it next tick. This holds for every module type because the rebuild chain is core, and it composes with the [robustness rule](#robustness): any change, any order, keeps the device running. Only a *firmware* OTA flash needs a power cycle, the same physical boundary the robustness rule draws.
If a module needs to actively notify a specific other module of an event (rather than publish data for polling, or change its own controls), the pattern is a direct method call from the producer to a known consumer: `ImprovProvisioningModule::loop1s` calls `networkModule_->setWifiCredentials(...)` when credentials arrive over UART. No event bus; the producer holds a pointer to the consumer set at wiring time (`main.cpp`). Pub/sub becomes the right pattern only when there are multiple unknown subscribers per event; projectMM has none today.
@@ -243,11 +242,9 @@ Firmware-vs-deviceModel is a **two-level** model for **where a pin or setting de
So the Ethernet pins live at **both** levels, and that's not a contradiction: the firmware *seeds* a per-chip default, the deviceModel *fixes* the real map. The driver (which Ethernet stack) is firmware-only; the pin map is firmware-seeded but deviceModel-authoritative.
-**The deviceModel is one level — there is no separate per-unit "device" provenance level.** Whether a control is PCB-fixed (the Ethernet PHY pins) or user-wired (a mic, LED strands) is not a taxonomy the code tracks; it falls out of **what the catalog entry lists**. A bare dev kit (`Generic ESP32 Dev`, `LOLIN D32`) lists few controls — the user wires the rest, so those stay unset; a finished product (`QuinLED Dig-2-Go`, an LED-controller shield) lists more, because its wiring is fixed. Same kind of catalog entry, different completeness — no `kind:` flag, no separate catalog.
+**The deviceModel is one level — there is no separate per-unit provenance level.** Whether a control is PCB-fixed or user-wired is not a taxonomy the code tracks; it falls out of what the catalog entry lists. A bare dev kit lists few controls (the user wires the rest, so those stay unset); a finished product lists more (its wiring is fixed). Same kind of entry, different completeness, no `kind:` flag.
-**The governing rule — "default only where the hardware actually fixes it"** — is the [`assign defaults only when they cannot do harm`](history/decisions.md) rule applied to pin provenance. A control whose value the product fixes (Ethernet PHY pins, status LED) *should* default (and *omitting* it harms — a deviceModel with un-defaulted Ethernet pins can never connect); a control the *user* wires (mic pins, LED-driver pins) must not default — any guess can drive a pin the user wired elsewhere — so it stays unset until set. This is enforced purely by **what each catalog entry lists**: an entry defaults a control by *including* it, and leaves a user-wired control unset by *omitting* it. No level-tagging needed — the data carries the rule.
-
-The rule covers **settings, not just pins**. `txPowerSetting` is the worked example: whether the assembled rig can *sustain* the current spike of full-power WiFi TX depends on the product's power regulation and how the user powers it (USB port, PSU, cable) — a brownout property of the physical product, not the chip. In-tree, the catalog sets `Network.txPowerSetting: 8` for the ESP32-S3 N16R8 Dev, which browns out at full power on typical USB; the entry *suggests* the safe floor, and a unit can lower it further for a weak supply. The general lesson: any setting whose safe value depends on the physical assembly or its power, not the chip, is set by the deviceModel's catalog entry. (The catalog is [`web-installer/deviceModels.json`](../web-installer/deviceModels.json); see the [installer README](../web-installer/README.md) for its schema.)
+**The governing rule — "default only where the hardware actually fixes it"** — is the [Defaults rule](coding-standards.md#defaults) applied to pin provenance: an entry defaults a control by *including* it and leaves a user-wired control unset by *omitting* it, so the data carries the rule with no level-tagging. It covers **settings, not just pins**: `txPowerSetting` is set per-entry because whether a rig sustains full-power WiFi TX is a brownout property of the assembly and its power supply, not the chip (the catalog pins `Network.txPowerSetting: 8` for the ESP32-S3 N16R8 Dev, which browns out at full power on typical USB). The catalog is [`web-installer/deviceModels.json`](../web-installer/deviceModels.json) (schema in the [installer README](../web-installer/README.md)).
## Peripherals
@@ -278,6 +275,8 @@ A device has **one** network name, `deviceName`, and every name the device prese
- **Always a valid hostname.** Because all three uses are DNS/SSID names, `deviceName` must satisfy the RFC-1123 label rules (`[A-Za-z0-9-]`, no spaces, no leading/trailing hyphen). `SystemModule` enforces this at the source: it runs `mm::sanitizeHostname()` (in `core/Control.h`) on the value in `setup()` and every `loop1s()`, coercing whatever the user typed or persistence restored (`"My Living Room!"` → `"My-Living-Room"`) and falling back to the MAC-derived `MM-XXXX` if the result is empty. Sanitising *at the owner* means every consumer is correct for free — no per-consumer validation, no chance a raw name reaches mDNS. (`unit_sanitizeHostname` pins the rule.)
- **Follows a live rename.** Renaming the device re-advertises immediately, no reboot — the [live-reconfiguration](#live-reconfiguration-every-change-applies-without-a-reboot) rule applied to identity. `NetworkModule::syncMdns()` (called each `loop1s()`) compares the current name to the last-registered one and re-registers mDNS when it changed, so `.local` resolves within a tick.
+**A machine-facing identity that an external system binds to is never the editable name.** An MQTT topic prefix, a Home Assistant discovery `unique_id`, an API key path: anything a foreign system keys off must derive from an immutable hardware id (MAC / chip-id, e.g. `projectMM/`), because a live `deviceName` rename would silently repoint every topic and orphan the peer's config. The human-readable name rides a *separate*, published-but-non-identifying field (WLED, Tasmota, ESPHome, and HA discovery all anchor identity this way). `deviceName` above is the network-presentation identity (mDNS / AP / DHCP, where the name *is* the address); an external-integration identity is the opposite case and stays decoupled from it.
+
# Light domain
The light domain is everything specific to driving lights. **Light** here means any controllable light source: an addressable LED pixel (WS2812, APA102), a DMX fixture (RGB par, moving head, dimmer), or any other output that takes colour/intensity data. The term is used instead of "pixel" because the system controls both LEDs and conventional lighting fixtures.
@@ -313,7 +312,7 @@ Modules in the light pipeline can be added, replaced, or removed dynamically at
- *Shared-struct (pull):* `Drivers` hands every child driver a `Buffer*` (source) plus a `Correction*` (shared brightness/reorder/white), and `Layer` exposes its pixel buffer to `Drivers` directly on the identity-mapping fast path: each consumer holds a `const`-pointer and reads it per frame. The pointers are **(re)bound on every rebuild**, not just at boot: `Drivers::onBuildState()` re-resolves the active `Layer` (`Layers::activeLayer()`) and calls `passBufferToDrivers()`, which re-runs `setSourceBuffer()`/`setLayer()` on each child (clearing them to `nullptr` when there is no active Layer). So a held pointer is valid only until the next rebuild — which is exactly why the consumers re-read it each frame and tolerate a null (the [robustness rule](#robustness)): a Layer add/delete/replace re-binds or clears it live, no dangling reference.
- *Push to a core sink:* `PreviewDriver` owns the preview wire format (a one-time coordinate table + per-frame RGB point list) and pushes the bytes to a `BinaryBroadcaster` (the core HTTP server). The server broadcasts them over WebSocket without knowing they're a preview: the format and the light types stay entirely in the driver. See [PreviewDriver](moonmodules/light/moxygen/PreviewDriver.md).
-**Graceful degradation under transport backpressure.** The preview is the project's worked example of a property worth naming generically, because it is the transport-side sibling of the memory-side [§ Degradation cascade](#degradation-cascade): when a consumer can't keep up, **shed quality first** — degrade the stream rather than reach for a stall. The link to a browser is the slow consumer; a full-resolution frame (128² = 16384 lights = ~49 KB) may not drain in the budget one tick allows. Rather than block the loop until it drains, the producer **degrades**, shedding in the order video streaming does — frame rate first, then resolution: (1) the full-resolution frame streams from the driver buffer with no intermediate copy, drained a memory-adaptive chunk per transport tick (a **resumable** send), and the next frame starts only when the previous one finished — so the **effective frame rate self-limits** to what the link sustains, with no loop stall and no connection drop; (2) only when even one frame can't drain promptly does it shed **resolution** via a spatial-lattice downsample, the same congestion-responsive, adaptive-bitrate idea behind HLS/DASH/WebRTC applied to a binary WebSocket. The point budget is itself memory-derived (per [§ Scaling to available memory](#scaling-to-available-memory)), so a tighter board downsamples sooner. The render loop is charged only a bounded slice per tick; each delivered frame is a faithful **complete** frame at a lower rate or coarser sample — a WebSocket message is atomic to the browser, so a frame is whole or absent, never partial/torn. The coordinate table and the downsampled frames take a bounded synchronous send (begin/push/end): a client whose socket stays blocked past the spin budget is closed and reconnects (the browser re-handshakes and the next coordinate table re-primes it) — the bound caps tick occupancy, and a reconnect is a brief blip rather than a frozen preview. This is *graceful degradation*: a fast link sees every light at full rate, a slow link sees a faithful coarser sample at a few fps, and a wedged client is dropped and recovers rather than stalling the device. The mechanism (resumable cross-tick send + drop-new backpressure — a frame offered while one is in flight is dropped, the in-flight one is kept — plus adaptive frame rate + adaptive lattice) lives in `PreviewDriver` + `HttpServerModule`; it is payload-agnostic, so other bulky streams can ride the same transport.
+**Graceful degradation under transport backpressure.** The preview is the transport-side sibling of the memory-side [§ Degradation cascade](#degradation-cascade): when the browser can't keep up with a full-resolution frame (128² = ~49 KB), the producer sheds quality rather than stall the loop, in video-streaming order, frame rate then resolution. The frame streams from the driver buffer with no intermediate copy, a resumable memory-adaptive chunk per tick, and the next frame starts only once the previous drained, so the effective frame rate self-limits to what the link sustains. Only when a single frame can't drain promptly does it downsample via a spatial lattice (the adaptive-bitrate idea behind HLS/DASH, on a binary WebSocket). Each delivered frame is whole (a WebSocket message is atomic), the render loop is charged a bounded slice per tick, and a client blocked past the spin budget is closed and reconnects (a blip, not a freeze). The mechanism is payload-agnostic and lives in [PreviewDriver](moonmodules/light/moxygen/PreviewDriver.md) + `HttpServerModule`, so other bulky streams can ride the same transport.
**Naming convention.** Capital `Layouts`, `Layers`, `Drivers` are class names (always capitalised when referring to the class). Lowercase "layouts", "layers", "drivers" is the English plural, used freely when context makes it clear. Singular "layout", "layer", "driver" is an individual instance.
@@ -338,7 +337,7 @@ Layouts cover both addressable LEDs and DMX fixtures. An LED-strip layout yields
Positions are computed algorithmically, not stored. Grid is the most commonly used layout, but any geometry works: spheres, rings, cones, spirals, arbitrary point clouds. Grid is full-density (every position maps to a light); a wheel is sparse (only spoke positions are mapped, gaps are unmapped).
-Multiple layouts can live in one Layouts container. Each layout describes one light type; mixing light types in a single Layouts (e.g. LED strips + par lights) is listed in [§ What we leave undesigned](#what-we-leave-undesigned).
+Multiple layouts can live in one Layouts container. Each layout describes one light type: the model is one light type per layout (LED strips, or par lights), not mixed in a single Layouts.
## Layers and Layer
@@ -371,16 +370,14 @@ Effects know nothing about hardware, protocols, physical LED layout, or mapping.
### Buffer persistence — the layer does not clear each frame
-The Layer's buffer **persists** frame to frame: `Layer::loop()` does not clear it before running effects. The buffer holds the previous frame until an effect overwrites or fades it, and is zeroed **once** on allocation/resize. (This is the standard LED-animation model — FastLED, WLED, and MoonLight all persist their frame buffer rather than auto-clear.) Persistence holds **between frames, not across rebuilds**: `Layer::onBuildState()` clears the buffer once after `rebuildLUT()`, so adding, replacing, or reconfiguring an effect (or a resize) starts from black, then persistence takes over frame to frame again. Each effect owns its own background:
-
-- A **full-grid** effect (Plasma, Rainbow, Fire, Noise) writes every pixel each frame — the previous frame is simply overwritten.
-- A **trail** effect calls `layer()->fadeToBlackBy(amt)` to decay the previous frame before painting its new pixels — a comet leaves a fading tail because the old pixels are still there to fade.
-- A **read-prior** effect (a scroll like FreqMatrix, Game-of-Life, a blur) reads last frame's pixels via `draw::get` / `draw::blur` and acts on them — the persistence *is* its state.
-- A **sparse** effect that wants a clean frame calls `draw::fill(buf, {0,0,0})` itself (e.g. RubiksCube, whose draw touches only surface voxels).
+The Layer's buffer **persists** frame to frame: `Layer::loop()` does not clear it before running effects (the decision, and why not clear-each-frame, is [ADR-0003](adr/0003-layer-buffer-persists-frame-to-frame.md)). It is zeroed once on allocation/resize, and once more in `Layer::onBuildState()` after `rebuildLUT()`, so a rebuild starts from black and persistence then holds between frames. Each effect owns its background:
-There is deliberately **no per-effect "persistence" flag**: persistence is universal, so a flag would change no framework behaviour (unlike `dimensions()`, which drives extrude). Multiple effects on one layer *interact* through the shared persistent buffer — that is a feature.
+- A **full-grid** effect (Plasma, Rainbow, Fire, Noise) writes every pixel each frame.
+- A **trail** effect calls `layer()->fadeToBlackBy(amt)` to decay the previous frame, so a comet leaves a fading tail.
+- A **read-prior** effect (FreqMatrix scroll, Game-of-Life, a blur) reads last frame's pixels via `draw::get` / `draw::blur`; the persistence *is* its state.
+- A **sparse** effect that wants a clean frame calls `draw::fill(buf, {0,0,0})` itself (e.g. RubiksCube).
-**Collected fade (`Layer::fadeToBlackBy`).** Fade is a Layer operation, not a per-effect buffer pass (MoonLight's `VirtualLayer::fadeToBlackBy`). Effects register a fade amount; the Layer keeps the **MIN** across all requesting effects (the gentlest fade wins, so the longest requested trail is honoured) and applies **one** whole-buffer pass at the start of the next frame, then resets the collected amount. So N fading effects on one layer cost a single pass, not N, and one effect's fade never darkens another's just-painted pixels mid-frame. An auto-clear would make trails and read-prior effects impossible without a shadow buffer; this model gets them for free.
+Fade is a Layer operation: effects register an amount, the Layer keeps the MIN across them and applies one whole-buffer pass at the next frame's start, so N fading effects cost one pass, not N, and never darken each other's fresh pixels.
### Dimensionality
@@ -392,7 +389,7 @@ Every effect declares its native dimensionality through `EffectBase::dimensions(
D1/D2 are **opt-in promises**: declaring them tells the framework it can fill the missing axes, saving the per-effect work of iterating z (or x and z). Effects that don't make that promise stay at the D3 default and iterate the whole buffer.
-**Why 1D runs along Y, and the unified expand rule.** A lower-D effect occupies the *low* axes and the framework expands it across the *next* axis: **1D → 2D adds columns across X** (the 1D output is the first column, duplicated rightward); **2D → 3D adds slices across Z** (the 2D front face, duplicated in depth). 1D-along-Y is the deliberate choice (shared with MoonLight): it makes a 1D effect the natural **first column** of its 2D form — write the effect once down Y, and expanding to a panel is just "repeat the column," same math, no special-casing. (The alternative, 1D-along-X, would make 1D a *row* that expands *downward* — a less natural fit, since a strip is a column and a 2D effect's columns are what you tile.) Concretely: a 1D effect draws its shape down Y, so it renders correctly on a layer whose single populated axis is Y (a `1 × N` grid, width 1, height N); on an `N × 1` grid the extrude would run the wrong way and collapse the shape to a flat line. How a physical output (a strip, a row of [Hue lights](moonmodules/light/moxygen/HueDriver.md)) maps to a `1 × N` grid is a layout concern — see the layout docs.
+**Why 1D runs along Y, and the unified expand rule.** A lower-D effect occupies the low axes and the framework expands across the next: **1D → 2D adds columns across X**, **2D → 3D adds slices across Z**. 1D-along-Y (shared with MoonLight) makes a 1D effect the natural first column of its 2D form, so expanding to a panel is just "repeat the column," same math (1D-along-X would make it a row expanding downward, a worse fit since a strip is a column). A 1D effect therefore renders correctly on a `1 × N` grid (width 1, height N), but on `N × 1` the extrude runs the wrong way and flattens it. How a physical output (a strip, a row of [Hue lights](moonmodules/light/moxygen/HueDriver.md)) maps to `1 × N` is a layout concern.
Hot-path cost: extrude pays one comparison and returns for the D3 case. For D1/D2 on a layer whose unused axes are size 1 (a D2 effect on a 2D layer, a D1 effect on a 1D `1 × N` layer) the inner loops are guarded by `depth_ > 1` / `width_ > 1` and never run. Real `memcpy` work happens only for a D1 or D2 effect on a layer with more dimensions than the effect writes: exactly the case where you wanted the framework to do the duplication.
@@ -422,10 +419,10 @@ MoonLive lets you author an effect (later: a layout, modifier, driver, or core r
The engine is a **domain-neutral core** with one narrow seam, structured as three tiers so adding a CPU is additive, never a rewrite:
- **Front-end** (`src/core/moonlive/`, platform-independent): a recursive-descent lexer + parser over an expression grammar (every function argument is a literal or a nested call) that lowers each statement to a typed **IR** — a flat list of three-address ops over virtual registers. The IR is the seam: it knows *operations*, never an ISA and never a domain. It is compile-time only — consumed during lowering and discarded, so it costs nothing at run time; the CPU executes only the final native instructions.
-- **Host builtin table** (the domain seam): the core owns no function names. A *host* registers `{name → descriptor}` — `setRGB`/`fill`/`random16` for LEDs (`src/light/moonlive/`), something else for a display or sensor. A descriptor is either a `Call` (a generic call to a host C function pointer — a pure helper like `random16`) or an `Inline` op (a neutral opcode tag the backend emits inline — a buffer writer, no per-pixel call). This is the [ESPLiveScript / ARTI bound-function model](backlog/livescripts-analysis-top-down.md); it is what keeps the core LED-free while the hot path stays inline. The LED *names* and the "an element is 3 RGB bytes" meaning live only in the light-domain registration and the per-ISA lowering, never in core.
+- **Host builtin table** (the domain seam): the core owns no function names. A *host* registers `{name → descriptor}` — `setRGB`/`fill`/`random16` for LEDs (`src/light/moonlive/`), something else for a display or sensor. A descriptor is either a `Call` (a generic call to a host C function pointer — a pure helper like `random16`) or an `Inline` op (a neutral opcode tag the backend emits inline — a buffer writer, no per-pixel call). This is the ESPLiveScript / ARTI bound-function model; it is what keeps the core LED-free while the hot path stays inline. The LED *names* and the "an element is 3 RGB bytes" meaning live only in the light-domain registration and the per-ISA lowering, never in core.
- **Per-ISA backend** (`src/platform/`, behind the boundary): a tiny named-instruction MacroAssembler (the textbook V8 / LLVM / asmjit shape — append one instruction, back-patch label offsets) plus the IR→bytes lowering that drives it. Xtensa (classic ESP32 / S3), RISC-V (P4), and the host ISA (desktop arm64/x86-64) each are *a new backend file behind the unchanged IR* — the front-end and IR never branch on ISA. Emitted code goes into an `allocExec` block (see [§ Platform abstraction](#platform-abstraction)) and is called each tick.
-A recompile is the normal cold-path rebuild: editing the `source` control routes through the same `onBuildState()` sweep every control change uses, so a new script swaps in live (no reboot), and a parse error surfaces in the module status while the layer renders dark — robust to any input. The full design (the staged language ladder, the safety model, the performance budget, the memory-arena plan as the language grows) lives in [docs/backlog/livescripts-analysis-top-down.md](backlog/livescripts-analysis-top-down.md); the module contract is [docs/moonmodules/light/MoonLiveEffect.md](moonmodules/light/MoonLiveEffect.md).
+A recompile is the normal cold-path rebuild: editing the `source` control routes through the same `onBuildState()` sweep every control change uses, so a new script swaps in live (no reboot), and a parse error surfaces in the module status while the layer renders dark — robust to any input. The module contract is [MoonLiveEffect](moonmodules/light/MoonLiveEffect.md).
## Modifiers
@@ -457,7 +454,7 @@ Because mapping and blending happen in a single pass over each layer, there is n
**Drivers** (a MoonModule) is the top-level container for one or more drivers. It is the consumer side of the pipeline. The Drivers container owns a shared output buffer and performs blend+map from every layer's buffer into it each frame. Individual drivers then read from this buffer to push to hardware / network.
-The shared output buffer is necessary when blend+map writes to arbitrary physical positions via the LUT: the output is not filled sequentially, so a driver cannot read chunk-by-chunk until the full buffer is populated. It is *not* needed for the single-layer, no-blend case (identity or serpentine-shuffle mapping): there a driver can fuse map + output correction + protocol encode into one pass straight into its own output (DMA buffer / packet), skipping the shared buffer. Full detail in [the LED-driver design doc](backlog/leddriver-analysis-top-down.md).
+The shared output buffer is necessary when blend+map writes to arbitrary physical positions via the LUT: the output is not filled sequentially, so a driver cannot read chunk-by-chunk until the full buffer is populated. It is *not* needed for the single-layer, no-blend case (identity or serpentine-shuffle mapping): there a driver can fuse map + output correction + protocol encode into one pass straight into its own output (DMA buffer / packet), skipping the shared buffer.
Each driver (a MoonModule) speaks one protocol:
@@ -468,7 +465,7 @@ Each driver (a MoonModule) speaks one protocol:
Each driver child reads from the Drivers container's output buffer. Everything before the Drivers container is platform-independent.
-**Output correction** turns logical RGB into the physical signal every physical driver needs: **brightness** scaling, channel **reorder** (RGB→GRB etc. via a *light preset*), and **white** derivation for RGBW fixtures. The Drivers container owns the shared correction state, a brightness lookup table plus the light-preset, exposed as `brightness` and `lightPreset` controls. Each *physical* driver applies the correction per-light as it reads its source buffer, into its own output buffer/packet. Preview is exempt: it shows the raw logical buffer (the effect's true output, not the dimmed/reordered wire signal). Every physical driver consumes the correction via a `const Correction*` set by `Drivers`, the RMT / LCD_CAM / Parlio LED drivers and `NetworkSendDriver` alike; `Drivers` hands the same pointer to each child it wires. The brightness LUT rebuilds on the cheap `onUpdate` tier (see [§ Event triggering between modules](#event-triggering-between-modules)), so the slider stays fluent.
+**Output correction** turns logical RGB into the physical signal: **brightness** scaling, channel **reorder** (RGB→GRB via a *light preset*), and **white** derivation for RGBW. The Drivers container owns the shared correction state (a brightness LUT + the light-preset, exposed as `brightness` / `lightPreset` controls) and hands each physical driver a `const Correction*`; the driver applies it per-light into its own buffer/packet. Preview is exempt (it shows the raw logical buffer). The brightness LUT rebuilds on the cheap `onUpdate` tier ([§ Event triggering](#event-triggering-between-modules)), so the slider stays fluent.
Network-based drivers (ArtNet, E1.31, DDP) pace their output with a **non-blocking elapsed-time gate**, never a blocking wait (no `delay`/`vTaskDelay` — that would stall the single-threaded tick, the hot-path rule). The gate is the `lastSendTime`/`millis()` pattern: `if (now − lastSendTime < interval) return;` early-exits the tick so every other module's loop keeps running, exactly how FPS limiting works (`NetworkSendDriver`, `fps` control). **Frame-rate pacing is required** and implemented this way. **Inter-packet pacing** (spacing the universes within one frame) uses the same non-blocking gate *if* a receiver drops packets under a burst — it is not needed by default (the bench ArtNet matrix test runs clean bursting the universes), so it is added only when a target requires it, never as a busy-wait between packets.
@@ -476,6 +473,16 @@ Network-based drivers (ArtNet, E1.31, DDP) pace their output with a **non-blocki
All buffers are allocated as single contiguous blocks outside the hot path, at startup or when configuration changes (LED count, layout size, layer count). They are then reused every frame with zero allocations in steady state. Measured per-module timing and memory for each platform: [performance.md](performance.md).
+### Pay for what you use
+
+A module holds heap **only for capabilities it is actually exercising**, the same zero-overhead principle C++ applies to abstractions ("you don't pay for what you don't use"). Concretely, for every module:
+
+- **A module not in the tree costs nothing.** Modules are heap-allocated through `MoonModule::operator new` when added (via the factory or boot wiring), so a deviceModel that omits a module pays zero — not even its `classSize()`. This is the base case the rest of the rule extends inward.
+- **A feature's buffer allocates on first use, not at `setup()`.** When a module *is* present but a given capability is dormant (a driver with no output attached, an MQTT client with HA discovery toggled off), that capability's buffer is `nullptr` until the code path that needs it runs. Allocating eagerly at `setup()` for a path that may never execute is the anti-pattern this rule forbids — it charges every instance for the worst case.
+- **The allocation frees in `teardown()`** (and on the transition that makes the capability dormant again — a disable, a toggle-off), and is reported through `dynamicBytes()` so `/api/system` and the memory scenarios see the real ladder. `MoonModule::teardown()` reverse-recurses into children, so a subtree's memory unwinds bottom-up with no leak.
+
+The result is a memory ladder that tracks configuration exactly: module-absent → 0; module-present-but-feature-off → just the class instance; feature-active → `+dynamicBytes()`. The LED driver's output buffer and the MQTT module's discovery-config scratch are the worked examples; the rule governs every module. It matters most on a no-PSRAM ESP32, where the internal-heap reserve (`HEAP_RESERVE`) is the tightest constraint, so a buffer held but unused spends the reserve the render loop, WiFi, and HTTP depend on.
+
### Buffer types
- **Layer buffers**: one per active layer, holds the logical light data for one effect chain. Allocated in PSRAM when available. On memory-constrained devices, consumers may read from the layer buffer directly (no mapping, no blending, no physical buffer needed).
@@ -488,7 +495,7 @@ Network input (ArtNet receive, WebSocket) is processed synchronously at a define
### Adaptive allocation
-The system checks available heap before each allocation and degrades gracefully when memory is insufficient. A minimum reserve (`HEAP_RESERVE = 32 KB`) is kept for stack, HTTP, WiFi, and overhead.
+The system checks available heap before each allocation and degrades gracefully when memory is insufficient (the allocate-on-demand-with-a-cascade decision, over fixed buffers, is [ADR-0002](adr/0002-adaptive-memory-degradation-cascade.md)). A minimum reserve (`HEAP_RESERVE = 32 KB`) is kept for stack, HTTP, WiFi, and overhead.
- **Mapping LUT** is created only if all of: modifiers exist on the layer; layout is not a simple non-serpentine grid (where physical == logical); enough heap available after the reserve.
- **Driver output buffer** (see [§ Drivers](#drivers) for what it's for) is created only when the pipeline must write into physical space rather than hand a driver a layer's logical buffer directly — that is, when **two or more layers are enabled** (they must be composited into one buffer) **or** a layer has a **mapping LUT** actually allocated (logical≠physical) — and enough heap is available. A single enabled layer with no LUT needs no output buffer: drivers read its buffer directly (the zero-copy fast path).
@@ -562,10 +569,3 @@ A module's chips come from three sources, rendered identically on the card and t
| **Audio** (`tags()`) | 🔊 audio-reactive | reads `AudioModule::latestFrame()` |
`tags()` carries **only** origin + creator + audio (+ any genuinely module-specific marker); a module can carry several (e.g. `💫🦅` = MoonLight origin, a named creator). Role and dim are added by the UI, so a module never duplicates them in its string. When migrating, set each module's `tags()` from this legend so the chip set is consistent across the library.
-
-## What we leave undesigned
-
-Genuinely open questions, *not* the same as a 🚧 marker. A 🚧 item has a settled, committed design (two-core handover, clock sync, device-to-device light distribution) — code is written toward it; the items here are ones where the *design itself* is still open, deferred until a concrete need forces the decision:
-
-- **WiFi runtime disable**: today the eth-only build profile compiles WiFi out. Whether runtime gating should key off detected hardware presence, an explicit control, or a deviceModel-catalog field isn't decided; the eth-only build covers the need until one is.
-- **Mixing light types in one Layouts**: each layout child describes one light type (all LED strips, or all par lights). Whether a single Layouts container should hold mixed types (LED strips + par lights together), and how the channel layout would reconcile across them, isn't designed; one Layouts per light type is the current model.
diff --git a/docs/backlog/backlog-core.md b/docs/backlog/backlog-core.md
index 6ff009f9..0705877b 100644
--- a/docs/backlog/backlog-core.md
+++ b/docs/backlog/backlog-core.md
@@ -147,7 +147,7 @@ On `esp32-eth-wifi`, default 128×128 grid, free heap at boot is ~28 KB — not
Fix options in increasing scope:
- **Cap the default grid** — drop to 64×64 on `esp32-eth-wifi` (Layer ~32 KB + LUT ~16 KB = 48 KB, comfortably under). Simplest.
-- **PSRAM for Layer buffer + LUT** — ESP32-Gateway has 4 MB PSRAM unused on non-S3 builds. Moving the 49 KB pixel buffer + 64 KB LUT out of DRAM frees ~110 KB for radios. Cost: ~25% FPS hit (PSRAM bandwidth ~12 MB/s vs DRAM ~80 MB/s); needs measurement. See [decisions.md](../history/decisions.md) "Adaptive memory allocation design" for the allocation rules.
+- **PSRAM for Layer buffer + LUT** — ESP32-Gateway has 4 MB PSRAM unused on non-S3 builds. Moving the 49 KB pixel buffer + 64 KB LUT out of DRAM frees ~110 KB for radios. Cost: ~25% FPS hit (PSRAM bandwidth ~12 MB/s vs DRAM ~80 MB/s); needs measurement. See [lessons.md](../history/lessons.md) "Adaptive memory allocation design" for the allocation rules.
- **Lazy WiFi init** — skip `esp_wifi_init` when `ssid_` is empty and no AP-fallback is pending. Helps only when credentials exist but the network is unreachable — niche.
### Boot-time buffer degradation on non-PSRAM at 128×128 (investigation)
@@ -170,6 +170,10 @@ No FreeRTOS tasks are pinned today. At 16K LEDs the render task takes ~52 ms/tic
## Architecture
+### WiFi runtime disable — open design question (undesigned)
+
+Today the eth-only build profile compiles WiFi out (`MM_NO_WIFI`). Turning WiFi off *at runtime* instead is undesigned: whether the gate should key off detected hardware presence, an explicit control, or a deviceModel-catalog field isn't decided. The eth-only build covers the need until a concrete case forces the choice. (Moved from architecture.md § What we leave undesigned; it's a deferred design decision, not a settled 🚧 one.)
+
### Consolidate the two module-by-name tree-walkers (backlog)
`HttpServerModule::findModuleByName` (`findInTree` recursion) and `Scheduler::firstByName` (`firstInTree`) are two implementations of the same "find the first module in tree-walk order with this name" operation. The duplication predates the `setControl`-to-Scheduler extraction, but that extraction made `Scheduler::firstByName` public *and* added `Scheduler::instance()`, so HttpServer no longer needs its own copy: its ~9 remaining call sites (identify, addModule, removeModule, clearChildren, the WLED/system shims) can call `scheduler_->firstByName()` and the private `findModuleByName`/`findInTree` pair deletes. Purely a *No duplication* cleanup — no behaviour change — worth doing so the walk order/semantics live in exactly one place. (Reviewer note, IrModule/setControl branch.)
@@ -239,7 +243,7 @@ When picked up: add `offsetX/Y/Z` (lengthType) controls to `LayoutBase`; `Layout
### Improv as a child of NetworkModule (deferred — needs scheduler work first)
-Architecturally the right shape; attempted in plan-21, reverted. Blocker: `Scheduler::tick()` only walks top-level modules for `loop20ms`/`loop1s` — children silently miss those callbacks. See [decisions.md](../history/decisions.md) "Trying to add a child module to NetworkModule".
+Architecturally the right shape; attempted in plan-21, reverted. Blocker: `Scheduler::tick()` only walks top-level modules for `loop20ms`/`loop1s` — children silently miss those callbacks. See [lessons.md](../history/lessons.md) "Trying to add a child module to NetworkModule".
Minimum-scope fix before the move:
1. `MoonModule::loop20ms`/`loop1s` propagate to children (or Scheduler walks them) — pick whichever costs less at runtime.
@@ -300,7 +304,14 @@ Explicitly **out** (no practical MQTT case, and wrong for the transport):
**Shape:** ~15 lines routing into the existing `applySetControl` + a state publish; gate behind a `generic API` bool defaulting **off**, so a home user's broker isn't a remote-config backdoor and the curated HomeKit surface stays the clean default. **Don't** auto-generate a semantic topic per control — HomeKit/HA need stable typed topics; a generic `palette/set` whose meaning shifts per firmware breaks their discovery. Keep semantic topics hand-curated; let the generic pair be the escape hatch.
-**If the actual goal is deeper HA (not scripting), the better path is HA MQTT Discovery** — the device announces its controls as HA entities via retained `homeassistant/…/config` topics (the industry-standard pattern, *Common patterns first*), giving HA typed entities instead of a raw JSON pipe. Bigger than the generic pair, and the right lift if HA depth — not power-user scripting — is the target. Related integration threads to keep this coherent with: the [DevicesModule command half](#devicesmodule-interop-plugins-the-command-half-discovery-shipped) (Tasmota-MQTT / zigbee2mqtt as *outbound* control of foreign devices — the mirror of this *inbound* surface), and the [LightsControl integration point](backlog-mixed.md) (the eventual single owner of "device ↔ outside world", MQTT/HA included).
+**HA MQTT Discovery — SHIPPED.** The device announces a retained JSON-schema light config to
+`homeassistant/light//config` (the Tasmota/ESPHome/Zigbee2MQTT pattern), so HA auto-creates a
+wired entity — gated on the `haDiscovery` control, with a Last-Will availability topic. This covered
+the "HA can't reach the device over MQTT" incidents. The *generic-topics escape hatch* above (the
+whole REST API over MQTT, for power-user scripting) is the remaining unbuilt piece; keep it coherent
+with the [DevicesModule command half](#devicesmodule-interop-plugins-the-command-half-discovery-shipped)
+(Tasmota-MQTT / zigbee2mqtt as *outbound* control — the mirror of this *inbound* surface) and the
+[LightsControl integration point](backlog-mixed.md).
**Open question — Homebridge example maps both `setBrightness` and `setHSV`.** In `homebridge-mqttthing`'s `lightbulb`, HSV's *value* (V) already carries HomeKit's Brightness characteristic, so mapping both topic pairs may double-drive brightness (mqttthing's docs lean toward using one or the other). The [MQTT § Homebridge example](../moonmodules/core/services.md#mqtt) currently lists both, to show the device's full topic surface. Resolve on hardware: flash a board, run Homebridge + mqttthing with that config, and check whether the Home-app brightness slider misbehaves — if it does, drop the two `brightness` topics from the example; if not, it's a non-issue. (Flagged by CodeRabbit; parked here rather than changing the doc on an untested hunch.)
diff --git a/docs/backlog/backlog-light.md b/docs/backlog/backlog-light.md
index 64b6d32d..532c8074 100644
--- a/docs/backlog/backlog-light.md
+++ b/docs/backlog/backlog-light.md
@@ -20,7 +20,7 @@ The **classic ESP32 has 8 RMT TX channels** (`platform_config.h`: "8 on classic
### Audio-reactive follow-ups
-The manual level + 16-band FFT spectrum has shipped (AudioModule; what landed and why is in [decisions.md](../history/decisions.md)). These are the deferred follow-ups, each its own increment:
+The manual level + 16-band FFT spectrum has shipped (AudioModule; what landed and why is in [lessons.md](../history/lessons.md)). These are the deferred follow-ups, each its own increment:
- **Per-band noise-floor (kill a steady single-frequency hum)** — the bench mic picks up a constant ~258 Hz tone (a mains harmonic via the mic/supply) that lights one band even in silence. A high-pass can't remove it (it's well above the ~40 Hz DC-blocker cutoff) without also killing real bass; the clean fix is a per-band adaptive floor that learns each band's idle baseline and subtracts it, so a constant tone in one band gates to dark while the others stay sensitive. Minimal version ≈ 16 floats of state + ~16 ops/frame. This is the next concrete audio step.
- **Adaptive conditioning** — auto noise-floor / auto-gain / smoothing so the display self-calibrates to a room ("sound off → dark, sound on → vivid") instead of being tuned by hand. A self-calibrating version was prototyped and removed; the manual `floor`/`gain` is the shipped baseline. Reinvent from scratch when wanted, and **tune it in a quiet room** — a noisy environment (a strong, varying low-frequency ambient) is the adversarial case that made the prototype hard to settle. (The per-band floor above is the first piece of this.)
@@ -110,6 +110,10 @@ The light `Buffer` already holds `channelsPerLight = 4` (RGBW), and the device o
Today a "light" is a point at a static coordinate with a colour. A **moving head** is a fixture that emits a *beam* in a direction it controls live (pan + tilt), plus colour, beam-width, etc. — per-light **vector** state, not just colour, and a different draw (a cone/ray, not a disc). The static-positions-`0x03` + colour-`0x02` split can't express "this fixture's beam now points here." The industry-standard model is **DMX/GDTF fixtures**: a fixture has a position *and* a set of typed attributes (color, pan, tilt, beam). The preview becomes a fixture renderer (disc for a pixel, cone for a beam); this is also the "make Preview a general-purpose module, not light-specific" goal. A domain-model change (the fixture/attribute model), not just transport. Plan when moving heads are actually on the bench.
+### Mixing light types in one Layouts — open design question (undesigned)
+
+Today each layout child describes one light type (all LED strips, or all par lights), and the current model is one Layouts container per light type. Whether a single Layouts should hold mixed types (LED strips + par lights together), and how the per-channel layout would reconcile across them, isn't designed. Deferred until a concrete need forces it; it's adjacent to the fixture model above (a real fixture/attribute model may reframe how mixed types are expressed). (Moved from architecture.md § What we leave undesigned; a deferred design decision, not a settled 🚧 one.)
+
### Extract the resumable backpressure transport as a domain-neutral channel (long term)
The preview's transport — resumable cross-tick send from a stable buffer + newest-wins backpressure drop + adaptive graceful degradation (see [architecture.md § graceful degradation under transport backpressure](../architecture.md)) — is **payload-agnostic**: any bulky throttled stream (a future MJPEG/video preview, fixture-state streams, fleet telemetry) could ride it. The *payload* model (count/stride/RGB) is light-specific; the *byte-pump* is not. When a second consumer for this transport appears, promote the pump into a domain-neutral core primitive (a `ThrottledChannel`-style sink) that PreviewDriver becomes *a* producer on, rather than owning the protocol. Concrete-first: extract on the second use, not before — until then the seam stays inside HttpServerModule/PreviewDriver.
@@ -131,7 +135,7 @@ For driving **lots of LEDs**, internal SRAM is the scarce resource and the paral
## LED drivers — deferred
-The LED-driver increments **shipped**: increment 1 (RMT/WS2812B single-strand on classic ESP32 — [`RmtLedDriver.h`](../../src/light/drivers/RmtLedDriver.h), `RmtSymbol.h`, `platform_esp32_rmt.cpp`) and increment 2 (2a multi-pin RMT, 2b parallel LCD_CAM on the S3 — [`LcdLedDriver.h`](../../src/light/drivers/LcdLedDriver.h) via [`ParallelLedDriver.h`](../../src/light/drivers/ParallelLedDriver.h), `platform_esp32_lcd.cpp`), all with host + on-board-loopback tests, hardware-proven. The locked decisions, file-by-file phases, the WiFi-flicker test-rig analysis, and the bench deviations (8-GPIO i80 bus, 2.67 MHz slot clock, SOC-macro gate, real-frame loopback) are in [decisions.md](../history/decisions.md), the [driver docs](../moonmodules/light/moxygen/RmtLedDriver.md), and the [analysis docs](leddriver-analysis-top-down.md). What remains here is only the work that has **not** shipped and is tracked nowhere else.
+The LED-driver increments **shipped**: increment 1 (RMT/WS2812B single-strand on classic ESP32 — [`RmtLedDriver.h`](../../src/light/drivers/RmtLedDriver.h), `RmtSymbol.h`, `platform_esp32_rmt.cpp`) and increment 2 (2a multi-pin RMT, 2b parallel LCD_CAM on the S3 — [`LcdLedDriver.h`](../../src/light/drivers/LcdLedDriver.h) via [`ParallelLedDriver.h`](../../src/light/drivers/ParallelLedDriver.h), `platform_esp32_lcd.cpp`), all with host + on-board-loopback tests, hardware-proven. The locked decisions, file-by-file phases, the WiFi-flicker test-rig analysis, and the bench deviations (8-GPIO i80 bus, 2.67 MHz slot clock, SOC-macro gate, real-frame loopback) are in [lessons.md](../history/lessons.md), the [driver docs](../moonmodules/light/moxygen/RmtLedDriver.md), and the [analysis docs](leddriver-analysis-top-down.md). What remains here is only the work that has **not** shipped and is tracked nowhere else.
- **sigrok/fx2lafw cross-check + MoonDeck "LED driver test" Python script** — the independent-clock proof and the run-from-MoonDeck flow ([analysis §5.3](leddriver-analysis-top-down.md)). The on-board RMT-RX loopback (shipped) is the cheap CI correctness gate but a *compromised witness* for WiFi-induced flicker — the RX capture runs on the same ESP32 whose WiFi causes the glitch. The real flicker test is a **sustained capture (seconds) with WiFi associated + a packet flood**, decoding every frame for a byte-slip or reset-gap deviation; it belongs with the core-1 driver-task work below, since that task pinning is the *fix* it validates. A DSLogic Plus (100 MS/s) upgrade is reactive — only if a flicker reproduces that 24 MS/s can't resolve.
- **Dedicated core-1 driver task + per-module core-affinity control** ([analysis §7.2](leddriver-analysis-top-down.md)) — the WiFi-glitch mitigation, shared across all the LED drivers. (See also [backlog-core § Task core-pinning](backlog-core.md#task-core-pinning-backlog) for the general task-pinning question.)
diff --git a/docs/backlog/livescripts-analysis-top-down.md b/docs/backlog/livescripts-analysis-top-down.md
index 2aa2dfdd..76dae106 100644
--- a/docs/backlog/livescripts-analysis-top-down.md
+++ b/docs/backlog/livescripts-analysis-top-down.md
@@ -378,7 +378,7 @@ The first sequencing question is **depth-first** (build the whole engine on Xten
The [MoonLight effects tutorial](https://moonmodules.org/MoonLight/moonlight/effects-tutorial/) is a ready-made *start-small-grow* curriculum (random pixel → control → trails → oscillators → 2D → 3D → audio → Cosmic Noise). Each rung is **one engine-capability spike** with a concrete acceptance bar. **RipplesEffect.h is the graduation test** (the hard real effect, after the 3D rung). Each step is a normal small commit; the multi-target part is sequenced per §9.1 — the *seam* is proven on a second ISA at Stage 0.5, but the *full* second backend (all stages) comes later, opportunistically.
-**Next up: Stage 2 (buffer read-modify-write / trails).** Stages 0, 0.5 and 1 (Controls) are done — what landed and why is recorded in the git history and [decisions.md](../history/decisions.md); this ladder tracks the remaining rungs.
+**Next up: Stage 2 (buffer read-modify-write / trails).** Stages 0, 0.5 and 1 (Controls) are done — what landed and why is recorded in the git history and [lessons.md](../history/lessons.md); this ladder tracks the remaining rungs.
| Stage | Capability proven | Acceptance bar (the spike) |
|---|---|---|
diff --git a/docs/coding-standards.md b/docs/coding-standards.md
index a86df7ac..721217a5 100644
--- a/docs/coding-standards.md
+++ b/docs/coding-standards.md
@@ -34,6 +34,12 @@ Guidelines:
Counter-example to avoid: storing `char rssiStr_[12]` and re-`snprintf`'ing `"-58 dBm"` into it every tick. The right shape is `int8_t rssi_` (1 byte) plus a control type that knows the unit. Saves 11 bytes per metric, scales linearly across the codebase.
+**Width the intermediate, not just the result.** Any `a * b` where both operands are `nrOfLightsType` (or a count times a multiplier) can overflow `uint16_t` even when each operand is individually small (`256 * 256 = 65536` wraps to 0 on a no-PSRAM device); do the arithmetic in a wider type (`uint64_t`), clamp to the ceiling, then narrow. Likewise a counter derived from a cell count (a delta, a stagnation check) must be the domain typedef (`nrOfLightsType`), not a fixed `uint16_t`. A width-sensitive path is invisible on the uint32 desktop build, so pin it with a `uint16`-typed unit test or hardware confirmation.
+
+**When a validation field's storage is narrower than what it claims to validate, the validation is wrong, not the field.** A `uint8_t` min/max slot can't bound an `Int16` control (it clamps to `[0..0]`); the fix is a wider bound (or per-type bound slots), and until then the constraint is documented at the field's declaration.
+
+**When one control type does two jobs with different UX, that's the smell for a new type, not a range hack.** An `int16` control the UI renders as a slider can't also mean "GPIO pin number"; a dedicated `Pin` type (smallest storage that fits the domain, `int8_t` for a GPIO) is the fix, not overloading the range.
+
## Per-type behaviour lives with the type
When a struct or enum is the semantic owner of some data — a control descriptor, a packet, a module role — the functions that interpret, serialise, validate, or otherwise operate on it should live next to the type, not at the call sites that use it. Free functions in the same `.cpp` count; member methods on the owning class are stronger; virtual methods on a base class are strongest. The wrong shape is the same `switch (type)` repeated in every consumer — adding a variant means hunting across N files for switches to extend, and the compiler can't tell you when one gets missed.
@@ -48,6 +54,10 @@ Counter-example to avoid: a `switch (c.type)` on `ControlType` duplicated in Htt
When a `switch (type)` outside the type's home file is legitimate: the caller has a genuinely different concern (HttpServerModule mapping `ApplyResult` to HTTP status codes is a transport policy, not per-type behaviour; scenario_runner's `switch (JsonVal::type)` dispatches on *its own* discriminator, not `ControlType`). The rule is "per-type dispatch lives with the type", not "switches are banned".
+**A flat parser that returns a zero sentinel for a missing key must be paired with a presence check before the value is applied as authoritative.** `json::parseInt(json, key)` returns 0 for an absent key, indistinguishable from a real 0; on a persistence-overlay load path that clobbers a non-zero default when an older/partial file omits the key. Guard with `json::hasKey()` first: an absent key leaves the control untouched (its default stands); a present key (even value 0) applies. Any "control resets to its default/0 after reboot" symptom is this overlay smell, not a control-init bug.
+
+**A reader that decodes only a subset of the escapes its writer emits is a latent asymmetry bug.** If the writer emits `\n` / `\t` escapes, the reader must decode them (not only `\"` / `\\`); make the escape set symmetric so multi-line text round-trips.
+
## File shape: header-only vs `.h` + `.cpp`
- **Light-domain modules and the `MoonModule` base: header-only.** Every effect, modifier, driver, layout, the light-domain containers (`Layouts`, `Layers`, `Drivers`, `Layer`), and the `MoonModule` base class live in a single `.h` with implementation inline. The benefit is concrete: a contributor copies `RainbowEffect.h`, edits, saves as `MyEffect.h`, registers one line in `main.cpp` — no "where does the `.cpp` go, what does CMake need" friction. The chain `RainbowEffect.h → EffectBase.h → MoonModule.h` is uniform; readers don't pivot to a different file shape at the base. When a light-domain file outgrows one concern, extract a helper into its own header (`BlendMap`, `MappingLUT`) rather than splitting to `.h` + `.cpp`. Header-only is a feature of the light domain.
@@ -96,7 +106,7 @@ All targets build warnings-as-errors: `-Wall -Wextra -Werror` on Clang/GCC (macO
## Static checks
- **Platform boundary** (`moondeck/check/check_platform_boundary.py`) — scans all files outside `src/platform/` for `#ifdef` / `#if defined` with platform macros and `#include` of platform-specific headers (`esp_*`, `freertos/*`, `driver/*`, `SDL.h`, `wiringPi.h`, …). Fails if any are found. The platform boundary rule itself: [architecture.md § Platform abstraction](architecture.md#platform-abstraction).
-- **Hot path lint** — flags allocation calls (`new`, `malloc`, `make_unique`, `make_shared`, `push_back`, `std::string` constructors) inside functions identified as hot path (render loop and callees). A code-review convention, enforced by hand. The hot path rule itself: [architecture.md § Hot path discipline](architecture.md#hot-path-discipline).
+- **Hot path lint** — flags allocation calls (`new`, `malloc`, `make_unique`, `make_shared`, `push_back`, `std::string` constructors) inside functions identified as hot path (render loop and callees). A code-review convention, enforced by hand. The hot path rule itself: [architecture.md § Hot path discipline](architecture.md#hot-path-discipline). A "no blocking in the hot path" audit must sweep *every* syscall the path can reach (connect, DNS, read, *and* write), not just the loudest one: a single-threaded loop that services I/O must make no blocking call at all (a socket timeout is not a fix, it is the size of the freeze; non-blocking + poll is the only safe shape). Fixing one blocker while an equally-blocking sibling survives is a partial fix that reads as complete.
- **Code formatting** — `clang-format` with a project `.clang-format` file. Applied in CI; code that doesn't match fails the check. Run locally via editor integration or `clang-format -i`.
## When checks run
@@ -148,3 +158,25 @@ A module's story therefore lives in exactly two places: its `.h` (technical, gen
### Test inventories: their own generator, not moxygen
`docs/tests/*.md` is generated by [`moondeck/docs/generate_test_docs.py`](../moondeck/docs/generate_test_docs.py), not moxygen. Unit tests are `TEST_CASE("…")` macros tagged with `// @module` and per-case `//` descriptions — a convention Doxygen documents nothing of (it parses C++ *entities*, not macro string literals) — and scenario tests are JSON, which moxygen cannot read. moxygen is for the `.h` module pages; the test generator owns the test pages.
+
+## Defaults
+
+**Assign a default only where the hardware, not the user's soldering iron, fixes the value.** The test is *who fixes the pin/setting*:
+
+- **Chip-/board-fixed → default it, and you must.** The RMII Ethernet pin map, the on-board status LED, a country code per region: silicon-/PCB-wired, so a default cannot do harm, and *omitting* it does (a no-WiFi board with un-defaulted Ethernet pins can never connect to be configured, a chicken-and-egg lockout).
+- **User-soldered → leave it unset.** A MEMS mic, an LED strand, an LED-driver pin goes wherever the user ran the wire, so any default is a guess that can drive a pin the user committed elsewhere. Empty until set; idle with a "set pins" status meanwhile (degraded is fine, crashed is not).
+
+A "default" that is really one specific board's values is bespoke masquerading as standard (a § Principles violation): make the capability opt-in and require each consumer to state its own values, so a missing declaration fails loudly instead of inheriting a stranger's wiring. Never auto-run a peripheral whose init can block on absent hardware. The design rationale (the MCU → deviceModel provenance model) lives in [architecture.md § Config provenance](architecture.md#config-provenance-mcu-devicemodel).
+
+## Debugging and verification
+
+Hard-won discipline for diagnosing hardware and infrastructure failures, distilled from the war stories in [lessons.md](history/lessons.md).
+
+- **Prove the failure is *about* the change before editing code.** When something fails right after a change, re-run it isolated, probe the actual end state (ping/curl the device, read the real CI error line, check machine load), and confirm the artifact under test is the one you built (process uptime, `build` timestamp, what is bound to the port). A stale process, a loaded machine, or an async-confirmation timeout reads as a regression it isn't.
+- **A status/dimension assertion does not prove the pipeline renders.** A correctness test for a mapping or effect asserts the buffer or LUT is non-empty with the expected coverage (e.g. LUT destinations == physical light count), not just that the declared dimensions look right.
+- **For a hardware bring-up, "it compiles" tells you almost nothing.** The truth is in the boot log on the actual board; a min-revision trap, a wrong PHY pin, a filename-keyed capability gate, or a Kconfig *choice* that an incremental build silently keeps all build clean and fail only on hardware (`rm -rf` the build dir when a sdkconfig choice changes).
+- **When test and reality disagree, enumerate what the test abstracts away and make the test transmit the genuine article.** Each closed gap either finds the bug or eliminates a theory with proof (a whole-frame loopback that sends the driver's real frame, not a synthetic pattern). Prove the firmware is *not* the cause with a measurement at each layer before editing code or buying parts.
+- **A measurement tool must be faithful to the real client, or it invents and hides bugs.** A one-shot WebSocket probe that gives up on close reports stalls a reconnecting browser never sees and misses blips it does; match the client's real behaviour (keepalive ping, auto-reconnect).
+- **Stop at the first failed fix on a working path.** Revert to the known-good state at attempt two rather than re-engineering a seam that already worked (§ *Anti-stalling* in CLAUDE.md).
+- **A generated artifact's ground truth is the rendered output, not a re-derivation.** Verify a doc anchor against the built HTML (`grep id=` the `.html`), not a reimplementation of the slug algorithm; verify an emitted machine instruction against the real toolchain's disassembler before flashing.
+- **A cross-boundary fact duplicated in code drifts silently; gate it.** A `docPath` in `main.cpp` that points at a docs page, a firmware projection that mirrors a build dict: the moment a check can resolve it against ground truth, that check is cheaper than the drift.
diff --git a/docs/history/README.md b/docs/history/README.md
index 8700da2b..cdadf334 100644
--- a/docs/history/README.md
+++ b/docs/history/README.md
@@ -31,7 +31,7 @@ One-time surveys of earlier projects, used to decide what to harvest into projec
### Our own lessons
-- [decisions.md](decisions.md) — hard-won lessons, proven patterns, and non-obvious decisions, recorded with the code that proved them (the PR-merge carry-forward gate writes here).
+- [lessons.md](lessons.md) — hard-won debugging lessons and gotchas (a bug, its cause, the fix), recorded with the code that proved them and pruned as they are absorbed (the PR-merge carry-forward gate writes here). Genuine architectural *decisions* live in [`../adr/`](../adr/README.md) instead; a lesson that hardened into a *rule* lives in CLAUDE.md / coding-standards.md.
## Cross-repo trends
diff --git a/docs/history/decisions.md b/docs/history/decisions.md
deleted file mode 100644
index b5d2710e..00000000
--- a/docs/history/decisions.md
+++ /dev/null
@@ -1,861 +0,0 @@
-# Decisions and Lessons Learned
-
-## Actions for the Future
-
-Distilled from four project iterations (MoonLight, v1, v2, v3). These
-are concrete rules, not aspirations.
-
-### Before writing any code
-
-1. **CLAUDE.md is the constitution.** Write it first. Include: platform
- boundary rule, memory rules, build commands, code style, agent
- workflow restrictions. Vague rules produce vague code.
-2. **architecture.md must be stable.** Don't start coding while the
- architecture is still being debated. Every mid-implementation
- architecture change costs more than the original design time.
-3. **Write module specs before module code.** Each docs/modules/*.md
- defines: purpose, controls, behavior, edge cases, interactions.
- These are specifications, not reverse-engineered documentation.
-4. **Write a UI specification.** Design the UI before implementing it.
- Define layout, controls, API, interaction rules. Don't discover
- the UI through bug reports.
-5. **Define integration tests upfront.** Unit tests per component are
- necessary but not sufficient. Define full-pipeline tests that
- exercise the path from effect → buffer → LUT → blend+map → driver
- output.
-
-### During implementation
-
-6. **Concrete first, abstract later.** Build one working feature
- end-to-end before extracting patterns into abstractions. Don't
- design the MoonModule base class before the first effect works.
-7. **Use /plan for every feature.** Review plans for: unnecessary
- files, inheritance where structs suffice, modifications outside
- the relevant directory. Reject and regenerate bad plans.
-8. **Commit after every small success.** Git is the undo button. If
- the agent fails a build twice, rollback and re-prompt. Don't let
- it spiral into a fix-up loop.
-9. **The agent never stages or commits.** The product owner stages,
- reviews, and commits manually.
-10. **Refactor first, create second.** Prefer extending existing code
- over adding new files. Deletion is preferred over duplication.
- Every addition must pay for itself.
-
-### Technical rules (embedded-specific)
-
-11. **Zero allocations in the render loop.** No malloc, new, push_back,
- std::string in any code path called per-frame. Enforce with a
- test that intercepts malloc/free.
-12. **PSRAM for bulk, IRAM for speed.** Large contiguous buffers
- (light buffers, LUTs) in PSRAM at boot. Never scattered small allocations
- in PSRAM during loops.
-13. **Pace all network output.** Never blast UDP packets in a tight
- loop. Add inter-packet delay and FPS limiting. Missing pacing
- looks like rendering bugs but is network flooding.
-14. **Use virtual interfaces, not dynamic_cast.** Modules interact
- through virtual methods. Layer should not know about MultiplyModifier
- or CheckerboardModifier by name.
-15. **Rebuild propagation must be in the framework.** Don't check dirty
- flags per-module in main.cpp. Use an event/observer system or a
- centralized pipeline-changed signal.
-16. **Separate build entry points per platform.** Root CMakeLists.txt
- for desktop, esp32/ wrapper for IDF. Don't pollute CMake files
- with if(ESP_PLATFORM) conditionals.
-
-### Documentation and process
-
-17. **Present tense only.** Docs describe the system as it is. Git
- commits are the history. No changelogs, no sprint files, no
- roadmaps in the repo.
-18. **Single source of truth.** CLAUDE.md (rules), architecture.md
- (design), docs/modules/*.md (specs). Nothing else. If it's
- duplicated, delete one copy.
-19. **Mandatory subtraction.** Periodically review and remove code
- and docs that no longer earn their place.
-20. **Test suite is the safety net.** For both human and AI
- development. If the agent breaks a test, it broke the system.
- No exceptions, no skipping.
-
----
-
-## Lessons from MoonLight (WLED fork)
-
-### Memory and resource constraints
-- On classic ESP32 (no PSRAM, 4MB flash), the framework's footprint
- left little room for effects. OTA updates and expandability were
- limited by tight resource budgets.
-
-### Upstream dependency burden
-- Diverging from the upstream framework (ESP32-SvelteKit) required
- tracking 1,271+ local changes to survive rebases. Ongoing
- maintenance friction.
-- Feature implementation was delayed waiting for upstream adoption
- (Ethernet, async webserver).
-
-### Testing was retrofitted, not foundational
-- Automated tests only verified trivial cases. Complex interactions
- required code restructuring that broke encapsulation.
-- Testing should be designed into the architecture from day one, not
- bolted on later.
-
-### Sustainability
-- Single maintainer carrying ~95% of workload while issue backlog
- grew with user base.
-- Actively-developed system frequently broke previously working
- features — no regression safety net.
-
-### What to carry forward
-- Platform abstraction (zero `#ifdef` in module code) is essential.
-- Self-describing UI (JSON-driven, no frontend code per new module).
-- Test suite as safety guardrail for both human and AI development.
-
-### Proven patterns from MoonLight (harvest for v3)
-
-- **PhysMap — 2-byte mapping entries on no-PSRAM.** Union packing: map type (2 bits) + physical index or RGB cache (14 bits) in a single uint16_t. On PSRAM: 4 bytes with 24-bit indices. The map type is stored IN each entry, not in a separate array. Saves memory vs v3's CSR approach which uses a separate offsets array.
-- **nrOfLights_t typedef.** `uint16_t` on no-PSRAM, `uint32_t` on PSRAM. Compile-time selection. Proven in production. Same concept as v3's `nrOfLightsType`.
-- **addControl binds to class variable by reference.** Control stores a `uintptr_t` pointer to the variable. Hot-path code reads the variable directly — zero overhead. UI updates write through the pointer. Supports uint8_t, int8_t, uint16_t, uint32_t, int, float, bool, Coord3D.
-- **Node — minimal memory.** Base class ~29 bytes + vtable. Effects add only their control variables (uint8_t each). A typical effect adds just 2 bytes on top. No std::string members.
-- **LightsHeader — one struct for LEDs AND DMX fixtures.** Configurable `channelsPerLight` (3=RGB, 4=RGBW, up to 32 for moving heads) with offset fields for red/green/blue/white/pan/tilt/zoom/rotate/gobo. This IS the "light = LED pixel or DMX fixture" concept.
-- **Region start/end percentages.** MoonLight's `startPct`/`endPct` as Coord3D percentages (0-100) of the total fixture. Shipped in v3 as a modifier, not as Layer controls.
-- **oneToOneMapping / allOneLight fast paths.** Boolean flags that skip the mapping table entirely when mapping is 1:1 identity. Essential for the no-PSRAM 12K LED case.
-- **Transition brightness.** Per-layer animated brightness overlay (current, target, step). Enables smooth fade-in/out when switching effects. Should be added to v3.
-- **SharedData — zero-allocation inter-node communication.** Single struct shared by all nodes: 16-band FFT audio, volume, beat detection, gravity (IMU), status. Lightweight alternative to pub/sub.
-- **Coord3D with rich operators.** Full arithmetic (+, -, *, /, %), comparison, distanceSquared, isOutofBounds. Uses `int` (not int16_t) to avoid intermediate overflow.
-- **10+ layout types proven.** Panel, Ring, Rings241, HexaPanel, Cloud, Human, Cone, Globe, SpiralGlobe — all algorithmic, no stored positions.
-- **60+ effects across 5 libraries.** MoonLight originals, MoonModules community, WLED ports, FastLED demos, SoulmateLights.
-- **11 driver types.** FastLED, Parallel LED, Hub75, ArtNet/E1.31/DDP in/out, DMX in/out, audio (WLED + FastLED), IMU, infrared.
-
----
-
-## Lessons from projectMM v1
-
-### Module system collapse
-- `StatefulModule` conflated five distinct jobs: lifecycle, JSON
- descriptors, persistence, child management, and timing windows.
- Should have been separate concerns.
-
-### PAL boundary erosion
-- Only ~50 of 1,397 PAL lines handled actual platform abstraction.
- The rest was networking, OTA, NTP, and system queries that should
- have been modules. The PAL grew into a kitchen-sink.
-
-### Manager overloading
-- `ModuleManager` became REST adapter, memory accountant, state
- persister, and dirty-flag debouncer simultaneously. Too many
- responsibilities in one class.
-
-### Documentation bloat
-- 90 markdown files across ~15,400 lines. A contributor cannot grasp
- the core concept in five minutes.
-- Architecture documentation read as historical narrative rather than
- prescriptive design.
-- Per-module documentation duplicated JSON schemas already rendered
- by the UI.
-
-### Testing pyramid never materialized
-- Four parallel test surfaces (unit, live, scenario, code-analysis)
- were created independently and never unified despite each solving
- real problems.
-
-### What worked
-- Line-count tagging per file enabled fast assessment of bloat.
-- Frugality principle: every addition must pay for itself.
-- Growth budgets with CI enforcement prevented drift.
-- Architecture as constraint document (not narrative).
-
-### Key insights for v2+
-- Guardrails precede code. Framework enforced before first module
- written prevents drift accumulation.
-- Mandatory subtraction: every release must remove something.
-- Recurring evaluation sprints to catch bloat early.
-
----
-
-## Lessons from projectMM v2
-
-Extracted from design review session with Gemini (2026-05-18).
-
-### The "Architecture Astronaut" trap
-- v2's "maximize minimalism" philosophy led to over-engineering minimal
- abstractions. 19 sprints for release 1 is a red flag — the agent was
- caught in a loop of refactoring abstractions instead of shipping
- concrete features.
-- When agents are told to be "minimal," they write highly abstract,
- overly clever code (template metaprogramming, deep trait trees) to
- keep things "elegant." On embedded systems, minimalism means flat,
- simple, predictable code — not abstract code.
-
-### The MoonModule base class dilemma
-- The generic MoonModule lifecycle (setup, loop, loop20ms, loop1s,
- teardown) tried to be an OS-level framework before the domain logic
- (LED pipeline) was working.
-- Giving an agent a highly abstract concept like "a generic lifecycle
- module that handles UI, WiFi, and LED drivers simultaneously"
- generates massive boilerplate, interfaces, and registries.
-- Solution: build concrete features first, extract common patterns
- later. "Concrete first, abstract later."
-
-### Uncontrolled creation
-- If you let an agent write code, tests, and build scripts without
- strict guardrails, it will solve every new problem by creating more
- code. It doesn't care about technical debt because it doesn't have
- to maintain it.
-- The "Refactor First" rule: when adding a feature, prefer extending
- an existing module over creating a new file. Deletion is preferred
- over duplication.
-
-### Sprint file accumulation
-- Using release/sprint files as "AI short-term memory" backfired.
- When the agent reads its own past sprint text, it treats abandoned
- or over-engineered ideas as strict instructions. Outdated
- documentation wastes context window.
-- Solution: single source of truth (CLAUDE.md + architecture.md).
- Git history is the log. Active workspace stays clean.
-
-### Context drift and compilation loops
-- When the agent hits a compilation error, it tries to rewrite
- multiple files to fix it, bloating the context window. If it fails
- twice, it's stuck in a context loop.
-- Solution: rollback with Git immediately. Don't let the agent keep
- trying. Approach the bug with a different, tighter prompt.
-
-### CLAUDE.md as "Constitution"
-- CLAUDE.md must be written BEFORE any code. It's the project's
- constitution. If it's weak or vague, the agent exploits the
- loopholes and writes messy code.
-- Must include: exact compiler flags, memory rules, error handling
- limits, style guide, platform boundaries.
-- Anti-stalling clause: "If a build error takes more than 2 attempts
- to fix, STOP and ask the human."
-
-### /plan mode is essential
-- Must use /plan before every feature. It's the design review phase.
-- Red flags to watch for in plans: adding dependencies when a small
- function would do, introducing inheritance when structs suffice,
- modifying files outside the relevant directory.
-- Reject bad plan steps explicitly. Only approve when the plan is
- minimal and precise.
-
-### Git as the undo button
-- Commit after every small successful feature. Be ruthless with
- rollbacks.
-- The agent has direct file system access. If it panics on a build
- error, it writes 500 lines of messy fix-up code. `git reset --hard`
- is faster than debugging the mess.
-
-### Build system
-- PlatformIO vs CMake: PlatformIO handles toolchains well for ESP32
- but its `native` desktop support is limited. CMake is ESP-IDF's
- native build system. Dual-purpose CMakeLists.txt that works for
- both desktop and idf.py is the cleanest approach, but risks
- conditional pollution. Separate entry points (root CMake for
- desktop, esp32/ wrapper for IDF) is pragmatic.
-
-### Memory and PSRAM
-- Banning PSRAM entirely is wrong for 10K+ LEDs. A single RGB frame
- is 30KB. With double-buffering and LUTs, you exceed 320KB internal
- SRAM easily.
-- OPI PSRAM (80-120MHz, 16-bit bus) has sufficient bandwidth for
- sequential light data streaming.
-- The rule: PSRAM allowed for large contiguous bulk allocations at
- boot. Never small scattered allocations in loops.
-
-### ArtNet packet pacing
-- Blasting 97 UDP packets per frame with zero delay causes receiver
- packet drops. Inter-packet delays (50us) and FPS limiting are
- required.
-- The absence of pacing produced symptoms that looked like rendering
- bugs (missing lights, random output) when the actual issue was
- network flooding.
-
-### Proven patterns from v2 (harvest for v3)
-
-- **DataBuffer — lock-free single-slot SPSC.** Atomic revision counter with acquire/release semantics. Zero branches on hot path. Producer calls `acquire_write()` + `publish()` (one atomic store). Consumer calls `try_acquire_read()` (two atomic loads, nullptr if no new frame). Multiple consumers each track their own read position. Teardown-safe via invalidate() sentinel.
-- **DataRegistry — type-erased buffer directory.** Producers declare buffers by id, consumers resolve by id. Hot-path cost: zero (consumers cache the pointer). Domain-neutral: stores void* + count + elem_size + dimensions.
-- **onBuildControls() / onAllocateMemory() separation.** Controls registered in onBuildControls() (supports rebuild via clearControls()), memory allocated in onAllocateMemory() (sets moduleAllocBytes_ — single source of truth). Better than v1's "do everything in setup()".
-- **PAL split into one file per concern.** PalHeap, PalRtos, PalUdp, PalWifi, PalFs, PalHttp, PalWs, PalSystemInfo. Each has ESP32 and PC implementations. Clean, focused, testable.
-- **PixelEffectBase — shared effect spine.** Eliminates ~70 lines of boilerplate per effect. Concrete effect implements only `build_effect_controls()` + `render_(px, w, h, d)`. Base handles layout resolution, buffer management, teardown safety.
-- **Multi-core scheduler with per-module core affinity.** Effects on core 0, drivers on core 1. Module declares `coreAffinity()` in constructor. Scheduler pins tasks via PalRtos.
-- **Canvas view (UI).** v2 introduced a node-graph canvas view alongside the traditional tree view. Modules shown as draggable nodes with SVG connection lines. Powerful for understanding pipeline topology. Note: introduces significant UI complexity — adopt carefully in v3.
-- **AutoWireSpec — declarative input wiring.** Modules declare dependencies as data (inputKey, searchType, allMatches, backKey). ModuleManager auto-wires at startup. Eliminates manual strcmp chains.
-- **Footprint reporting — zero boilerplate.** classSize set once at registration via `register_type()`. No CRTP, no macro. dynamicMemorySize() = moduleAllocBytes_ + framework overhead.
-- **Field order optimized for padding.** MoonModule fields ordered 8B → 4B → 2B → 1B, saving 24 bytes vs naive order. Matters when many modules are loaded.
-- **Separate frontend files.** index.html + app.js + style.css (not one monolithic HTML). Easier to maintain.
-
-### Map-on-the-fly vs separate Map stage
-- A separate Map stage that copies between logical and physical
- buffers doubles memory usage.
-- Map-on-the-fly (applying the LUT during generation or blend) avoids
- intermediate buffers and halves memory.
-- But: blend+map writes to arbitrary physical positions (not
- sequential), so the output buffer must be fully populated before
- any driver reads from it.
-
----
-
-## Lessons from projectMM v3
-
-### Product owner as critical success factor
-
-The single biggest improvement in v3's approach: the human is an active, hands-on product owner — not a passive requester. In v1 and v2, the agent had significant autonomy: it designed, implemented, tested, and committed with light oversight. The result was bloat, architectural drift, compounding bugs, and code the human couldn't fully understand.
-
-In v3, the product owner:
-- Reviews every line of generated code before committing
-- Specifies requirements in detail — the agent asks, it doesn't guess
-- Controls all git operations (staging, committing, pushing)
-- Tests on real hardware before approving
-- Questions design choices ("why static_cast here?", "is this future-proof?", "do we need this?")
-- Catches overengineering early ("that's too much code for something we might change later")
-- Rejects suggestions that add complexity without clear value
-
-This is the fundamental lesson: in agentic coding, the agent writes code but the human must think. The agent is a tool, not a decision-maker. Tight human control produces cleaner, simpler, more predictable systems than giving the agent autonomy.
-
-### Specs-before-code works
-
-Writing module specs before implementation prevented the architectural drift that plagued v1 and v2. Each spec documents: purpose, controls, behavior, edge cases, prior art. When the code deviates from the spec, one of them is wrong — the spec serves as the reference. One source of truth per module: the spec lives in `docs/moonmodules/`, deleted nowhere else.
-
-### Zero-copy preview driver (memory lesson)
-
-The initial PreviewDriver allocated a 49KB frame buffer to copy pixel data before sending via WebSocket. Following MoonLight's pattern (drivers read directly from the physical buffer), we eliminated the copy — saving 49KB on ESP32 without PSRAM. The lesson: always check if an existing buffer can be reused before allocating a new one.
-
-### WebSocket GUID typo cost hours
-
-A single wrong character in the RFC 6455 magic GUID (`5AB5FDF632E5` instead of `C5AB0DC85B11`) caused the WebSocket handshake to fail silently — the SHA-1 was correct, the response format was correct, but the browser rejected it. The accept key matched our computation but not the browser's because the GUID was wrong. Lesson: when implementing protocols, verify against the RFC test vectors, not just internal consistency.
-
-### KPI tracking in every commit
-
-Adding standardized KPIs (binary size, FPS, heap usage, test count, lizard warnings) to every commit message makes performance regressions visible in git history. The `collect_kpi.py --commit` script automates this.
-
-## Lessons from projectMM v3 (this project)
-
-### Single-file MoonModules are good
-- Keeping each MoonModule in a single .h file (no .cpp) reduces
- file count and makes authoring easy. A developer creates one file
- and implements the interface. This pattern should be kept.
-
-### Controls work well
-- The fixed-capacity control array with typed values (uint16, bool,
- text) and auto-rendering in the UI is a good pattern. Adding a
- control to a MoonModule automatically makes it visible and
- editable. No UI code changes needed.
-
-### The plan.md pattern works
-- Having a plan.md that shrinks as steps are completed gives clear
- progress visibility. Removing completed steps keeps the document
- focused on what's next.
-
-### Generic children eliminate boilerplate
-- Moving children array + addChild/removeChild to MoonModule base eliminated ~120 lines of duplicated code across Layer, DriverGroup, LayoutGroup. The typed arrays (effects_, modifiers_, drivers_, layouts_) and typed add methods were unnecessary — role() distinguishes child types at the call site.
-
-### Dynamic arrays over fixed-size
-- Replacing fixed arrays (MAX_CHILDREN=8, ControlList<8>) with grow-on-demand eliminated arbitrary limits. Leaf modules pay zero cost (nullptr). No hot-path impact — allocation happens during setup only.
-
-### classSize via template, not per-class override
-- ModuleFactory::registerType() captures sizeof(T) automatically. Eliminated 10 boilerplate `classSize() const override` lines. Same pattern as v2's register_type.
-
-### Naming matters
-- `isOneToOne()` was misleading — "1:1" includes both sequential and shuffled. Renamed to `hasLUT()` (asks the real question: is there a table?). `setIdentity()` for the no-table case. Four mapping types defined: 1:1 identical, 1:1 shuffled, 1:0 unmapped, 1:N multimap.
-- Semantic variable names (`availableHeap` not `available`, `internalHeap` not `internal`) — a reader should understand without looking at the assignment.
-
-### Per-module timing reveals bottlenecks
-- tickTimeUs as primary metric (FPS derived) exposed that ArtNet takes 51% of frame time on ESP32 128x128. Without per-module timing this would be invisible.
-- Memory reporting (classSize + dynamicBytes per module) revealed the LUT over-allocation: maxMultiplier() on MirrorModifier saved 64KB by using 4x instead of hardcoded 8x.
-
-### Live scenarios must be non-destructive
-- Initial implementation created modules on the running device without cleanup — the device ended up slower with extra effects. Fix: track created modules, delete them after each scenario. Show `=` for existing vs `+` for new.
-
-### freeHeap vs freeInternalHeap
-- On ESP32 with PSRAM, freeHeap() returns combined (internal + PSRAM). The HEAP_RESERVE check must use freeInternalHeap() because stack/HTTP/WiFi need internal RAM, not PSRAM. On desktop both return 0 (unlimited).
-
-### Rule of Five catches real bugs
-- MoonModule and ControlList owned raw pointers but had implicit copy/move — CodeRabbit caught the double-free risk. Delete copy/move on any class that owns raw memory.
-
-### setName must copy, not store pointer
-- HTTP module creation stored a pointer to a stack-local buffer. After the function returned, the name was garbage. Fixed by making name_ a char[24] buffer with memcpy in setName().
-
-## Lessons from the next-iteration branch (plans 08-12)
-
-The branch covering SystemModule/NetworkModule, persistence, the UI rewrite, the eth-only build, and the side-nav. Brief, actionable takeaways:
-
-- **Plan-09 was right to abandon part of itself.** The first persistence attempt (~1700 LOC) didn't pay for itself; ~700 LOC of genuine foundations (partition scheme, platform fs API, MoonModule additions) were kept and the rest dropped. Plan-10 then succeeded with a far smaller control-list-driven design. Lesson: a plan that produces "keep the foundations, drop the feature, re-plan smaller" is a success, not a failure.
-- **ESP-IDF v6.x removes WiFi via `EXCLUDE_COMPONENTS`, not a Kconfig flag.** `CONFIG_ESP_WIFI_ENABLED` is non-settable in v6.x — setting it `n` is silently ignored. The eth-only profile excludes `esp_wifi`/`wpa_supplicant`/`esp_coex` components (NOT `esp_phy` — the EMAC needs it) plus an `MM_NO_WIFI` define gating `if constexpr` branches in core. Verify a build profile actually changed something (image size, `nm` for symbols), don't trust the config.
-- **The render tick collapses on any blocking network write on the hot path.** The FPS-swing was a blocking 49 KB preview WebSocket write spinning `vTaskDelay` until lwIP drained. Fix: non-blocking scatter-gather write + downsample the payload to fit the send buffer. Any per-tick socket write must be non-blocking and abandon-on-backpressure.
-- **WiFi UDP is ~4× the Ethernet per-packet cost.** ArtNet at 16K lights is ~7 FPS on WiFi vs ~19 on Ethernet — WiFi physics (CSMA/CA, retries, rate adaptation), not a code regression. Recommend Ethernet (or the eth-only profile) for large installations.
-- **No-op wrappers are an "unnecessary abstraction" the Reviewer must catch.** Two were found and removed (`HttpServerModule::parseJsonString` re-namespacing `mm::json::*`; `NetworkModule::rebuildLocalControlsAndPipeline` whose name contradicted its body). A pure pass-through that only renames is the opposite of duplication — a single thing that shouldn't exist. CLAUDE.md's Reviewer definition now names this pattern explicitly.
-- **Always escape strings going into hand-built JSON.** A control value containing `"` or `\` produced malformed JSON in `/api/state` and the persisted config. Hand-rolled JSON serialization must escape on write and un-escape on read — caught by a round-trip test, not the reviewer.
-- **Doc-vs-code drift hides in design changes.** When the password approach changed mid-implementation (length-only → XOR+base64), the docs and one header were updated but a second header's comment was missed — it then documented a security property the code didn't provide. When a design changes, grep for every mention of the old behaviour.
-
-## Lessons from this branch (plans 13-16)
-
-Plan-13 (nest child cards inside parent box), plan-14 (replace-type button), plan-15 (stream `/api/state`), plan-16 (Layouts/Layers/Drivers top-level reshape) — plus the unplanned mid-branch work the user surfaced (effect-animation freeze, Int16 zero-corruption, Layouts container disable crash, MoonModule status slot, src/light/layers/ reorganisation, FilesystemModule + Scheduler split).
-
-- **A single `c.min`/`c.max` field can't bound multiple numeric widths.** `ControlDescriptor.min/max` are `uint8_t`; applying them to `Int16` / `Uint16` controls clamps every value into `[0..0]`. Default `addInt16` and `addUint16` leave bounds at `0,0` precisely because the slot can't represent the wider range — and on the load path that mistake silently zeros every Int16 control on every reboot. The bug shipped, was caught next session by "Layouts cannot be activated after reboot," and was reverted with explicit comments in `Control.h` documenting why bounds stay 0,0. Lesson: when a validation field's storage is narrower than what it claims to validate, the validation is wrong, not the field. A type-correct alternative (wider bounds, or per-type bound slots) is the real fix; until then, document the constraint at the field's declaration.
-
-- **Per-tick integer division can round the animation rate to zero.** `phase += dt * bpm * 256 / 60000` truncates to 0 when `dt < 234 / bpm` ms. ESP32 at 16K LEDs (`dt ≈ 62ms`) animates fine; desktop at `dt ≈ 0..1ms` and small grids on ESP32 freeze. Four effects shipped frozen on desktop for an entire iteration before a user noticed. Fix is to keep the raw `dt * bpm` numerator in the accumulator and divide only at the read site (the pattern NoiseEffect already used). Rule, now in CLAUDE.md § Hard Rules: "effects must run at every grid size and tick rate."
-
-- **Early-return on degenerate inputs leaks state, not safety.** `Layer::onAllocateMemory` early-returned on empty layouts (zero lights) without resetting its LUT or buffer; Drivers then reallocated its output buffer to 0 bytes while the stale LUT still pointed at 16K destinations, and the next `blendMap` dereferenced null. "Nothing to do" branches must still bring the module into a consistent zero state (dims = 0, LUT freed, buffer freed), not just skip work. Same lens as the zero-grid effect rule.
-
-- **HTML5 `dragstart`'s `e.target` is always the draggable element, not the deepest descendant under the mouse.** Excluding regions of a draggable element via `e.target.closest(".child-class")` in `dragstart` never matches because `e.target === card`. The reliable signal is the *mousedown* target; toggle `draggable` on mousedown based on where the grab actually landed, with `touchstart` mirror for mobile. The naive `dragstart` filter pattern shipped silently because the existing exclusion list happened to include `` (covered most controls); only when the controls region used a ``/`` did the bug surface.
-
-- **Severity is a real axis on the "module has something to say" channel.** A single `warning` slot conflates "this is fine, here's your IP" with "this degraded silently, look closer" with "this failed." Three levels (`Status` / `Warning` / `Error`) earn their keep when more than one module produces non-degradation messages (NetworkModule routes its old ReadOnly `status` control through the same slot). Default = `Status` (neutral info) so `setStatus("connected")` looks right; severity is only set explicitly when something is bad. Wire format mirrors the C++ enum names lowercased (`"status"` / `"warning"` / `"error"`) — collision with the field name `status` is documented at the introduction site so the bespoke choice carries its reason.
-
-- **Lifecycle events fall into "commit" and "merge"; "push" had no work of its own.** A first cut of the lifecycle gates carved out a separate Push event for the Reviewer agent — but that produced the "address-reviewer" follow-up-commit anti-pattern (reviewer flags an issue → noise commit → trail full of "fix reviewer"). Moving the reviewer to Commit was worse: every substantial commit paid 5-7 min + tokens. Final shape: reviewer at PR-merge over the whole branch diff (where architectural drift is visible across commits, not within one); on-demand pre-commit as the safety valve when the product owner asks. Push has no gate.
-
-- **The reviewer agent flags real findings AND wrong ones; triage matters.** Three different reviewer passes over this branch produced ~20 findings. ~30% were valid, the rest either misread line numbers (cited the wrong region), or repeated a finding already accepted in an earlier commit, or proposed a "fix" that would re-introduce a bug we just fixed (the `c.min`/`c.max` Int16 finding twice). When a reviewer finding is wrong, "skip with one-line reason in the commit body" is more honest than either fixing it to satisfy the reviewer or rejecting it silently. The reason text becomes the audit trail.
-
-- **The docs hierarchy now has a name shape: top-level system docs are flat (`architecture.md`, `coding-standards.md`, `building.md`, `testing.md`), per-module specs live under `docs/moonmodules//.md`.** The earlier `architecture.md` + `architecture-light.md` pair was asymmetric ("the general doc + the light-domain deepening") and pulled toward growing more suffixes. Merging back into one `architecture.md` with `# Core` / `# Light domain` top-level sections matched the convention every well-known C++/web project uses (Linux, Django, Rust). Subdirectories `core/` and `light/` only kick in under `moonmodules/` because there's a plural of each kind there. Bespoke `*-light.md` / `*-coding.md` suffix patterns are now ruled out by the "common patterns first" principle.
-
-- **`util/` is a category that classifies by "what is this thing" rather than "what does it do" — and it's the kind a new contributor learns nothing from.** When `src/core/` grew to 17 files I considered a `util/` bucket for the small headers (`Base64.h`, `Sha1.h`, `JsonSink.h`, etc.) and rejected it: each file already names what it does (it's not "util", it's WebSocket-handshake base64 / hash); grouping by file-shape rather than concern is the Frankenstein pattern. Same reason `modules/` got rejected: the four system services (Filesystem / Network / System / HttpServer) are singletons of their kind, not "modules of role X" — folder-grouping requires a plural of each kind to earn its keep. The right cleanup turned out to be header-only → `.h+.cpp` splits (done lazily as files were touched), not folders.
-
-- **`.claude/scheduled_tasks.lock` is harness runtime state.** Per-session lock file the Claude Code harness uses to coordinate scheduled wakeups; not project content. Add `.claude/*.lock` to `.gitignore` (a broader pattern than per-file, since other harness lock files will follow the same shape). The single-file ignore on `.claude/settings.local.json` was too narrow.
-
-## Lessons from this branch (plans 17-23)
-
-The plan-18 branch landed plans 17 + 18 + six unplanned follow-ups (19, 19.1, 20, 21, 22, 23). What earned its keep, what surprised us, what to remember.
-
-- **CORS-on-static-files isn't a thing GitHub Pages can fix from your side.** Plan-17's web installer assumed cross-origin fetches of GitHub release-asset `.bin` files would work; they don't (the GitHub Releases CDN returns no `Access-Control-Allow-Origin`). Plan-18 step 0 falsified this empirically and pivoted to "self-host the last N releases' binaries on Pages content" — same-origin with the install page, no CORS. The lesson sits both ways: don't assume CORS shape; AND don't fix CORS via a third-party proxy (WLED's `proxy.corsfix.com` dependency) when self-hosting works.
-
-- **HTTPS-page → HTTP-LAN fetches are blocked by Chrome's mixed-content policy, no override available cleanly.** A Pages-hosted install page can't `fetch("http://192.168.1.X/api/state")` even if the device has `Access-Control-Allow-Origin: *` set correctly. The block happens before the request leaves the browser. Plan-20's Diagnose feature moved to the device UI (same-origin, mixed-content moot) instead. Lesson: pick the side of the security boundary that actually works; trying to "fix" mixed-content from the device side is wasted code.
-
-- **ESP Web Tools' rich panel is in-browser-session-only.** "Visit Device" + "Configure Wi-Fi" rows appear right after provisioning; close the tab and the panel collapses to "Install + Logs" because the device URL is browser-side memory, not asked-back from the device. Plan-18 fix-pack added a `GET_CURRENT_STATE` → URL follow-up on the device (ESPHome pattern, mirrors what Improv allows) — verifiable via `improv_probe.py`, but it does NOT change the ESP Web Tools UI. Lesson: when a feature lives in a third-party tool's state machine, surfacing data from the device is necessary but not sufficient; the third-party tool's own state model also has to use it.
-
-- **`improv_provision` rejects new credentials when WiFi STA is already connected — that's by design.** The Improv listener returns `ERROR_UNABLE_TO_CONNECT` if `wifiStaConnected()` at the time of `WIFI_SETTINGS` — protects large installs from a scan-induced ArtNet drop. The browser dialog says "Unknown error (255)" though, because Improv's error code mapping doesn't carry a human reason. Lesson: protocol-level "this is fine, expected" and browser-level "something is wrong" don't line up automatically. Document the rejection at every layer the user encounters it.
-
-- **Per-board build directories should land as `build//`, not `/build//`.** Plan-19.1's choice of `build/esp32-/` (e.g. `build/esp32-esp32-eth-wifi/`) keeps every target under one root (`build/`) and shares the namespace with desktop targets (`build/macos/`, `build/linux/`, `build/windows/`). The doubled prefix on the ESP32 side (`esp32-` for the chip family + `` for the variant) is intentional — earlier draft put ESP32 builds under `esp32/build//`, which made `clean --all` more fragile because it had to walk both `esp32/build/` and `build/`. One root, one cleaner.
-
-- **`idf.py -B ` needs `-DSDKCONFIG=/sdkconfig` to keep per-build-dir sdkconfigs isolated.** Without the SDKCONFIG override, idf.py writes `/sdkconfig` at the project root, which all per-board build dirs then share — switching boards trips the "project sdkconfig was generated for target X, but CMakeCache contains Y" abort. The fix is a one-liner per script that invokes idf.py, but it's not obvious until the failure happens on the second board build. Caught by the per-board build sanity-test step of plan-19.1, retrofitted to build_esp32 / flash_esp32 / collect_kpi.
-
-- **Trying to add a child module to NetworkModule wasn't the small refactor it looked like.** Plan-21 (Improv as Network child) reverted the same session. The blocker looked like `Scheduler::tick()` only walking **top-level** modules for `loop20ms` / `loop1s`, so children never got tick callbacks. Lesson: "obviously the right shape" can hide infrastructure that isn't ready for that shape. **Resolved by the Peripheral-role branch:** the real gap was narrower than "the scheduler doesn't tick children" — `MoonModule`'s base lifecycle *does* propagate every callback (`loop`/`loop20ms`/`loop1s`/`setup`/`teardown`/`onBuild*`) to children; a child only misses a callback when the parent **overrides that method and forgets to chain to base**. The fix is therefore per-parent, not a scheduler refactor: SystemModule (which accepts user-added Peripheral children) overrides `setup()` and `loop1s()` and now chains both to `MoonModule::`, so peripheral children init and poll. `unit_SystemModule` pins this. The general rule — override-and-chain, with the parent-before/child-before convention per callback — lives in [coding-standards.md § Override-and-chain convention](../coding-standards.md#override-and-chain-convention).
-
-- **Splitting `platform_esp32.cpp` is safe at public-API boundaries, not at section banners.** Plan-23 cut Improv + OTA + LittleFS into siblings because each owns its private state and talks back to the rest of the platform layer only through `platform.h`. Network stayed in the core file because Eth + WiFi + sockets + mDNS share eight file-scope variables (event handlers, netif pointers, init-done flags). The criterion: a split happens at a public-API boundary, not at a "this section reads coherent in the diff" boundary.
-
-- **Desktop's platform.cpp is correctly asymmetric with ESP32's.** Plan-23 deliberately didn't split `platform_desktop.cpp` even though it has matching OTA / Improv / FS sections, because each is a 6-line stub. Per-subsystem stub files would be all overhead, no payoff. Lesson: symmetry across platforms is a heuristic, not a rule; the right axis is "does each file pay for itself."
-
-- **Nightly builds belong in their own workflow, not the main release workflow.** Plan-22 added `.github/workflows/nightly.yml` that tags `nightly-YYYY-MM-DD` and lets the existing `release.yml` do the actual build via tag push. Zero duplication of the build matrix or Pages staging logic. The skip-on-no-change check (`should-tag=false` when `main` HEAD hasn't moved since the last nightly tag) means quiet days cost ~2s of API calls, not a full build. Lesson: a scheduled trigger that tags-and-reuses an existing trigger is cleaner than a parallel workflow that duplicates build steps.
-
-- **`workflow_dispatch` reads the workflow YAML from the default branch, not the dispatched branch.** Cost us a CI cycle on RC2: dispatched against `plan-18` with a tag that wasn't valid against `main`'s older `release.yml`. The fix landed in the same branch (`3e2acb5` — branch allowlist + verify-version `--tag` arg), but the lesson is to read the GitHub Actions semantics for dispatch carefully: `inputs.tag` arrives correctly, but the workflow logic that consumes it is whatever main has at dispatch time.
-
-- **The reviewer agent's job at PR-merge is architectural drift across N commits, not line-level bugs.** CodeRabbit catches line-level bugs in each PR commit; the reviewer agent at Event-2 reads the full branch diff and asks "did three commits each add a wrapper that one commit would hide?". Plan-18 ran into this productively — the in-branch CodeRabbit findings got triaged and either fixed or skipped-with-reason; the reviewer-agent run at merge is the final architectural sanity check. Two agents, two scopes.
-
-- **A 13-commit branch is the upper end of what a single merge should carry.** Plans 18 + 19 + 19.1 + 20 + 20.1 + 21 (reverted) + 22 + 23 in one branch is a lot — the merge commit train is heavy and the reviewer-agent's job gets harder as more commits stack up. Future branches should aim for "ship the first 3-4 plans, merge, start the next branch" rather than "let the branch grow until everything's tidy." This branch worked because the plans were mostly independent (no two of them touched the same files in conflicting ways), but that won't always hold.
-
-- **Asymmetric lifecycle propagation in MoonModule was historical, not principled.** Before this change, `setup` / `teardown` / `onBuildControls` / `onAllocateMemory` propagated to children via base defaults, but `loop` / `loop20ms` / `loop1s` defaulted to empty no-ops — every container that wanted to tick its children wrote the same 5-line per-child block (Layers, Drivers, would-be-NetworkModule-with-Improv). The fix was a one-line base-default change per callback into a shared `tickChildren` helper that gates by `!respectsEnabled() || enabled()` (children that opted out of the enabled gate keep ticking; the rest tick only when enabled) and accumulates per-child timing the same way Scheduler does for top-level modules. Leaf modules with `childCount_ == 0` pay one predicted-not-taken branch per call — sub-nanosecond. PC tick stayed in the same 55-160 µs band across scenarios; no measurable regression. Lesson: when one half of a lifecycle propagates and the other half doesn't, the asymmetry is usually historical (no one wrote the helper yet), not principled. With the helper in place, the parked Plan-21 (Improv-as-Network-child) move was a four-line follow-up: `addChild` instead of `addModule`, plus chain `NetworkModule::setup` / `loop1s` / `teardown` / `onBuildControls` to the base.
-
-- **Override-and-chain convention: option A for loop, option B for setup.** When a container needs custom work alongside child dispatch, the convention is "parent prepares, children consume" for the loop callbacks — parent runs its work first, then chains to `MoonModule::loop()` so children read the freshly-prepared state (`Drivers::loop` runs `blendMap` before driver children read `outputBuffer_`). For `setup()` the convention is the opposite — chain to base first so children are initialised before the parent depends on them. `teardown` chains late (parent shuts down its own state, then the base reverse-iterates children). The conventions are explicit in `architecture.md § Lifecycle propagation to children`; deviations need a one-line comment at the override.
-
-- **Control-change reactions are a three-tier split; the coarse-grained rebuild debt is closed.** The long-standing debt ("the build pass ran on all top-level modules for every control change") is resolved. `handleSetControl` now: (1) always calls the cheap `MoonModule::onUpdate(controlName)`; (2) calls `scheduler_->buildState()` only when `controlChangeTriggersBuildState(controlName)` returns true; (3) the pass reaches each module's `onBuildState()`. `controlChangeTriggersBuildState` defaults false and is overridden to true on `LayoutBase` and `ModifierBase` (every control they expose changes physical dims / LUT shape). Effect/driver value controls — including the new Drivers `brightness` — take tier 1 only, so slider drag no longer triggers a tree-wide realloc sweep. The model mirrors MoonLight's `onUpdate` / `requestMappings` (`hasOnLayout`/`hasModifier`) / `onSizeChanged` split — confirmed against MoonLight source before implementing. The verb is "build" not "rebuild" (idempotent, history-agnostic) and `onAllocateMemory` was renamed `onBuildState` for the same reason — boot and a later change are the same call. Lesson: when the spec already describes selective behaviour (architecture.md § Rebuild propagation said this for months) but the code does the coarse thing, the gap is usually a missing cheap hook, not a missing design.
-
-- **Output correction (brightness/reorder/white) is a per-driver stage, shared via the Drivers container, not a WS2812-specific or effect-side concern.** Every physical driver needs to turn logical RGB into a physical signal; ArtNet was sending raw bytes (no brightness/order/white) — a gap, now fixed. The Drivers container owns a `Correction` (256-entry brightness LUT + channel-order table + derive-white flag) and hands each child a `const Correction*`; each physical driver applies it per-light into its own output buffer/packet. Preview is exempt (raw logical buffer). Brightness applied *before* white derivation (white = min of scaled channels). One LUT now; gamma/white-balance fold in later as a per-channel R/G/B split (the field is named `briLut` not `gammaLut` so that's a fill change, not a rename). Following MoonLight, which applies brightness/gamma/color-order at the driver edge via per-channel `redMap/greenMap/blueMap/whiteMap` LUTs + a lightPreset→offset table.
-
-- **Per-`ControlType` behaviour belongs with the type, not with the caller.** Three call sites (`HttpServerModule::writeControls`, `FilesystemModule::writeValue`, `scenario_runner.cpp::writeJsonValAsValue` and twin apply paths) were each carrying their own `switch (c.type)` over the same enum. Each was 50-60 lines, hand-maintained in parallel, and silently drifted (the scenario runner had stopped recognising new types added for the FS path). The fix was to extract the switch into a small set of free functions in `src/core/Control.cpp` (`writeControlValue`, `writeControlMetadata`, `applyControlValue`, `isPersistable`, `hasDefault`, `controlTypeName`) and route the three call sites through them. Two cross-cutting requirements made this work: (1) `JsonSink` gained a third "fixed-buffer" mode (alongside socket and heap-grow) so the FS path could share the serializer without growing a per-value allocation, and (2) the apply path got an `ApplyPolicy` parameter (`Strict` for HTTP, `Clamp` for FS load) so the tolerant-load semantics survived the consolidation. Codified in `docs/coding-standards.md § Per-type behaviour lives with the type` — applies whenever the same `switch` appears in 2+ places. Counter-example also in that section: if only one caller needs the behaviour, keep it at the call site (a one-shot switch is cheaper than a function with one user).
-
-- **Local Improv testing closes the dev-loop gap that made every Improv change a high-friction commit.** Before this branch, the only way to verify the Improv flow end-to-end was to tag a release, wait for CI, deploy to GitHub Pages, then flash from the live web installer — each iteration of the docs/install page or release-picker.js took minutes and burned a release tag. `moondeck/run/preview_installer.py` now has a "flash-ready" mode that stages every local `build/esp32-*/projectMM.bin` under `releases/latest/` in the preview server's tree, generates matching Pages-relative manifests via the same `generate_manifest.py` production uses, and serves the install page at `localhost:8000`. The picker resolves the GitHub `latest` tag to the staged local bins via `toLocalUrl`, and Web Serial works on `localhost` without the secure-origin requirement that gates the public site — so an end-to-end flash + Improv WiFi provision is verifiable without touching any release. Paired with `moondeck/build/improv_smoke_test.py` (probe + WiFi provision + LAN reachability, all three steps as a single CLI), which gives a deterministic pass/fail for the device-side Improv state machine and is wired into MoonDeck. Lesson: when a feature can only be tested in production, the iteration cost compounds into "don't touch it" — building the local test loop, even if it takes a day, pays for itself within a few commits.
-
-## Lessons from this branch (Board injection follow-ups)
-
-- **`src/ui/release-picker.js` is now `src/ui/install-picker.js` (exported symbol `installPicker`, embedded C array `installPickerJs`).** Renamed once the picker grew from "pick a release" to "pick release + board + firmware + install button" — the old name only described one of the three axes. The rename was a wide sweep (~20 files: source + CMake + release.yml + preview script + spec doc + every comment that mentioned the picker by name) but mechanically simple. Recorded here so a future search for `release-picker.js` lands on this entry and finds the new name; the file itself doesn't carry a "renamed from" comment because that would be the kind of past-tense history `CLAUDE.md § Principles` says belongs only in this folder.
-
-- **Why the web installer dropped ESP Web Tools for a custom orchestrator.** ESP Web Tools 10.x's `` held the SerialPort exclusively across its flash + provision lifecycle and fired its `state-changed` event inside its dialog's shadow DOM (verified by reading `esp-web-tools/src/install-dialog.ts`). Two consequences: post-PROVISIONED board injection from the installer page was structurally impossible (we couldn't hook a side-call between provision and reboot), and `devices.js`'s "Your devices" auto-add silently broke because the URL was emitted behind a shadow-root event we couldn't subscribe to from outside. Owning the SerialPort end-to-end in `install-orchestrator.js` lets both fixes land in one place. The same dispatcher seeds future per-control injectables (device name override, MQTT broker URL, DMX universe) — each new field adds one vendor command ID + one dispatcher case. Lesson: a third-party install widget that owns the transport and emits lifecycle events behind a shadow root is a dead end for any flow that needs to do work after provision; if you need composability, own the transport.
-
-## Adaptive memory allocation design (plan-07)
-
-The core rules for how the light pipeline allocates and degrades under memory pressure. These are the invariants the code was designed around; the implementation lives in `Layer.h` and `DriverGroup.h`.
-
-**Allocation rules:**
-- **MappingLUT** is created only when ALL are true: modifiers exist on the layer; layout is not a simple non-serpentine grid (where physical == logical); enough heap available after reserving `HEAP_RESERVE` (32 KB) for stack/HTTP/WiFi.
-- **Driver output buffer** is created only when: at least one layer has a LUT actually allocated (not just "has modifiers") and enough heap is available.
-- Result for 1:1 unshuffled (no modifiers, or grid without serpentine): zero intermediate buffers — ArtNet reads directly from the layer buffer. Maximum LED count at minimum memory.
-
-**Degradation cascade** — when memory is insufficient, degrade in this order:
-1. Full pipeline — LUT + driver output buffer (modifier applied, clean separation)
-2. Skip driver output buffer — LUT exists, DriverGroup does mapping inline (slower, sequential)
-3. Skip LUT — modifier not applied, forced 1:1 mapping
-4. Reduce layer dimensions — halve until buffer fits, minimum 8×8
-
-Each degradation level is observable via flags on the module (`degraded()`, `lutSkipped()`, `outputBufferSkipped()`).
-
-**Predict-measure-compare:** before each allocation, predict memory impact from grid dimensions + channelsPerLight + modifier presence; after allocation, compare heap delta. Variance > 5% signals a leak or accounting error. Buffer sizes: layer = W×H×D×cpl; LUT ≈ `MappingLUT::estimateBytes(logicalCount, maxDest)`; driver buffer = physicalCount×cpl.
-
-## Plan-09 persistence failure — why JSON didn't pay for itself
-
-Plan-09 attempted ~1700 LOC of JSON-based persistence that was fully abandoned. The five root causes are worth keeping because they recur in any persistence design:
-
-1. **Question the format premise.** "Persistence is JSON" was assumed without justification. Neither human-readability nor manual editability were real requirements. For POD-only module state, `memcpy(file, this + sizeof(MoonModule), classSize - sizeof(MoonModule))` is one line and a complete save. Plan-10 took this path and succeeded.
-
-2. **Suspicious helper proliferation signals over-elaborate design.** The plan spawned: `rebuildControls`, `clearControlsRecursive`, `LoadAllFn`, `setLoadAllHook`, `noteDirty`, `loadAll`, `loadTopLevel`, `applyNode`, `applyControls`, `serializeNode`, `serializeControls`, `buildTopLevelPath`, `cleanupTmpFiles_`, `cleanupTmpCb_`, `cleanupTmpLeafCb_`. That list is the system telling you the design is too elaborate for the job.
-
-3. **Persistence forced a Scheduler reorder that bred secondary bugs.** Overlaying persisted values onto bound control variables grew the Scheduler from 3 phases to 5. This required `onBuildControls` to be idempotent, bred a duplicate-children bug, required SystemModule to guard MAC→deviceName derivation with a `deviceName_[0] == 0` check, and caused multiple "device shows nothing" hardware failures. The right approach: load BEFORE any module's setup or onBuildControls, by memcpy'ing into member memory directly.
-
-4. **Defensive guards under memory pressure mask design bugs.** The plan added 5 null guards across BlendMap, DriverGroup, and Layer to handle failure modes that fragmentation produced. Each guard was locally correct; collectively they obscured the design problem (allocate-new-before-free fragmentation). Fix the invariant, not the call site.
-
-5. **Test isolation reveals persistent-state contamination.** Live scenarios that mutated state (mirror toggles, grid size) contaminated each other across runs — failures appeared random until previous runs leaving state in `.config/` was identified. Any persistence layer's tests must reset state explicitly.
-
-## Board-injection pipeline timing constraint
-
-The web installer's `boards.json` catalog ships per-board control values that the orchestrator pushes to the device. SET_BOARD over Improv-Serial carries only the board name (one vendor RPC, one Text control on BoardModule); every other field in `controls.*` ships via HTTP after WiFi association.
-
-This split works because every per-board control we ship today applies *post-association*: `Network.txPowerSetting` (the weak-power brown-out cap), and the future Ethernet pin maps, default-config overrides, etc. The radio briefly runs at the wrong setting for the ~1 s between association and HTTP fan-out completion — acceptable for power capping, would be unacceptable for:
-
-- **Country code.** Governs which channels the radio scans; a wrong code at scan time picks wrong channels.
-- **Antenna selector.** Wrong RF path at radio init makes the device deaf.
-- **Pre-association TX-power.** Some chips need the power cap applied before the first probe request, not after association.
-
-If we ever add such a control, **don't extend SET_BOARD's wire format to carry it** — that would couple unrelated controls to the board-name lifecycle and obscure the timing constraint. The two escape hatches are:
-
-1. **Add a second vendor Improv RPC** (`SET_` analogue of SET_BOARD) and dispatch it from the orchestrator BEFORE `SEND_WIFI_CREDENTIALS`. One RPC per pre-association control keeps the timing contract explicit.
-2. **Bake the value into firmware** via a board-specific sdkconfig fragment. Works when the value is truly board-static (country code per region) and not user-configurable.
-
-Pick (1) for user-configurable controls; (2) for truly fixed-per-board values. Avoid the implicit option C ("just push it earlier in SET_BOARD") — it tangles the lifecycle.
-
-## Lessons from the ESP32-S3 N16R8 (DevKitC) enablement branch
-
-Three non-obvious failures showed up while adding native-USB S3 support, all with the same diagnostic shape ("symptom looks like X, root cause is somewhere completely different"). Record them so a future S3 / native-USB addition doesn't repeat the dig.
-
-1. **USB-Serial-JTAG ≠ UART0 on ESP32-S3.** The ESP32-S3 N16R8 Dev's USB-C port wires through the ESP32-S3's built-in USB-Serial-JTAG peripheral, NOT through an external USB-Serial bridge to UART0. ESP-IDF's secondary-console feature (`CONFIG_ESP_CONSOLE_SECONDARY_USB_SERIAL_JTAG=y`, on by default for S3) mirrors stdio out BOTH paths, so the developer sees boot logs and assumes UART0 is wired and working. It isn't. Improv-listener-on-UART0 was deaf because the host was talking to USB-Serial-JTAG. **Fix:** install BOTH drivers and read from both transports — `#if SOC_USB_SERIAL_JTAG_SUPPORTED` keeps ESP32-classic builds free of the JTAG path. Don't make this compile-time-per-board ("the DevKit firmware") — the same binary should work on a board with either wiring.
-
-2. **CORS preflight is silent on the client side.** Every cross-origin POST from a browser with `Content-Type: application/json` (everything the web installer does) triggers an OPTIONS preflight. If the device's HTTP server returns 405 to OPTIONS, the browser **silently drops the subsequent POST** — no error surfaces in client code, no network tab line, no console message in the install log. The symptom is "the API write didn't happen" with no diagnostic to follow. Burned an entire session diagnosing what looked like a board-injection-fan-out bug; the root cause was an unhandled HTTP verb. **Fix:** always implement OPTIONS in any device-side HTTP server that's reachable cross-origin. Return 204 with `Access-Control-Allow-Origin: *`, `Access-Control-Allow-Methods`, `Access-Control-Allow-Headers: Content-Type`. Verify with `curl -X OPTIONS -H "Origin: ..." -H "Access-Control-Request-Method: POST" -H "Access-Control-Request-Headers: content-type" http://device/api/control` — must return 204 with the headers, not 405.
-
-3. **Cached "last applied" state goes stale when the underlying stack restarts.** The `txPowerSetting` cap used an `appliedTxPowerSetting_` field to skip redundant `esp_wifi_set_max_tx_power` calls. When ESP-IDF stops the WiFi stack (AP→STA cascade, STA reconnect, AP shutdown), it resets the radio's TX-power state — but our cached "applied" value stayed equal to the desired value, so `syncTxPower()`'s equality check short-circuited and the cap never re-landed on the restarted radio. The user saw "the cap doesn't actually work after the first power-cycle." Classic cache-invariant bug: the cache was correct vs the *driver state we set*, but stale vs the *underlying hardware state* that an external event reset. **Fix:** every callsite that calls `wifiStaStop()` / `wifiApStop()` also invalidates the cache (`appliedTxPowerSetting_ = -1`). Generalisable: any "skip if already applied" optimisation against a hardware peripheral needs an invalidation hook tied to every event that resets the peripheral, not just the obvious explicit ones.
-
-## Core/light type boundary: light_types.h split + preview decouple
-
-`src/core/types.h` had grown into a junk-drawer of light-domain types (`nrOfLightsType`, `CoordCallback`, `defaultGridSize`, `HEAP_RESERVE`, `lengthType`) alongside `Dim`. Split it so each symbol lives with its owner. Done in two passes:
-
-**Pass 1 — the symbols with no core consumer:**
-- **`nrOfLightsType`, `CoordCallback`, `defaultGridSize`** → `src/light/light_types.h`.
-- **`HEAP_RESERVE`** → `platform.h` (it's a platform memory constraint guarding stack/HTTP/WiFi headroom — not a light type and not Layer's, even though Layer was its only caller; ownership follows concept, not call count).
-
-**Pass 2 — `lengthType`, by removing its core consumers rather than declaring them load-bearing:**
-The apparent blocker was "core uses `lengthType`." On inspection the uses were incidental: `Control.h` only *mentioned* it in a comment; `HttpServerModule`'s `put16` only took it because it serialised `PreviewFrame`. The real tie was `PreviewFrame` itself — a light-produced struct sitting in `src/core/` that core's `HttpServerModule` read to build the preview WS frame. We severed it properly:
-- Introduced `BinaryBroadcaster` (core interface, ~6 lines): "send these bytes to all WS clients." `HttpServerModule` implements it via `broadcastBinary` — the old `broadcastPreviewFrame` body minus all preview specifics.
-- `PreviewFrame.h` → `src/light/`. `PreviewDriver` now owns the 13-byte header packing and **pushes** the bytes to the broadcaster; the HTTP server no longer reads `PreviewFrame` or knows the format. Push replaced the old `PreviewFrame::ready` poll (flag deleted).
-- `lengthType` → `light/light_types.h`, zero core users left.
-
-**Result:** core has zero light dependency in the preview path; light owns the preview end to end; the binary-frame *transport* stays in core (reusable by any future binary feed — the leverage that justifies the new interface).
-
-**Pass 3 — `Dim`, and the deletion of `core/types.h`.** `Dim` (the effect/modifier dimensionality enum) was the last light symbol in core. It looked load-bearing: `ModuleFactory::registerType` probes a type for `dimensions()` to capture the UI's 📏/🟦/🧊 chip, and the probe named the enum — `requires { { t.dimensions() } -> std::same_as; }`. But the very next line did `static_cast(probe.dimensions())` — the factory only ever wanted a byte. The `Dim` name in the constraint was incidental, not essential. Loosened the probe to `requires { static_cast(t.dimensions()); }` — detects the method and reduces it to a byte without naming the type. `Dim` then moved to `light/light_types.h`, and with it gone **`core/types.h` was empty and was deleted.** Verified the probe still captures correctly: `/api/types` reports dim 3 for NoiseEffect, 2 for CheckerboardEffect, 0 for GridLayout/ArtNetSend — unchanged. (Safe because only EffectBase/ModifierBase declare `dimensions()`; nothing else matches the loose constraint.)
-
-**The rule that held:** don't accept "core uses X" at face value — check whether the use is *essential* or *incidental*. Every tie here turned out incidental and severable: a comment (`Control.h`), a serializer following a misplaced struct's field type (`PreviewFrame`/`put16`), and a SFINAE constraint that named a type it immediately discarded (`Dim`). The fixes: move the real owner and give core a domain-neutral seam — `BinaryBroadcaster` for the preview bytes, a return-type-agnostic probe for `dimensions()`. End state: **no `core/types.h`; core names zero light types.**
-
-## The layer buffer persists frame-to-frame; effects own their background (fade/fill/read-prior)
-
-The render buffer is **not** cleared each frame — `Layer::loop()` leaves last frame's pixels in place, the FastLED / WLED / MoonLight convention (their `leds[]` / segment / VirtualLayer buffers all persist; none auto-clears). An earlier design cleared the buffer before every effect frame ("the buffer is the effect's to fill, every time"), but that silently broke every persistence effect: a scroll (FreqMatrix) reads the prior column via `draw::get` and shifts it — reading a wiped buffer, so only the freshly-painted pixel survived; a trail effect calls `fadeToBlackBy` to decay the previous frame — fading zeroes, so the trail never formed; Game-of-Life reads its prior cell state — gone. The symptom that surfaced it: FreqMatrix lit only one row, and ~13 effects' `fade` controls did nothing.
-
-**The model (matches the three reference frameworks):**
-- **Persistence is the default and universal** — the buffer holds the previous frame; there is no per-effect "persist" flag (a flag would be bespoke and change no framework behaviour, unlike `dimensions()` which drives `extrude`). The buffer is zeroed **once** on allocation/resize, then persists.
-- **Each effect owns its background inside its own `loop()`:** a full-grid effect overwrites every pixel; a **trail** effect calls `layer()->fadeToBlackBy(amt)`; a **sparse** effect that wants a clean frame calls `draw::fill(buf, {0,0,0})` itself (e.g. RubiksCube, whose `drawCube` writes only surface voxels). Multiple effects on one layer deliberately *interact* through the shared persistent buffer — that's a feature, not a defect.
-- **`fadeToBlackBy` is a Layer operation, collected once per frame** (MoonLight's `VirtualLayer::fadeToBlackBy`): effects register an amount, the Layer keeps the **MIN** across them (the gentlest fade wins, so the longest requested trail is honoured), and applies **one** buffer pass at the next frame's start, then resets. N fading effects cost one pass, not N, and never fade each other's fresh pixels. `unit_Layer_persistence` pins persistence + the MIN-collect + reset.
-
-**GoL corollary (still holds, now for the right reason):** separate *when the simulation advances* from *when the effect paints*. `GameOfLifeEffect` steps one generation per `bpm` beat but its state lives in the persistent buffer, so between beats it simply leaves the buffer untouched (the automaton holds). Time-gate the state update (the `dt*bpm` accumulator, same shape as `CheckerboardEffect`); the sim runs at `bpm`, the buffer persists at frame rate. A `unit_GameOfLifeEffect` case pins it.
-
-**Two adjacent traps the same effect hit:**
-- **First-frame `dt`.** `lastElapsed_` starts at 0, so the first `now - lastElapsed_` is the whole device uptime — a huge `dt` that pins the step accumulator above the beat threshold *permanently* (max rate forever, `bpm` ignored). Bootstrap `lastElapsed_` on the first call and take `dt = 0` that frame.
-- **Width of the change delta.** The stagnation check narrowed `alive - lastAlive_` to `uint16_t`; at the 512×512 max grid on a PSRAM board (`nrOfLightsType == uint32_t`, 262144 cells) that truncates and triggers false re-seeds. Counters derived from cell counts must be `nrOfLightsType`, not a fixed width.
-
-## A static "current instance" pointer needs re-election, not just claim/vacate
-
-`AudioModule::latestFrame()` hands effects the active mic via a process-wide `static AudioModule* active_`. The original design was "setup() claims the seat, teardown() vacates it" — which silently breaks with **two** mics: removing the one that holds `active_` leaves the seat null while a second, still-running mic sits captured-but-unread, and every audio effect goes silent. The fix is a three-part protocol: the **first** live module claims in `setup()`, `teardown()` vacates, and any running module **re-claims an empty seat in `loop()`** — so the survivor takes over on its next tick for *any* add/remove order (the robustness rule). Rule: a singleton-accessor backed by a static pointer must have a *self-election* path in the periodic loop, not only claim-on-setup — otherwise it's only correct for exactly one instance. (`unit_AudioModule` pins the two-mic first-wins + re-election.) Tempting CodeRabbit "fix": gate the claim on `inited_` — rejected, because a claimed-but-uninited module publishes valid *silence* (the documented contract), and a mic-less board running `simulate` publishes synth frames without being `inited_`; gating on init breaks both.
-
-## An "Effect-role" module is not guaranteed to be an `EffectBase`
-
-DemoReel hosts a child effect and needs its `dimensions()` to extrude it. The first cut did `static_cast(current_)->dimensions()` every frame on the `MoonModule*` child — which **crashed** (SIGBUS in RTTI/vtable) when the eligible list included a test `EffectStub` that registers with `ModuleRole::Effect` but is a bare `MoonModule`, not an `EffectBase`. Two lessons: (1) **role is a registration property, not a type guarantee** — a module can carry any `role()` without deriving from the matching base, so a downcast keyed on role is undefined behaviour; and (2) the data was already available without the cast — the factory probes `dimensions()` at registration via `if constexpr` and stores it, so `ModuleFactory::typeDim(index)` gives the dimension RTTI-free (ESP32 builds `-fno-rtti`, so `dynamic_cast` isn't even an option). Reach for the factory's probed metadata instead of a cross-tree downcast whenever you need a registered type's declared property.
-
-## uint16 intermediate overflow blanks the display — and a status check doesn't prove the render works (MultiplyModifier)
-
-A high fan-out modifier (`MultiplyModifier` at 8×8×4) black-screened the no-PSRAM Olimex while the desktop showed it working. Root cause: `Layer::rebuildLUT` computed `maxDest = logicalCount * mod->maxMultiplier()` in `nrOfLightsType`. On no-PSRAM that's `uint16_t`, and `256 * 256 = 65536` **wraps to 0** — the LUT was sized to ~nothing, so almost every light mapped nowhere and the frame went black. On desktop (`nrOfLightsType == uint32_t`) the product fits, so the bug was invisible there. **Fix:** compute the product in `uint64_t`, clamp to the ceiling, then narrow back. This is the same family as the GameOfLife delta-width trap above, but for an *intermediate product*, not a stored counter — **any `nrOfLightsType * nrOfLightsType` (or `× a multiplier`) can overflow uint16 even when both operands are individually small; do the arithmetic in a wider type before narrowing.**
-
-The harder lesson is about *verification*: the agent's hardware test asserted the Layer **status string** ("16×16×1") and called it a pass — but the status reflects `logicalDimensions`, which was correct; the *render* (driven by the corrupted LUT) was black. The product owner caught it by eye. **A status/dimension assertion is not proof the pipeline renders.** A correctness test for a mapping/effect must assert the **buffer or LUT is non-empty with the expected coverage** (the regression added here counts LUT destinations == physical light count), not just that the declared dimensions look right. And: a bug that depends on `nrOfLightsType` width is **invisible on the uint32 desktop build** — width-sensitive paths need either a uint16-typed unit test or hardware confirmation, not desktop-only.
-
-## Lessons from the repo-transfer + v1.0.0 release branch
-
-Moving the repo `ewowi/projectMM → MoonModules/projectMM` and cutting the first stable release surfaced three CI failures that had nothing to do with the transfer's code changes — they came from the *infrastructure around* the release (GitHub's hosted runners, GitHub Pages environment rules). Same diagnostic shape as the S3-DevKit branch: the red X appears on a release/build, but the cause is a platform behaviour we pinned against, not our diff. Record them so the next release doesn't re-dig.
-
-1. **Don't pin GitHub-runner toolchain specifics — the `windows-latest` image migrates underneath you.** Mid-release, GitHub began redirecting `windows-latest` from the VS 2022 image to `windows-2025-vs2026` (the run's own annotation warned: "redirected to windows-2025-vs2026 by June 15, 2026"). Two breakages followed on the *same source tree* that had been green the day before: (a) `package_desktop.py` hard-coded `-G "Visual Studio 17 2022"`, and the new image has no VS 2022 → CMake failed at configure ("could not find any instance of Visual Studio"), a 21-second fast-fail before any compile; (b) once the generator pin was dropped (let CMake auto-detect the installed VS), the build reached compilation and the *new* VS 2026 MSVC STL emitted **C5285** ("specializing `std::tuple` is forbidden") on the vendored `doctest.h`'s tuple forward-declaration, and `/WX` made it fatal. **Fixes:** drop the generator pin (auto-detect survives image migration); add `/wd5285` to the existing MSVC suppression list (it's third-party header code we don't own; GCC/Clang never warn). **Generalisable:** anything pinned to a hosted-runner's bundled toolchain version (CMake generator, compiler path, SDK version) is a time-bomb — the image rotates on GitHub's schedule, not yours. Prefer auto-detection; when a pin is unavoidable, expect to update it and don't treat a sudden Windows-only failure on an unchanged tree as your regression.
-
-2. **Don't gate asset-publishing behind a Pages-only `environment:` — a tag fails the gate before any step runs, silently dropping all release assets.** The `release` job did two things (upload release binaries *and* deploy GitHub Pages) under one `environment: github-pages`. That environment's protection rule allowed only `main`. When publishing v1.0.0 created the `v1.0.0` *tag*, it re-triggered the workflow on `refs/tags/v1.0.0`; GitHub evaluates the environment protection rule **at job start, before the first step** — the tag failed it, so the *entire job was rejected in 2 seconds*, including the "Publish GitHub release" step. Result: a published release with **zero binaries**, and a red X whose message ("Tag v1.0.0 is not allowed to deploy to github-pages") pointed at Pages, not at the asset upload that actually got skipped. The two symptoms (missing assets + red X) had one root cause: coupling. **Fix:** split into a `release` job (no environment, `contents: write`, uploads assets — runs on tags) and a `deploy-pages` job (`needs: [release]`, `if: ref==main`, carries the `github-pages` environment). Tags publish assets; `main` deploys Pages. **Generalisable:** an `environment:` on a job gates the *whole job* via a ref-based protection rule evaluated up front — never put work that must run on refs the environment forbids (tag-triggered asset upload) in the same job as the environment-gated work (production deploy). Recovery without re-tagging: `gh workflow run release.yml -f tag=vX.Y.Z` replays the fixed job and uploads assets onto the existing release.
-
-3. **A failure that looks like the change is often the environment — verify before assuming a regression.** Recurring across this branch: a scenario "failed" its 120µs tick contract at 536µs (machine load from concurrent builds + MoonDeck + a preview server — re-ran isolated at 118µs, passed); Improv reported `UNABLE_TO_CONNECT` while the device was *already provisioned and reachable* (async-confirmation timeout, not a join failure); MoonDeck showed `0/0 online` while the device served HTTP 200 (the active network record had an empty subnet and a duplicate record held the device). None were code or transfer defects. **The rule:** when something fails right after a change, first prove the failure is *about* the change — re-run isolated, probe the actual end state (ping/curl the device, read the real CI error line, check the env), and only then edit code. Several hours here would have been saved by checking the device was reachable *before* debugging the "WiFi failure".
-
-## Lessons from the LCD_CAM WS2812 driver bench debug (LcdLedDriver, S3)
-
-Bringing the 8-lane LCD_CAM driver from "compiles and ticks" to "strip actually animates" took three stacked root causes, each masked by the one before it. The through-line: **every layer of indirection between "the code ran" and "the LED lit" hid a failure the layer above couldn't see.** Record the chain so the next parallel-output driver (16-lane LCD, P4 PARLIO, Teensy FlexIO) skips the dig.
-
-1. **The i80 peripheral requires ALL `bus_width` data GPIOs — a partial bus never exists.** `esp_lcd_new_i80_bus` validates every `data_gpio_nums[0..bus_width)` and rejects `GPIO_NUM_NC` entries (`esp_lcd_panel_io_i80.c`, "configure GPIO failed"). A 1-pin config therefore never initialized: no bus, no transmit, dark strip — while the UI showed a configured, enabled driver. **Fixes:** the driver demands exactly 8 pins and reports `LCD bus needs exactly 8 pins` in the status slot (unused lanes take `0` in `ledsPerPin` and idle LOW); the loopback builds its private bus full-width from the driver's real pin set. **Generalisable:** when a peripheral claims a *group* of pins, surface the group contract in the control's validation — don't let a config that the hardware layer will reject look valid in the UI.
-
-2. **"Capacity still fits" is not "config unchanged" — a resize-optimisation early-return swallowed pin changes.** `reinit()` skipped the bus rebuild when the existing DMA buffer was big enough — correct for grid resizes, wrong for pin edits: moving lane 0 from GPIO 13 to 18 keeps the frame size identical, so the bus kept clocking out on the OLD pins and the strip pin carried nothing. **Fix:** record the pin set (data + WR + DC) the live bus was built with and compare on every reinit; any difference forces the rebuild. **Generalisable:** an "is the existing resource still good?" fast path must compare *identity* (what the resource is bound to), not just *capacity* (how big it is). Same family as the S3-DevKit branch's stale-cache lesson: the cached check was true about the wrong invariant.
-
-3. **Gate the LCD driver on `CONFIG_SOC_LCDCAM_I80_LCD_SUPPORTED`, NOT `CONFIG_SOC_LCD_I80_SUPPORTED` — the names look interchangeable and are not.** `lcdLanes` (and the whole driver wiring) first gated on `SOC_LCD_I80_SUPPORTED`, on the assumption it meant "has the S3-style LCD_CAM i80 bus." It doesn't: **the classic ESP32 also defines `SOC_LCD_I80_SUPPORTED=1`** for its unrelated *I2S-LCD* peripheral. So on the classic chip `lcdLanes` became 8, the `LcdLedDriver` got wired at boot, and `esp_lcd_new_i80_bus()` hung the watchdog trying to init an LCD_CAM bus the chip lacks → **boot-loop on every classic ESP32** shipped with that gate. The correct macro is **`SOC_LCDCAM_I80_LCD_SUPPORTED`**, defined only on chips with the actual LCD_CAM peripheral (S3 / P4) — which is what `esp_lcd`'s i80 driver requires. Bisect-proven (the prior commit booted, the LCD-driver commit looped) and hardware-verified after the fix (classic boots with LcdLed absent, S3/P4 keep it). The gate lives in `src/platform/esp32/platform_config.h` (`lcdLanes`) and the `#if` in `platform_esp32_lcd.cpp`. **Generalisable:** SOC capability macros with near-identical names can describe *different peripherals* on different chips — verify the macro is actually defined where you expect (and only there) before gating on it; a "supported" flag that's true on a chip you didn't mean is worse than a missing one, because it silently activates code on the wrong hardware.
-
-4. **A self-test that passes while the device fails is telling you what the test doesn't cover — close the gap before theorising.** The first loopback transmitted a 3-byte synthetic pattern through a 136-byte transfer and PASSED while the real strip washed out max-white. The differences between test and reality were the suspect list: frame size (5.4 KB, multi-descriptor GDMA chain), sustained back-to-back cadence, and pulse timing as seen by a real WS2812 rather than an RMT RX capture. Upgrading the test to transmit the driver's REAL frame (same size, same chain, repeated like the render loop) and bit-verify the WHOLE capture (RMT RX with the DMA backend, ~1536 symbols) eliminated the first two suspects in one run — leaving timing as the only remaining difference, which was the answer. **Generalisable:** when test and reality disagree, enumerate what the test abstracts away and make the test transmit the genuine article; each closed gap either finds the bug or eliminates a theory with proof instead of speculation.
-
-5. **Modern WS2812B reads a 412 ns "0" as "1" — T0H max is ~380 ns on newer revisions, and 3.3 V direct drive eats the remaining margin.** The classic 3-slots-at-2.4 MHz encoding (hpwit / FastLED lineage, slot ≈ 416 ns) produced a waveform the RMT RX decoded perfectly — and the strip rendered as max white with flicker: most "0" pulses sampled as "1" (mostly-ones ≈ white; the animation's actual 1-bits flicker through). The same strip on the same pin ran clean from the RMT driver's 350 ns zeros, which isolated timing as the only variable. **Fix:** pclk 2.67 MHz → 375 ns slots ("0" = 375 ns, "1" = 750 ns, bit 1125 ns), inside every WS2812B revision's window; latch pad resized to keep ≥300 µs. The lineage gets away with 416 ns because those rigs typically sit behind a 74HCT level shifter that restores threshold margin. **Generalisable:** datasheet timing windows shrank across WS2812B revisions — design new encoders against the NEWEST revision's T0H max (≤380 ns), and treat "RMT-captured waveform is correct but the strip disagrees" as a threshold/margin problem, not a logic problem.
-
-Bench-procedure notes worth keeping: a board that drops STA mid-session falls back to softAP silently — poll the device's reachability before interpreting an unanswered API call as a wedge (one STA beacon-timeout drop was observed seconds after the LCD bus first went live; not reproduced since, watch for recurrence). And the differential test that cracked the case twice: drive the same pin/strip with the already-proven RMT driver — it exonerates wiring, power, and the strip in one move.
-
-## Diagnosing LED flicker: eliminate firmware with hardware tests before blaming (or fixing) the wire
-
-A classic ESP32 driving a WS2812 strip on RMT showed random wrong colours on LEDs the effect left black ("blue flicker", later "random flicker"). The temptation is to guess — WiFi interference, a buffering bug, timing — and start changing code. The bench session that resolved it instead ran a four-step elimination, each step a *measurement*, and the answer fell out:
-
-1. **Capture the source/preview buffer** — it held zero stray colour. The effect output is clean, so the corruption is downstream of the logical buffer (not an effect or correction bug).
-2. **Run the whole-frame loopback self-test** through a short jumper — bit-exact `PASS`, repeatedly, even under WiFi load. The RMT encode + transmit emit a correct WS2812 waveform on real silicon, so the firmware/peripheral is innocent.
-3. **Sweep `txPowerSetting` 20 → 1 dBm** while watching the strip — the flicker was *constant*. A ~50× drop in radiated RF changed nothing, so it is **not** WiFi coupling into the data line (the standing hypothesis, disproven by the experiment).
-4. **Check the pulse timing** — 350/700/1250 ns, spec-exact, and the loopback confirms the wire carries them. Not a timing-margin bug like the LCD T0H case.
-
-With every firmware cause eliminated *by test*, the remaining cause is the physical data path — and "constant regardless of TX power" specifically fingers electrical signal integrity over radio. On a 3.3 V part driving WS2812 directly, the dominant cause is the missing 3.3 → 5 V level shift (the LED's logic-high threshold sits above what the GPIO drives, so marginal bits flip under any noise). Fix is hardware: level shifter, series resistor, shorter/grounded wire — documented in [LED signal integrity](../usecases/led-signal-integrity.md).
-
-**The transferable lesson** is the order: when hardware output looks wrong, prove the firmware is *not* the cause with a measurement at each layer (buffer → encode/transmit loopback → environment sweep) before either editing code or buying parts. Two strong hypotheses here (a buffering regression, WiFi interference) were both wrong, and only the measurements said so — guessing would have burned a level shifter's worth of time on the wrong layer, or "fixed" code that was never broken. The whole-frame loopback self-test exists precisely so step 2 is a one-click answer instead of a scope session.
-
-**Red-herring note:** the flicker's *appearance* shifted (blue-only → random) after an unrelated change (the `lightPreset` default went RGB→GRB, remapping which channel carries which colour) plus a pin swap. The underlying electrical fault was identical; only the colour mapping over it changed. A changed symptom is not proof a code change caused it — confirm the mechanism, not the surface.
-
-## ESP32-P4 support, round 1 — per-board Ethernet pin config, and the P4's WiFi reality
-
-Adding the Waveshare ESP32-P4-NANO (round 1 of 4: board + Ethernet-only; later rounds add the Parlio LED driver, C6-co-processor WiFi, and the Parlio loopback). Two findings worth keeping:
-
-**The P4 has no native WiFi.** `SOC_WIFI_SUPPORTED` is absent on esp32p4 (it has EMAC, RMT, LCD_CAM i80, and Parlio, but no radio). WiFi on these boards comes from an on-board **ESP32-C6 co-processor over SDIO** via the `esp_wifi_remote` / esp-hosted stack — which is a managed component, **not in mainline IDF v6.1-dev**. So round 1 ships Ethernet-only (`MM_ETH_ONLY`, WiFi components excluded), and round 3 will introduce a WiFi abstraction seam so the P4 routes to the remote stack while classic/S3 stay on native `esp_wifi`. The C6 SDIO pins on the P4-NANO (recorded for round 3, and so round-2 Parlio avoids them): CLK 18, CMD 19, D0-D3 14-17, C6 reset 54.
-
-**Ethernet pins became a per-target compile-time config, not scattered #ifdefs.** `ethInit()` had the Olimex RMII/PHY pins baked in as literals. The P4-NANO needs different ones (IP101 PHY addr 1, MDC 31, MDIO 52, reset 51, and crucially an *external* 50 MHz RMII clock fed IN on GPIO50 — `EMAC_CLK_EXT_IN`, the opposite of Olimex's `EMAC_CLK_OUT`). Rather than `#ifdef` the pins inside `ethInit`, an `EthPinConfig` struct + a `constexpr ethPins = isEsp32P4 ? {…} : {…}` lives in `platform_config.h`, and `ethInit` reads it. This keeps the platform-boundary rule (compile-time branching is `if constexpr` on config flags, not `#ifdef` in domain code), turns the Olimex magic numbers into a named config, and is the seam future eth boards extend. Full *runtime* PHY/pin selection stays a 2.0 backlog item — this is compile-time-per-target, which is all the board variants need.
-
-**The IP101 PHY driver is a managed component in IDF v6.** IDF v6 moved every per-PHY driver out of `esp_eth` core into the component registry (`espressif/ip101`). The generic PHY (Olimex LAN8720) stays in core, so only the P4 build pulls `espressif/ip101` (added to `idf_component.yml`); the IP101 ctor is behind `if constexpr (ethPins.isIp101)` so non-P4 builds never reference the symbol. A subtle build-script trap fixed alongside: the eth-fragment detector matched only `.eth`, so the new `sdkconfig.defaults.esp32p4-eth` (ending `-eth`) would have silently set `MM_NO_ETH` and stubbed Ethernet out — the matcher now accepts both `.eth` and `-eth`.
-
-## ESP32-P4 round 2 — Parlio LED driver: a simpler peripheral, the same encoder
-
-Adding `ParlioLedDriver` (the P4's parallel WS2812 path, round 2 of 4) turned out to be mostly *subtraction* from the LCD driver, not addition — worth recording why, so the next parallel-output backend (16-lane, Teensy FlexIO) starts from the right base.
-
-**Parlio is a simpler peripheral than the LCD_CAM i80 bus, so the driver is simpler too:**
-- **No sacrificial WR/DC pins.** The i80 bus mandates two real GPIOs (pixel-clock + data/command) that WS2812 ignores — `LcdLedDriver` carries `clockPin`/`dcPin` controls just to feed them. Parlio generates the clock internally (`output_clk_freq_hz`), so `ParlioLedDriver` has neither control.
-- **No exactly-8-pins rule.** The i80 layer rejects a partial bus (`esp_lcd_new_i80_bus` requires a real GPIO on every data line), which is why `LcdLedDriver` demands exactly 8 pins. Parlio takes `data_gpio_nums[]` with unused = `-1`, so the driver runs on any 1–8 lanes. The "exactly 8" validation simply isn't there.
-
-**The encoder is shared, not duplicated.** `LcdSlots.h::encodeWs2812LcdSlots` outputs "one bus byte per slot, bit L = data line L" — and a Parlio bus byte is byte-identical to an i80 bus byte. So `ParlioLedDriver` reuses the same encoder and the same `unit_LcdLedEncoder.cpp` test; no new encode code, no new encode test. (This is the "reuse a recognisable shape" rule paying off: a second parallel peripheral cost ~0 encoder lines.)
-
-**One Parlio API constraint to remember:** `data_width` must be a *power of two* (`(w & (w-1)) == 0`), ≤ `SOC_PARLIO_TX_UNIT_MAX_DATA_WIDTH`. We always create the unit at `data_width = 8` (matching the encoder's 8-bit bus byte) and set unused lanes' GPIOs to `-1` — rather than narrowing the bus to the lane count, which would mismatch the encoder's byte layout. So 1–8 driver lanes all map onto an 8-wide Parlio bus; the unused high bits are simply not wired.
-
-**Clock math is identical to the LCD driver:** Parlio's default `PLL_F160M` (160 MHz) ÷ 60 = 2.67 MHz = the same WS2812 slot rate (375 ns) the LCD driver settled on for the T0H-margin reason — so the timing decision carries straight across with no rework.
-
-**Default pins must dodge the P4's strapping GPIOs.** The first cut of `ParlioLedDriver` defaulted `pins` to `36,37,38,...` — but **GPIO 34-38 are the ESP32-P4's strapping pins** (boot-mode control). It inited fine on the bench (nothing drove them during the boot window), but defaulting LED *output* onto strapping pins is a latent footgun: a driven level at the wrong moment can change boot mode. Fixed the default to 8 strapping-safe pins (`pins="20,21,22,23,24,25,26,27"`) with `ledsPerPin="64"` putting all 64 lights on lane 0 — a serpentine 8×8 panel is one 64-LED strand, and the other 7 lanes idle LOW at zero cost (the parallel DMA transfer time is set by the *longest* lane, not the lane count, so 1-of-8 is the same speed as 8-of-8; reassigning `ledsPerPin` adds strips later with no pin change). The clear GPIOs on the P4-NANO, after Ethernet (28-31, 49-52), C6 SDIO (14-19, 54), I2C (7-8) and **strapping (34-38)**, are 20-27, 32-33, 39-48. **Generalisable:** when picking default output pins for a new chip, pull the strapping-pin list from the datasheet first — "it booted on the bench" doesn't prove a strapping pin is safe to drive, only that nothing drove it at the wrong instant.
-
-## Audio input (INMP441 mic) — ship the manual core, design fresh from the datasheet
-
-The first audio-reactive capability: `AudioModule` (a SystemModule Peripheral) reads an INMP441 I²S mic and publishes an `AudioFrame` (level + 16-band spectrum + dominant peak) that `AudioVolumeEffect` and `AudioSpectrumEffect` consume. Decisions worth keeping:
-
-**Two seams, everything else host-tested.** Only the I²S read and the FFT *kernel* sit behind the platform boundary (`platform_esp32_i2s.cpp`: IDF `i2s_std` + esp-dsp's float `dsps_fft2r_fc32`). All the signal math — DC strip, RMS, the Hann window, the magnitude→16-band log mapping — is pure header-only domain code (`AudioLevel.h`, `AudioBands.h`), the `RmtSymbol.h`/`LcdSlots.h` shape. The desktop `audioFft` stub is a real (naive O(n²)) DFT, so the *whole* pipeline (window → FFT → bands) runs end-to-end in CI on synthesized sines with no hardware. esp-dsp **float** (not fixed-point) is the right call on an FPU chip — Espressif's own benchmark fact.
-
-**Effects reach the producer via a static accessor, not a boot setter.** The normal producer/consumer wiring passes a `const Foo*` to the consumer once in `main.cpp` (PreviewDriver→HttpServer). That works when both ends are boot-wired singletons — but an audio effect can be *added through the UI after boot*, and a boot setter only ever wired the boot instance. So `AudioModule::latestFrame()` is a static accessor: the active mic registers itself in `setup()`, clears the pointer in `teardown()`, and returns a static silent frame when there's no mic. Any add/remove order yields the live frame or valid silence, never null. Reach for this whenever a *user-addable* consumer needs a *singleton* producer's data.
-
-**Found on hardware, pinned by tests.** Two bugs the desktop build couldn't surface, both now regression-tested: (1) a missing `registerType` made `create("AudioModule")->markWiredByCode()` deref null and **boot-loop** — the fix is the registration plus a null-guard, pinned by a "registered + createable" test; (2) the I²S read **blocked the render tick** ~7.7 ms at a 20 ms timeout — fixed to a non-blocking read plus a cross-tick sample accumulator (a full 512-sample block takes ~23 ms at 22 kHz, longer than one tick), dropping AudioModule to ~400 µs. The INMP441 is also on the **left** I²S slot here (the right reads empty) — one config line, the first bench suspect when level floors with sound present.
-
-**Shipped the manual core; the adaptive conditioner was prototyped and removed.** The mic, FFT, log/dB scale, two effects, and a `floor`/`gain` manual control surface are the solid, host-tested increment that landed. A self-calibrating conditioner (auto noise-floor + AGC + smoothing, goal "sound off → dark, sound on → vivid") was built and then *deleted* — it needs bench tuning in a **quiet** room, and the development environment (a campground van with strong, *varying* low-frequency engine/inverter rumble) was the adversarial worst case that kept it from settling: a per-band auto-floor removes *constant* tones but can't track *varying* broadband ambient; global AGC pumps the residual to full in silence; a relative per-band floor fixes treble over-cut but flattens the spectrum. Lesson — **land the manual core, treat adaptive auto-tuning as its own increment, and tune it where the noise floor is real-quiet, not in the field.** Also: `level` is overall RMS loudness (independent of the FFT) — don't derive it from the bands, or it stops fluctuating with volume.
-
-**Designed fresh from the datasheet + textbook DSP, not from a prior project.** Per the product owner: don't trace WLED-MM (or any existing controller) for naming, structure, or functionality — build something independent. The concrete DSP choices and *why* (Hann/RMS/geometric-bands/argmax, and why a flat ±3 dB mic needs no per-frequency correction table) are documented in the module spec, [AudioModule.md](../moonmodules/core/moxygen/AudioModule.md), where an integrator looks for them. The lesson worth keeping here is consistent with the repo's *Industry standards, our own code* principle (study with respect, don't copy): **reference proven behaviour, don't trace structure.** Here the datasheet made even the behaviour-reference unnecessary — a flat ±3 dB mic has no per-frequency error to correct, so the hand-tuned band-correction table years of prior-project work produced was the wrong tool, and the textbook defaults were enough. Read prior art to understand *what* works and *why*; let the hardware datasheet and standard DSP decide *how*, so the result is independent by construction rather than a renamed trace.
-
-## ESP32-P4 round 3 — WiFi via the C6: the abstraction the earlier round feared wasn't needed
-
-Round 1 recorded that round 3 "will introduce a WiFi abstraction seam so the P4 routes to the remote stack while classic/S3 stay on native `esp_wifi`." The actual implementation was far smaller, and *why* is the lesson.
-
-**`esp_wifi_remote` is API-compatible, so there was no seam to build.** From the P4's side you still call `esp_wifi_init()`, `esp_wifi_connect()`, `esp_wifi_scan_start()`, `esp_netif_create_default_wifi_sta()` — identical signatures; the component forwards them to the C6 over SDIO. So the entire existing WiFi platform layer (`wifiStaInit`/`wifiApInit`/scan/tx-power, ~230 lines) compiles and runs **unchanged** on the P4. The only genuinely new code is a **two-call prelude** — `esp_hosted_init()` + `esp_hosted_connect_to_slave()` — that must run *before* `esp_wifi_init()`, added to `ensureWifiInit()` behind `if constexpr (platform::usesRemoteWifi)` (= `isEsp32P4 && hasWiFi`). Lesson: before designing an abstraction for "two backends," check whether the vendor already made them API-identical — here the "seam" was a 15-line prelude, not a routing layer. *Concrete first* would have caught this even without the foresight; the round-1 note over-scoped from a position of less information.
-
-**Init ordering is the whole risk.** Espressif's docs and the community starters are emphatic: esp_hosted must be fully up before anything touches the WiFi stack, and wrong ordering surfaces as **NVS errors / asserts / a silent hang**, not a clean error — so it reads like an unrelated bug. The prelude-before-`esp_wifi_init` placement is the mitigation; it's the first thing to check if a P4-wifi build misbehaves at boot.
-
-**Pulled P4-only via a `rules` gate.** `esp_wifi_remote` + `esp_hosted` are added to `idf_component.yml` with `rules: - if: "target == esp32p4"`, so classic/S3 and the eth-only P4 build never fetch or compile them. This is the clean answer to "managed components are per-project" — the gate makes the pull per-target. (The pre-existing `ip101` entry could use the same gate but doesn't; not worth churning.)
-
-**A deliberate v6.0-floor exception.** These components live outside mainline v6.0, so the build steps below the [v6.0 floor](../building.md#esp-idf-version). That floor has an explicit-exception clause exactly for cases like this: the product owner accepted it consciously, it's documented at every introduction site (the yml, the sdkconfig fragment, the platform flag, building.md), and it's scoped to the P4 — the other targets keep the v6.0 fallback intact. Lesson: a floor rule with a *documented-exception* path beats a rigid one; the exception stays honest because it's recorded, not silent.
-
-## RMT timeout: don't cancel a stuck transfer — the cancel crashes classic ESP32
-
-A deferred "fuller error handling" item for `rmtWs2812Show`/`rmtWs2812Wait` (🐇 CodeRabbit PR#17) turned out to be mostly *already done* and partly *actively harmful* — a good case of *default to subtraction*.
-
-**Most of it had already landed** in the multi-pin (2a) work: `rmtWs2812Transmit` already returns the `rmt_transmit` result, and `RmtLedDriver::loop()` already tracks `started[]` so it only waits on channels whose transmit succeeded (a failed one gives no done-callback, so waiting would burn the full 1 s timeout). And there's no mid-transmit corruption risk because the symbol buffer is re-encoded from scratch *before* any transmit each tick. So two of the three "to-do" items were no-ops.
-
-**The remaining item — cancel the in-flight transfer on timeout via `rmt_disable()` — is a trap.** `rmt_disable()` while a transmission is still active triggers an **interrupt-WDT panic on classic ESP32** (espressif/esp-idf#17692; classic-only, S3/C6/P4 unaffected). That trades a self-healing dropped frame for a crash on a shipping target — strictly worse, and a direct *"crashed is not acceptable"* violation. So the right change was to **not** add the cancel, and instead replace the vague "deferred" comment with a sourced explanation of why we deliberately leave a timed-out transfer alone (it self-heals: next tick re-encodes and re-transmits; a still-busy channel just fails its `rmt_transmit` cleanly, and `started[]` skips the wait). Lesson: a deferred-improvement note is a hypothesis, not a spec — verify the improvement is real *and* safe on every target before implementing it; sometimes the finished work is "document why the current code is already right."
-
-## Pin defaults: assign one only when it cannot do harm
-
-A mic-less **classic ESP32** boot-looped (TG1WDT_SYS_RESET at ~736 ms, no panic backtrace — a silent hang). Bisect: clean-built the known-good commit (still looped → recent driver work innocent), then disabled the AudioModule wiring in `main.cpp` → booted clean → **AudioModule was the cause.** Root cause: AudioModule was **auto-wired** (`addChild` + `markWiredByCode()` in `main.cpp`, gated on `platform::hasI2sMic`), so on every boot it ran `setup()` → `reinit()` → `platform::audioMicInit()` → the IDF `i2s_channel_enable()`, which on the classic's older I²S driver **blocks forever** when no mic is clocking the pins. The watchdog fired on the stuck init. (The P4 was never affected: its newer I²S either returns a silent frame or fails cleanly without blocking — same mic-less condition, different driver behaviour. Two independent code paths, one symptom only on classic.)
-
-**The fix, per the product owner, was a design fix not a band-aid:** (1) **don't auto-wire AudioModule** — register it in the factory (`registerType`) so it's user-addable like an effect, but only when the user with a mic adds it; (2) **default the mic pins to unset (0)**, not to bench values; (3) `reinit()` **no-ops on any unset pin** (`if (wsPin==0||sdPin==0||sckPin==0) { setStatus("set …"); return; }`) so even an added-but-unconfigured module never touches I²S. Classic then boots 191 FPS, 0 WDT resets, and an added mic works once its real GPIOs are entered.
-
-**The generalisable rule the product owner drew from it: _assign a pin default only when it cannot do harm._** The test is *who fixes the pin*:
-- **Chip-/board-fixed pins → default them** (and you *must*): the RMII **Ethernet** pin map is silicon-/PCB-wired, so a default cannot do harm — and *omitting* it does, because a no-WiFi board with un-defaulted Ethernet pins can never connect to be configured (a chicken-and-egg lockout). This is why `platform::ethPins` is a compile-time-per-target constant, never a user-blank control — and it stays that way.
-- **User-soldered pins → leave them empty**: a MEMS mic or an LED strand goes wherever the user ran the wire, so any default is a guess that can drive a pin the user committed to something else. Empty until set; idle with a "set pins" status meanwhile (the robustness rule: degraded is fine, crashed is not).
-
-The LED drivers (Rmt/Lcd/Parlio) are the same Device-level case — user-soldered, so their pin defaults follow the same rule. This rule is the runtime face of the three-level **MCU → Board → Device** config-provenance model (architecture.md § Config provenance): a pin may be defaulted only at the level that actually fixes it, and the empty Device-level defaults are the correct baseline a saved device profile later *fills* rather than *overrides*. Lesson: a hard-coded pin default is a claim about the user's hardware — make that claim only when the hardware, not the user's soldering iron, decides the pin; and never auto-run a peripheral whose init can block on absent hardware.
-
-## Live reconfiguration falls out of the prepare-pass for free — MoonLight's "initless" goal, a different mechanism
-
-projectMM has a property most LED-controller firmware lacks: **every module reconfigures live the instant a control changes — pins, leds-per-pin, output protocol, mic pin/rate — with no reboot, immediately reactive on the next render tick.** The design note for *why* this exists lives in [architecture.md § Live reconfiguration](../architecture.md#live-reconfiguration-every-change-applies-without-a-reboot); the lineage and the *how-it-differs* are the lesson worth keeping here.
-
-**The lineage is MoonLight's "initless drivers."** The product owner's earlier project ([MoonLight nodes.md § Initless drivers](https://github.com/ewowi/MoonLight/blob/main/docs/develop/nodes.md)) set the same no-reboot goal at the LED-driver level, named *initless*: a driver with **no `addLeds` (FastLED) / `initLed` (Parallel LED Driver) step** — it reads a mutable Context at `show()` time, so pin allocation, leds-per-pin, RGB/RGBW and light type all change live without a restart or recompile.
-
-**projectMM reaches the same outcome by a different mechanism, so the word doesn't transfer.** Our drivers *do* have an explicit rebuild — `RmtLedDriver::reinit()` re-creates the RMT channels, the i80/Parlio drivers rebuild the DMA bus — so they are not "initless" in MoonLight's no-`initLed` sense. What makes the behaviour universal here is that the rebuild is driven by the **generic tier-3 `onBuildState()` sweep** ([§ Event triggering](../architecture.md#event-triggering-between-modules)), not hand-built per driver: any module that returns `true` from `controlChangeTriggersBuildState` inherits live-reconfig for free, which is why it spans drivers, the audio peripheral, effects, layouts, modifiers and network I/O alike. Lesson: credit the lineage for the *idea* (MoonLight's initless drivers), but name the property by what the user sees (*live, no-reboot reconfiguration*) when the mechanism differs — overloading a prior project's term onto a different implementation misleads. And: a generic prepare-pass buys breadth a per-driver technique can't — the same three tiers that rebuild a mapping LUT also re-target a GPIO, so the property generalised itself.
-
-## Lessons from the catalog-driven installer branch (3-layer device model)
-
-The installer was reworked so a board catalog (`boards.json`) sets a device up for its hardware at install time. The mechanics and schema live in the installer README; these are the hard-won principles worth keeping.
-
-**Inject from data, don't bury in code (vs MoonLight's `ModuleIO.h`).** MoonLight hardcoded ~20 boards' pin presets in firmware C++ (`setBoardPresetDefaults()`, behind `#ifdef CONFIG_IDF_TARGET_*`); adding a board is a recompile, and every binary ships every board's table. projectMM instead **injects** the same information from the catalog after flash — the firmware is a generic engine that knows nothing about QuinLED/Serg/Olimex, and the *data* specialises it. Adding a board is a JSON edit, binaries carry no board tables, and board definitions become community-contributable data. This is the *domain-neutral core* principle: the specialisation is data, not code.
-
-**Investigate before building — the device side was already done.** The original plan was a new `POST /api/preset` batch endpoint with a device-side consumer. Investigation overturned it: three install clients already fan a catalog's controls out as `POST /api/control` calls. The *real* gap was that the fan-out can't configure a module that doesn't exist on a fresh flash (a control write 404s) — solved by the **already-existing, idempotent `POST /api/modules`** the clients simply weren't driving. Lesson: the reflex to add an endpoint hid that the mechanism existed; a day of mapping the existing code replaced a new core endpoint (and a forbidden JSON-array parser) with a small client change. *Default to subtraction.*
-
-**`loopbackTxPin` — verify the claim against the code before deciding twice.** A driver loopback self-test transmits on `pins[0]`, and the bench's loopback TX jumper is a *different* pin from the operational LED pins — so the test forced retyping `pins`. A `loopbackTxPin` override control was proposed, then **dropped** on the reasoning "it only fits RMT's single pin, not Parlio/Lcd's lane array," then **re-added** after reading the code: the Parlio/Lcd loopback only drives **lane 0** with the test pattern, so a single TX override substituting for lane 0 works uniformly on all three drivers. Lesson: the "doesn't fit lane arrays" objection was an assumption about the loopback, not a fact — checking `ParallelLedDriver::runLoopbackSelfTest` (lane-0-only) settled it. Verify the mechanism before a design call that rests on how it behaves.
-
-**Board vs Device is a completeness spectrum, not two schemas.** The carrier/shield pattern (a PCB an ESP32 *module* plugs into) is a **Board** — it fixes the LED/relay pins, the MCU and strips are chosen separately. A vendor-finished all-in-one with peripherals soldered (QuinLED Dig-2-Go, Dig-Next-2 with built-in mics) is a **Device**. But they are the *same* catalog entry shape — a "Device" is just a Board entry with more of its optional `modules`/`controls` filled in (verified: every `boards.json` entry shares one schema). So **no *separate-schema* `devices.json`**: one entry type, not two. (This rejects a *second schema*, not a future *same-schema* grouping — architecture.md leaves the door open to splitting the flat list into a `devices.json` / `kind:` tag purely for organisation once it's large enough to be unwieldy; that's a file-layout choice under the sequencing rule, not a second entry type.) A board's pins fall into three categories, not two: *always-fixed* (LED outputs, status LED — default freely), *board-optional* (a populated-or-not W5500/IR/power-monitor — an opt-in peripheral block, can't default blind because the same board name ships with and without), and *user-soldered* (always unset). The optional-peripheral case (e.g. SE 16 with a W5500) is the runtime SPI-PHY mechanism, not a new one.
-
-**Per-board capability spec'n'test loop.** Each board's pin-layout `image` and product-page `url` are the *inputs* to a repeatable build loop, not just decoration: (1) read the board's capabilities off the image + link (LEDs, mic, IR, power monitor, …); (2) for a capability we already offer (an I²S mic → `AudioModule`), wire it into the entry's `modules`/`controls` with real pins; (3) for one we don't offer yet (IR receive), write a proposal against the architecture, spec it, **create test scripts** (host unit + scenario, hardware loopback where applicable), iterate until it works, *then* add it; (4) the entry grows only as far as each capability is spec'n'tested — an un-implemented capability is a recorded proposal, not a half-wired control. A fully-implemented board entry is the *output* of running this loop; the image/link tell you what to aim for. This is *Specs before code* at board granularity.
-
-**Drivers became catalog-added, with an OTA nuance.** LED/network drivers stopped being boot-wired (only `Preview` stays, since it needs the HTTP broadcaster the catalog can't supply); each board declares its driver(s) in `modules`. A fresh-erased board boots with `Drivers = [Preview]` only — the deliberate explicit-add model. **The nuance hardware surfaced:** an OTA update *without* erase keeps a device's previously-persisted drivers (they're saved config the new firmware reloads as user-added children), so the clean Preview-only state applies only to a fresh/erased flash. That's correct — an update shouldn't wipe a user's configured drivers — but it means "out-of-box" and "after-update" differ, which isn't obvious until you flash a non-erased board.
-
-**A persistence overlay must distinguish "key absent" from "value 0".** The runtime-Ethernet-PHY work moved pin/PHY config from a compile-time `constexpr ethPins` into persisted NetworkModule controls (`ethType`, pin GPIOs, …) with **non-zero per-chip defaults** (P4 IP101 = `ethType` 2). That exposed a latent bug in `applyControlValue` (the persistence load path): it used `json::parseInt(json,key)`, which returns 0 for an *absent* key — indistinguishable from a real 0 — and then wrote that 0 into the control under the Clamp policy. So loading an older/partial `.json` that omitted a key **clobbered the control's default with 0**. On the ESP32-P4 this zeroed `ethType` (2 → 0 = none), so `ethInit()` dispatched to "no Ethernet": link LEDs on, but no DHCP. It was invisible on classic/Olimex (their eth defaults are mostly 0 anyway) and on `main` (which still read the `constexpr ethPins` directly), so it only bit once eth config became persisted controls with meaningful non-zero defaults. **Fix:** a `json::hasKey()` guard in `applyControlValue` — an absent key leaves the control untouched (preserves its default); a present key (even value 0) still applies. Lesson: any "control resets to its default/0 after reboot" symptom is a persistence-overlay smell, not a control-init bug; a flat JSON parser that returns a zero sentinel for missing keys MUST be paired with a presence check before the value is applied as authoritative. The decisive debugging move was a `std::printf` of the runtime struct over the P4's *secondary* USB-Serial-JTAG console (stdout reaches USB even when ESP_LOG/UART is on GPIO 37/38), after a `git worktree` bisect (round-1 ✓, main ✓, uncommitted ✗) proved it was our code, not hardware or IDF.
-
-**A GPIO pin is its own control type (`ControlType::Pin`), not an overloaded int16.** Pins were first added as `addInt16` with a `-1..48` range, which the UI rendered as a *slider* — meaningless for a GPIO, and the cap wrongly excluded the P4's high pins (MDIO 52, clk 50). Dropping the range didn't help: the UI's `int16` case *always* draws a slider (an unbounded int16 falls back to a −100..200 percentage slider that position/region controls rely on), so int16 couldn't be made to mean both "position slider" and "pin number." The fix is a dedicated `Pin` type: `int8_t` storage (one byte — a GPIO never exceeds ~54, and on a DRAM-scarce ESP32 the per-pin byte matters across many pin controls), −1 = unused, the UI always renders a plain number input keyed off the `"pin"` type string, and min/max are a server-side write-clamp guard only. Serializes/parses as a plain integer (same as int16). This also serves every future pin control (LED-driver clockPin/dcPin, GyroDriver SDA/SCL, board pins) — they migrate to `addPin` for free. Lesson: when one control type is doing two jobs with different UX (slider vs number), that's the smell for a new type, not a range hack; and pick the smallest storage that fits the domain (int8 for a pin).
-
-**`deviceName` (identity) vs `deviceModel` (product) vs board (bare PCB) — one term was doing three jobs.** "Board" had been overloaded to mean the per-unit network identity, the hardware product/catalog key, AND the bare PCB. Untangling it: `deviceName` is the **per-unit identity** — one string that drives mDNS (`.local`), the SoftAP name, and the DHCP hostname, so the device shows up under one name everywhere; it's RFC-1123-coerced (`sanitizeHostname`) because it becomes a hostname. `deviceModel` is the **hardware product** (the `deviceModels.json` catalog key, e.g. "projectMM testbench S3") — display-form, spaces allowed, never a hostname. "Device" is the umbrella noun; "board" now means **only the bare PCB**. This drove the BoardModule→SystemModule fold (the identity is core unit state, not a separate module), the `board`→`deviceModel` rename across catalog/installer/Improv (SET_BOARD→SET_DEVICE_MODEL, byte 0xFE unchanged), and the eth pin-map clarification (driver = firmware, pin map = firmware-seeded but **deviceModel-authoritative** so an Olimex entry can override). Lesson: when one noun answers three different questions ("what do I call this unit on the network?", "what product is it?", "what's the bare board?"), that's a naming smell — split it into the qualified terms, pick one umbrella word, and make the split visible in every layer (control names, RPC symbols, catalog keys, docs) so the three concepts can't re-merge.
-
-**"Improv = REST over serial" — one apply-core, two transports, and the testability that follows from extracting the hard part.** The deployed HTTPS installer couldn't configure a flashed device: a browser blocks an HTTPS page from POSTing to an `http://` device (mixed-content), and the `?deviceModel=` pull/handoff that replaced it only ran if the user opened that exact link. The fix reframed the problem — the installer already owns the USB serial port during provisioning, so push the config over it as the *same REST operations the HTTP API runs*: a new `APPLY_OP` (0xFC) Improv vendor RPC whose payload is `{"op":"add|set|clearChildren",…}`, the same JSON a `POST /api/modules`/`/api/control` body carries. On the device the op routes to **one transport-free apply-core** (`HttpServerModule::applyAddModule/applySetControl/applyClearChildren/applyOp`) the HTTP handlers also call, so a network REST call and a serial APPLY_OP execute identical code; the handlers became thin `switch(applyX())` → status-code mappers. This **deleted** the whole browser handoff (device-side catalog fetch, `?deviceModel=` decoration, the inject button) — a net subtraction — and works on Ethernet-only firmware once the Improv listener is decoupled from WiFi (the vendor RPCs compile in unconditionally; only `WIFI_SETTINGS`/`GET_WIFI_NETWORKS` stay `#ifndef MM_NO_WIFI`). Lesson 1: when a push is blocked by the *medium* (mixed-content on HTTPS), look for a medium you already control (the serial port mid-flash) instead of bolting on a fragile pull. Lesson 2 (the one with legs): the way to make it *provable* was to **extract the hard part into a pure core primitive** — the chunk reassembly + out-of-order/duplicate sequence guard moved from the ESP32-only handler into `src/core/ImprovOpReassembler.h` (header-only state machine, returns `Continue/Ready/Error`), and the JS frame builders into `docs/install/improv-frame.js` so `node:test` imports them without the orchestrator's browser deps. Both are *Complexity lives in core; domain modules stay simple* applied for testability: the device handler keeps only its serial I/O, the algorithm gets unit-tested on the desktop, and a format implemented three times (device C++, Python, JS) is pinned by **one shared golden vector** asserted in `test/python` + `test/js` — a contract test is the right answer to *forced* duplication no shared compilation target can remove. The reflex worth keeping: a hard mechanism buried in a platform `.cpp` that "can only be tested on hardware" is a smell — extract its pure core, and "rock solid proven" becomes a unit test instead of a bench session.
-
-**A periodic re-broadcast to let late joiners "catch up" is a hack wearing a keepalive costume.** The 3D preview sends a coordinate table (positions) once, then per-frame colour. The original implementation re-sent the *whole table every ~1 second* "so a client that connected after the last rebuild catches it." It looked fine — a fresh page recovered within a second — so it shipped and sat there. But it's a workaround, not the mechanism: it rebuilt the full table from the layout **every tick-second forever**, on the hot path, whether or not anyone had connected and whether or not anything changed — and it papered over a missing request/response with polling. The correct construct is event-driven: send the table **when it actually changes** (`onBuildState` — grid/layout/LUT rebuild) and **when a client asks** (a new WS connection bumps `BinaryBroadcaster::clientGeneration()`, which `PreviewDriver::loop()` watches and re-sends on change). That's strictly *less* code than the timer and zero idle cost. How it sneaked past review: the workaround *worked* in casual testing and its cost was invisible until a later change made each rebuild heavier and the per-module tick was profiled. Lessons: (1) "re-send periodically so it eventually syncs" is the polling-instead-of-events smell — ask "what's the event that should trigger this?" and trigger on *that*; (2) a recurring rebuild on the hot path must justify itself every tick, so "every second, just in case" fails *Data over objects / fastest hot path* on sight; (3) this is *Continuous refactor, no hacks* — the fix isn't a scheduled cleanup, it's "the moment you see a keepalive timer standing in for a request, replace it." The guard is a test that advances the clock several seconds with no client change and asserts the table is **not** re-sent (the old timer would have).
-
-**When a working seam regresses after your "fix," suspect the fix — and measure with a tool faithful to what the user sees.** The resumable preview send (`sendBufferedFrame`/`drainPreviewSend` — stream the producer buffer a memory-adaptive chunk per `loop20ms`, drop-new backpressure, downsample + display cap) shipped working on all three boards. A later attempt to *also* route the coordinate table + downsampled colour frame through the resumable path (removing the synchronous `sendAllOrClose` spin-and-close) looked principled but **regressed every board into an intermittent stall**, through several variants. Three lessons compounded: (1) **Stop at the first failed fix on a working path.** Each "improvement" to a seam that already worked added a new failure; the discipline (CLAUDE.md *Anti-stalling*) is to revert to the known-good state at attempt two, not re-engineer. The committed synchronous coord/downsampled path *closes a wedged client past a spin budget and lets it reconnect* — not elegant, but proven; the elegant unification wasn't worth a regression nobody could pin. (2) **A measurement tool must be faithful to the real client or it invents and hides bugs.** A plain one-shot WebSocket probe *gave up on close* where a browser *reconnects*, so it reported stalls users never saw AND missed blips they did — it sent the debugging in circles for a whole session. The fix was a browser-faithful probe (`moondeck/diag/preview_health.py`: reads binary frames, sends the 25 s keepalive ping, auto-reconnects with backoff — exactly `app.js`'s `connectWs`); only then did probe and eyes agree. (3) **A stale process masquerades as a code bug.** "No preview on the PC build" with a corrupt-looking on-the-wire coordinate count survived every fresh rebuild — because a **38-hour-old desktop binary** still held port 8080; the freshly-built one couldn't bind and the browser/probe hit the stale one. The tell was the *uptime*, spotted by the product owner, not the diff. Lesson: before bisecting a "rebuild didn't fix it" bug, confirm the artifact under test is the one you built (check the process uptime / `build` timestamp / what's actually bound to the port). The faithful probe is now the standing way to measure preview health on any target (PC + the three boards), wired into MoonDeck's Live tab.
-
-**Don't hold a vendor library's async handle across your own event loop — it races the library's internal timers.** A UI refresh intermittently crashed the device (`assert failed: xQueueSemaphoreTake queue.c:1709 (( pxQueue ))` — a null FreeRTOS queue — inside the espressif mDNS component's `mdns_query_async_get_results`, plus an `Interrupt wdt timeout`). The mDNS *browse* (discovering peers for the "Your devices" list — distinct from mDNS *advertise*, which serves `.local` and was never the problem) used the async API: `mdns_query_async_new` returns a handle that `DevicesModule` held across ticks, polling it each `loop1s` with a 0 ms timeout. The trap: the mDNS component's **own task** owns that handle's queue and **frees it when the query's window (3 s) expires** — so a poll landing in the gap after expiry asserts on a freed queue. It was intermittent and grid-size-sensitive (a bigger grid lengthens the tick, widening the gap) and looked like "refresh crashes it" only because a refresh's activity coincided with the poll. **First fix attempt was wrong:** I assumed a *service-table mutation* (live rename re-registering `_http._tcp`) tore the handle down and added a cancel-before-mutate guard — it didn't fix it, because the freeing party is the component's expiry timer, not our code. **Real fix:** stop using the async-handle API entirely — replace the start/poll/stop trio with one synchronous `mdnsBrowse()` (`mdns_query_ptr`) that queries, delivers results, and frees everything in a single call, holding **no handle across ticks**, so the race window can't exist. The catch that synchronous introduced: `mdns_query_ptr` blocks the *full* timeout (it waits the whole window for late responders, no early return) and `loop1s` is charged to the tick — an 80 ms query tanked the tick. So **throttle**: browse one service type every ~8th tick with a ~60 ms timeout — one brief hiccup every ~8 s, invisible for discovery, FPS untouched in between. Lessons: (1) a library's async/iterator handle is only valid between *its* lifecycle events — if you can't see/where those fire (here, an internal expiry timer on another task), don't hold the handle across your loop; prefer a self-contained synchronous call that owns the whole lifecycle. (2) An *intermittent, load-dependent* crash whose backtrace sits in a vendor component is a **lifecycle race**, not a component bug — but find the *actual* concurrent actor before "fixing" (my first guess at the actor was wrong and the fix did nothing). (3) Trading async for synchronous trades a race for a blocking cost — budget it (throttle + bound the timeout) so the cure isn't a tick-killer. (4) Desktop stubs these mDNS calls to no-ops, so it's a hardware-only fix the unit suite can't reach; the reproduction (concurrent WS churn at a large grid → crash before, stable after, uptime climbing) is the proof, in the commit, not a desktop test.
-
-**A dead control that was always meant to be functional belongs in the mechanism that already expresses it, not where it happens to sit.** Six persisted-but-ignored Layer controls (percent region carving) were due to be wired into `rebuildLUT`; the product owner's question — *can a modifier already do this?* — was the better path. `ModifierBase`'s two virtuals (`logicalDimensions`, `mapToPhysical`) express carving exactly, so it shipped as a modifier and the Layer controls were deleted. Lessons: (1) before wiring an inert control where it lives, check whether an existing mechanism already covers it. (2) "make the default fastest" is best met by making the default the *absence* of the feature — full coverage = no modifier = the existing fast path, zero added cost — not a guarded branch through it.
-
-One sub-decision the implementation forced: the boundary rounding. The original spec said inclusive-ceil ("start 33/end 66 on a 4-wide axis → pixels 1..3"), which on a 128-wide axis makes `end=50` land on pixel 64 *inclusive* — so two abutting layers (0..50, 50..100) **overlap by one pixel** at the seam. The product owner chose **half-open `[start, end)`** instead: `end=50` → pixels 0..63, and 0..50 + 50..100 tile a 128 axis into 0..63 / 64..127 exactly, no overlap, no gap (with a min-1-pixel floor so tiny panels still get a non-zero region). Lesson: when a region/range feature will be used to *tile* a space, half-open intervals are the textbook choice (same reason `[begin, end)` is the C++ iterator convention) — inclusive bounds double-count the seam.
-
-Lessons: (1) a persisted-but-inert control is a feature with no home yet — before wiring it where it sits, ask whether an existing mechanism already expresses it (the modifier interface did, completely). (2) "make it the fastest at the default" is often best met by making the default the *absence* of the feature, not a fast branch inside it. (3) a feature framed as "a Layer property" may really be "a composable transform" — the modifier framing also unlocked stacking for free. (4) reach for half-open intervals whenever regions abut.
-
-## Composable modifiers — invert the map build (physical→logical), don't bolt fan-out onto the old interface
-
-Modifiers needed to chain (Region then Multiply then Rotate), but the old interface — `mapToPhysical(logicalCoord) → [physical indices]`, a virtual→physical **fan-out** — didn't compose: stages emitted flat indices, not coordinates, and chaining would need a product-of-`maxMultiplier` fan-out ceiling (the exact overflow class that caused the multiplyZ black-screen). The fix was to **invert the build to physical→logical**, adopting MoonLight's proven model (the product owner's prior engine) in projectMM's own code: each modifier becomes an in-place coordinate fold, and the Layer walks the *physical* lights, folding each through the enabled chain to its logical cell. Three hooks — `modifyLogicalSize` (fold the box), `modifyLogical` (fold a coord, return false to reject), `modifyLive` (per-frame remap for Rotate).
-
-Why the inversion was the right call, not just the chaining bolt-on:
-- **Fan-out becomes free.** N physical lights folding onto one logical cell *is* the fan-out — no fan-out list, no product ceiling, no overflow. `destinationCount ≤ driverCount` is now a hard invariant. The whole `maxMultiplier`/scratch-buffer/`buildBoxToDriver`/`buildSparseIdentityLUT` machinery deleted.
-- **The hot path is untouched.** Our `MappingLUT` is a CSR keyed by logical index; the inverted build is a scatter onto arbitrary logical keys, which doesn't fit `setMapping`'s in-order contract — so the build is a textbook **counting-sort CSR construction** (count, prefix-sum, scatter, replay) entirely on the cold path. The per-frame `forEachDestination` read is byte-identical.
-- **Static vs dynamic split correctly.** Mask/tile/crop fold forward at build time (`modifyLogical`); rotation gathers backward per frame (`modifyLive`) — each in its natural direction, so rotation keeps its clean inverse-sample (no gaps), and a static-only chain pays *nothing* per frame (the live pass is gated on `hasModifyLive()`).
-
-Lessons: (1) when a feature "doesn't compose," check whether the *interface direction* is wrong before adding machinery to force it — inverting the build deleted more code than it added. (2) A proven external model (MoonLight) is worth adopting wholesale when it's the textbook approach (backward mapping + LUT bake), but write it fresh against your own structures (our CSR, our names) rather than porting. (3) Matrices compose the *affine* subset cleanly (Rotate is written as an explicit 2×2 matrix, the codebase's matrix reference) but can't express masks/tiles — so the coordinate fold is the general composition model, with a matrix-backed modifier as a special case the same interface hosts.
-
-## MoonLive: build around expressions + host-bound functions, not statement shapes
-
-The MoonLive live-script compiler (IR rung) was first built around the *statement shape* `setRGB(idx, r, g, b)` — the parser had per-slot rules (index could be `random16`, colours were literal-only) and the IR had an RGB-specific `Store` op baked into the **core**. Three product-owner remarks exposed the same root flaw: (1) `random16` only worked in the index slot, not any argument; (2) `random16(255)` capped at a byte because the index/colour validators conflated ranges; (3) the core compiler was light-domain-specific (`setRGB`/`fill`/`Store` hardcoded), violating *Domain-neutral core*.
-
-The fix was the ESPLiveScript / ARTI / doc-§3.4 model: **the core knows only expressions + a generic call mechanism; the host registers its functions in a builtin table.** Every argument parses as an expression (a literal or a nested call), so `setRGB(random16(256), random16(256), 30, 0)` works and a number is a uint16. `setRGB`/`fill`/`random16` — the LED *names* and the RGB meaning — live only in the light-domain registration (`MoonLiveBuiltins_light.h`); the core sees a neutral `BuiltinTable` of `{name → Call(fn ptr) | Inline(opcode tag)}`. A buffer writer is `Kind::Inline` (lowers to stores — the hot-path fast path, no per-pixel call); a pure helper is `Kind::Call`. A mechanised test pins the neutrality: with an empty table the core knows *no* functions, and a host can register an arbitrary name (`paint`) against the same machinery.
-
-Two codegen lessons surfaced fixing it: (1) **the live-vreg-across-call contract must hold for ANY expression, not a hand-ordered one** — once arguments can be calls, a value computed before one call can be live across a *second* call; the assembler's `call()` must save/restore the whole caller-saved register set (host: a full stp/ldp frame; Xtensa: s32i/l32i of the rotate-out registers a8/a9/a11, with the result stashed in a non-saved reg across the restore). (2) **register budget is real on the MCU** — fold the address into a dead vreg (WriteRGB writes into the index register after `index *= cpl`) rather than reserving fresh scratch, so a multi-call statement fits the small windowed register file.
-
-Lessons: (1) when a language "can't express X in slot Y," the fix is almost always *real expressions*, not a per-slot special case — build the general grammar once. (2) Domain-neutrality is testable: assert the core, given an empty host table, knows nothing — if it compiles a domain function, the domain leaked in. (3) The bound-function table is the same seam for *speed* and *neutrality*: the descriptor carries how it lowers (inline store vs call), so the core stays LED-free while the hot path stays inline.
-
-### MoonLive RISC-V backend + vreg reuse (the third ISA, and what it exposed)
-
-Bringing up the ESP32-P4 (RISC-V) backend — a third per-ISA assembler + lowering behind the same neutral IR — was mechanical *and* revealing. Mechanical: RV32 is uniform 4-byte instructions and a standard (non-windowed) call ABI, so the assembler is simpler than Xtensa's; every encoding was verified by disassembling the assembler's own output with `riscv32-esp-elf-as`/`objdump` before flashing (the same discipline that caught every Xtensa encoding bug). Revealing: the P4 was the first target where multi-call statements *failed to compile*, exposing two limits the host (14 regs) and Xtensa (12 regs, but the heaviest test was 2 calls) had masked.
-
-First, the **register file**. The front-end allocated a fresh virtual register per sub-expression and never reused it, so `setRGB(random16(..), random16(..), random16(..), 0)` needed more live vregs than the 12-register device pool — "codegen failed." The fix is the textbook tree-walk register stack: a free-list allocator where each argument temp is returned to the pool the moment its call consumes it, so a chain of N calls peaks at a handful of registers instead of growing 2N. `vregsUsed` (the lowering's reservation) is the high-water mark, which now *shrinks* because freed vregs are reused at low indices. This is the "concrete-first, the allocator arrives when a real script exhausts registers" point the design anticipated — and a 4-call statement is exactly that script.
-
-Second, the **code arena**. RISC-V's `call()` saves the full caller-saved set around each host call (~140 bytes), so four calls in one statement is ~600 bytes — past the original 256-byte staging cap. Rather than grow per-script (a moving target), the arena is sized once for the heaviest *realistic* single statement (four-arg-all-calls on the bulkiest ISA). Exec memory is cheap; a fixed worst-case cap is simpler and more predictable than dynamic growth.
-
-Lessons: (1) a third backend is the cheap insurance that the IR seam is real — if adding RISC-V had needed front-end changes, the seam was a lie; it needed none (only the new register/byte *limits* surfaced, which are target properties, not design leaks). (2) Register allocation is where "it works on my 14-register host" quietly diverges from "it works on the 12-register device" — bring up the smallest register file early. (3) Verify every emitted instruction against the real toolchain's disassembler before trusting it on hardware; it is faster than debugging a `StoreProhibited` on-device, every time.
-
-### MoonLive: size the exec block to the program, and bound every fixed table the codegen fills
-
-Two follow-on lessons after the engine landed, both about *fixed-capacity buffers in a code generator*. (1) **Allocate the live exec block at the emitted length, not the worst-case cap.** The first cut allocated `allocExec(kCodeCap)` (the 768-byte ceiling sized for a four-call statement) for *every* script, so a `fill(0,0,255)` — ~60 bytes of native code — reserved 768. The fix: `place()` allocates `allocExec(len)` (word-rounded for the IRAM 32-bit-store rule), so per-effect heap scales with the script; the staging *buffer* stays worst-case (it's transient stack), only the *retained* block is right-sized. Paired with it: the effect must **report that block** (`setDynamicBytes(codeLen())`) or the JIT'd code is invisible to the memory accounting — the UI card showed only `sizeof(MoonLiveEffect)` and none of the native code it allocated. (2) **A code generator's label/fixup tables need the same bounds check as its code buffer.** The assemblers guarded `emit()` against `kCap` from the start but let `newLabel()`/the branch-fixup enqueue write `labelPos_[]`/`fixups_[]` unbounded — a script with enough branches would corrupt memory past the arrays. The fix routes both through the same `overflow_` signal `emit()` already uses (a guarded `addFixup()` helper, a bounds check in `newLabel()`), so *every* fixed table the codegen fills fails cleanly, not just the byte buffer.
-
-Lessons: (1) when a JIT retains a fixed buffer per unit, size the *retained* allocation to the actual output and keep only the *scratch* at worst-case — and report the retained bytes, or the memory accounting lies. (2) a bounds check on the code buffer is not enough: audit *every* fixed-capacity array the generator appends to (labels, fixups, relocation tables) and route them all through one overflow signal — robustness is per-table, not per-generator. (3) these surfaced from a deliberate memory review at the small-language stage, before loops/expressions multiply the branch count — cheaper to pin the discipline now than after a real script first overruns a table.
-
-### MoonLive Stage 1: live controls via a 5th-argument data arena (not baked immediates)
-
-A scripted effect became *tunable*: `uint8_t speed = 50; // @control 0..99` surfaces a real uint8 MoonModule control. The load-bearing decision was **how a control value reaches the native code each tick without recompiling on every slider move**. A baked-in `Const` immediate is wrong (a slider change would need a re-emit); the answer is the **`kArg3`/`t` pattern, one slot over** — pass a pointer to a control-values *arena* as a 5th function argument (`CtrlFn = void(*)(buf, nLights, cpl, t, const uint8_t* ctrls)`), add an IR `LoadCtrl{offset}` that lowers to one byte load (`ldrb [x4]` / `l8ui a6` / `lbu (a4)`), and let the binding update an arena byte when a slider moves. The next `run()` reads it — live, no recompile. `t` already proved this exact mechanism works across all three ABIs, so a control pointer was a mechanical addition, not a new design.
-
-Three sub-decisions held against the principles: (1) **the arena lives in the engine as a stable-address, grow-capacity-only heap block** (`platform::alloc`, PSRAM-first), NOT a fixed member array (won't scale to big effects) and NOT a core sweep change (re-ordering `rebuildControls`→`onBuildState` to allow realloc-and-rebind would touch *every* module to serve one — violating *Core grows slower than the domain*). Because the sweep runs `onBuildControls` on every change, re-binding the control pointers there is automatic; because the arena only grows (never moves on a normal recompile), `&arena[i]` stays valid, and a kept control keeps its live value across a source edit. (2) **The binding owns the projectMM-facing details** — the engine exposes a *neutral* `DeclaredControl{name, type, min, max, def, offset}` list and the binding turns each into `controls_.addUint8` bound to the arena slot, keeping the engine domain-neutral; the binding copies the (non-NUL-terminated) name spans into a stable pool because the control descriptor borrows the name pointer. (3) A scripted control's value change returns **false** from `controlChangeTriggersBuildState` (no recompile); only `source` returns true.
-
-Two things surfaced building it. **The device JSON string reader (`parseString`) decoded only `\"` and `\\`, not `\n`** — yet the writer emits `\n` escapes — so a multi-line script arrived with a literal backslash-n and the `// @control` comment swallowed the statement. The fix is symmetry: the reader now decodes the standard escapes its own writer produces (`\n \r \t \b \f`), which benefits any multi-line text control, not just scripts. And **the `ok()` predicate**: adding the `ctrl_` (5-arg) compiled-source path meant `ok()` had to include it — the classic "added a code path, forgot the predicate that gates it" (compile returned true while ok() returned false, so every source compile looked failed). Pinned by the live-read unit test thereafter.
-
-Lessons: (1) when a value must change live without re-emitting code, pass it through *data* (an arena read each tick), never *code* (a baked immediate or self-modifying patch) — the read is one instruction and the arena is the seam the next capability (script state, palettes) reuses. (2) Reach for the mechanism the system already proves: `t` was the template, so the 5th-arg control pointer was low-risk by construction. (3) A reader that decodes a subset of the escapes its writer emits is a latent asymmetry bug — make them symmetric. (4) Adding a parallel code path means revisiting every predicate that gates the old paths: the `ok()` bug — a host-side compile-validation gap where the new `ctrl_` path compiled fine but `ok()` still tested only `fn_`/`anim_`, so every source compile read as failed — was caught by the host live-read unit test (the runtime test path's job), not by disassembly. (5) Separately, on the device codegen: register-map shifts (stealing the next arg register) are the riskiest part; disassembling every emitted instruction against the real toolchain before flashing is what confirmed `l8ui a6`/`lbu (a4)` land correctly. The reader-symmetry and runtime-predicate fixes are host test-path lessons; the register-encoding evidence is device-ISA — distinct, not the same fix.
-
-### Adding a new chip (ESP32-S31) + bumping the IDF: where the cost actually was
-
-Adding the S31 (a RISC-V preview chip) cost almost nothing in *our* code — it inherited the RISC-V MoonLive backend and all four LED drivers for free, because both gate on SOC capabilities (`__riscv`, `SOC_RMT_SUPPORTED`, `SOC_PARLIO_SUPPORTED`, …), not a chip-family flag. The real work, and the lessons, were in the **toolchain and the platform/board layer**:
-
-- **An IDF pin bump is a re-test pass, not a `git pull`.** Moving from an early v6.1-dev snapshot to the `release/v6.1` tip (the line carrying the S31 preview target) jumped ~4800 commits, which surfaced HAL *graduation* drift: the RMT HAL moved into its own `esp_hal_rmt` component, deleting the public `SOC_RMT_TX_CANDIDATES_PER_GROUP` soc-cap (now `RMT_LL_TX_CANDIDATES_PER_INST`), and Parlio renamed `sample_edge`→`shift_edge`. None of this is caught by building one target — a **re-verify gate that rebuilds every existing target on the new IDF** is what found them before they reached CI. The pin also needs the toolchain re-installed (`install.sh`) and the env's stale Python venvs refreshed; the build picks the most-recently-modified venv, which can be the wrong one.
-- **A chip's *minimum-revision* default can silently brick field silicon.** v6.1 defaults the P4 minimum to rev 3.1; the bench (and field) P4 is v1.3, so the stock build refused to boot ("requires chip revision [v3.1-v3.99]"). The fix (`SELECTS_REV_LESS_V3` + `REV_MIN_0`) was only *found by flashing real hardware* — it builds clean either way. Hardware re-test, not CI, is what catches a min-rev trap.
-- **Newest esptool-js ≠ best for flashing.** esptool-js 0.6.0 (the latest) deterministically corrupts a compressed flash — a P4 web-flash aborts at the *same* block (seq 38) every time, where 0.4.7/0.5.7 and the CLI all succeed (a fixed-seq failure, not random, rules out a transient). We pinned **0.5.7 — the version ESP Web Tools (ESPHome/WLED) ship** — over the newest, because *Common patterns first* applies to dependency versions too: match the battle-tested one the ecosystem runs, not the highest number. (0.5.x also moved `hardReset` off `ESPLoader`; driving DTR/RTS through the transport directly is version-agnostic.)
-- **A new chip can be CLI-flashable but not browser-flashable**, and that's an *upstream* gap, not ours: esptool.py knows the S31 (since v5.2.0), esptool-js has no S31 chip class in any version. Worse, the S31's ROM magic collides with the classic ESP32's, so esptool-js would *mis-identify* it — a version bump alone won't fix browser flashing, it needs the secondary detection esptool.py already has. The catalog ships the firmware (`ships:True`) anyway: even with no web-flash, the published release asset is the device's **OTA** update channel, which is the more-used path (flash once via CLI, OTA forever).
-
-## Device discovery: UDP presence (we control it), mDNS advertise-only; the transport is per-ecosystem
-
-The DevicesModule refactor first reached for a UDP beacon, then swung to "mDNS is the standard, use it for discovery too", and finally — after bench measurement — landed on **UDP presence for discovery, mDNS for advertise-only**. The swing is the lesson: the "use the standard" instinct was right about *announce-to-foreign-apps* and wrong about *discover-peers*, and only measuring the wire separated the two. The lessons:
-
-- **Discovery transport is per-ecosystem, owned by the plugin — not one mechanism for all.** Use UDP where we control both ends (projectMM↔projectMM) or a foreign system already broadcasts (WLED's 44-byte packet on UDP 65506, on by default). Use mDNS only where a foreign *app* requires it (the native WLED app discovers us *only* via mDNS `_wled._tcp` — UDP can't reach it). The `DevicePlugin` seam reshaped from mDNS-shaped (`service()`/`classify(MdnsHost)`) to transport-agnostic (`discoveryPort()`/`classifyPacket(data,len,srcIp)`) so each plugin carries its own transport.
-- **Two directions of one interop can need different transports.** We discover WLED over UDP 65506; WLED's *app* discovers us over mDNS. Same ecosystem, opposite directions, different mechanisms — the plugin owns both halves. Trying to force one transport for both is what kept the design fighting itself.
-- **The mDNS query was the bug, and the fix was to stop querying mDNS, not to pace it better.** A blocking PTR query for a service the device *also advertises* exhausts the IDF mDNS pool (`mdns_querier: Cannot allocate memory` *with megabytes free*) and the device's own advertisement vanishes from peers. An earlier "throttle the query" / "go passive-browse" detour each half-worked (a passive browser can't discover — nothing re-announces unsolicited; a 75 s capture proved it). Moving discovery to UDP makes the self-query-disturbs-advertise bug **structurally impossible**: mDNS shrinks to advertise-only (`mdnsInit` announces `_http`+`mm=1` / `_wled`+`mac=`; the browse/query seam is deleted).
-- **Misattributing a self-inflicted bug to "the network" or "the chip" wastes hours — read the device's own serial, and the prior art's actual behaviour, first.** Chasing this I twice concluded wrongly ("the network filters multicast", "ESP32↔ESP32 mDNS is an IDF limitation") — both were our own query destabilising the boards. The product owner's logic cut through it (*the WLED app discovers WLED devices on classic ESP32s, so classic mDNS demonstrably propagates — an invisible projectMM device is OUR bug*). And reading the prior art's behaviour, not assuming it, is what made the rest safe + precise: a WLED that receives our 65506 packet *lists* us, it does not *sync* to it (confirmed from MoonLight's working code); the WLED-app interop's missing `wifi{}` field was a one-line fix once we read the WLED-Android app's actual Moshi model, not a guess.
-- **Commands split three ways by two questions — must it arrive? is it latency-critical? — not "all REST".** Discovery → **UDP presence**. Must-arrive + latency-tolerant config (brightness, presets, OTA) → **REST/TCP** (~10–50 ms, delivery guaranteed). Latency-critical + lossy-OK (time sync, live pixels) → **UDP stream** (~0.5–1 ms, broadcast to N; REST would be 10–100× too slow and visibly de-sync). The trap is routing a sync pulse over REST because "it's a command".
-- **A plugin seam makes "interop" our own industry-standard hook-in point.** Foreign systems hook in as `DevicePlugin`s (the adapter pattern), not hardcoded branches — one file per ecosystem, no core edit. The discovery half ships now (projectMM + WLED); the command half is a reserved extension (*concrete first, abstract later*). The seam allows **hub-shaped** plugins (Hue: a bridge → child resources + auth), not just flat devices — designing that room in now (without building it) lets the harder tiers land additively. The differentiator: one UI across WLED + ESPHome + Hue, incrementally.
-
-**A blocking socket op on the single render loop freezes EVERYTHING — and an intermittent freeze hides as the wrong cause.** The desktop web UI went "slow like a cow in the mud" and FPS pinned at ~18 regardless of grid size. The HTTP/WebSocket server runs *inside* the desktop render loop (`HttpServerModule::loop20ms`), and the accepted client socket carried a **2 s `SO_RCVTIMEO`**: whenever a request's bytes hadn't landed the instant `accept()` returned (the GET line a TCP segment behind the handshake), `recv()` **blocked the whole loop up to 2 s**. The two symptoms were one bug — a 2 s stall per unlucky request (the sluggishness) and a fixed ~50 ms-per-connection drag that capped the tick at ~18 FPS (the "FPS independent of grid" tell, while the Noise effect itself measured 222 FPS — proof the cost was non-render). **Fix:** the accepted socket is persistently non-blocking, `read()` returns -1 immediately when nothing's pending, and `writeSome` stops toggling it back to blocking; ~2 s → ~13 ms, FPS tracks real render speed. Lessons: (1) **a single-threaded loop that services I/O must never make a blocking call** — a timeout isn't a fix, it's the size of the freeze; non-blocking + poll is the only safe shape. (2) **"FPS constant across workload" means a fixed non-workload cost dominates** — compare the effect's own FPS to the tick FPS; a large gap points away from rendering. (3) An *intermittent* stall (timing race on byte arrival) masquerades as environment noise — it sent the debug down "zombie process / CPU busy-spin" dead ends before a profiler (thread stuck in the loop, not in render) + a "does plain curl also stall?" test isolated it. (4) It was **pre-existing on main**, surfaced only because this branch added a second per-tick socket read (the WS poll) that made the race fire more often — a latent single-loop-blocking bug waits for the load that exposes it.
-
-## Docs from source: generate the technical layer from `///` comments, keep a thin hand-written summary — and the traps that cost real time
-
-The docs overhaul first tried to *shrink* the per-module `.md` files, then inverted to **generate** them: the `.h` is the single home of technical content (`///` comments), a Doxygen→moxygen pipeline renders one page per module, and a hand-written summary page (a 4-column table) is the only prose that stays. The old `.md` files are absorbed into the headers and deleted. The recognisable prior art is docs.rs / Sphinx-autodoc / Doxygen — a generated API reference under a hand-written guide — which is why it survived the *Common patterns first* bar. The lessons are mostly the traps found building it:
-
-- **A per-file external-tool invocation doesn't scale — batch it.** The first cut ran `npx moxygen` once per header (132×); each `npx` cold-start is ~1 s, so a build took ~150 s and pinned `mkdocs serve` unusable. One Doxygen pass over all headers + one `moxygen --classes` call, recombined per-header via the XML `` map, is ~7 s (22×). The rule: when a build step shells out to an external tool per item, the per-invocation startup cost *is* the build time — invoke once over the whole set, split the output, never loop the process.
-- **Writing generated files into a watched dir is an infinite-rebuild loop — write only on change.** The generated `.md` live under `docs/`, which `mkdocs serve` watches. An unconditional write bumps the mtime every build → the watcher rebuilds → which regenerates → which writes again. The serve churned forever and every page load landed mid-rebuild (~7 s). Fix: compare content and skip the write when identical, so mtime stays put and the watcher goes quiet. Any generator that emits into a watched tree needs this.
-- **An unbalanced backtick in a `///` comment silently deletes the class from the docs.** One header (`ImprovProvisioningModule`) generated *no* page. Cause: `` `connected: `ssid`` `` — nested/odd backticks — made Doxygen report "end of C comment inside a `` ` `` block" and stop associating the comment with the class, so `HIDE_UNDOC_CLASSES` dropped it. The symptom (a missing page) was three steps from the cause (a typo in prose). Sweep every enriched header for `WARN`-level Doxygen output, not just build success; a comment typo is a *content* bug that no compiler catches.
-- **A bare `` in a doc comment renders as live HTML and eats the rest of the page.** `