Release 9 — Should We Restart?¶
Theme: No code ships in Release 9. After eight releases the codebase has grown to ~3 100 LOC of core, 70 source files, 27 test files, 23 deploy scripts, and ~15 400 lines of documentation across 90 markdown pages. The maintainer is considering throwing it away and starting again from a frugal core that fits in one head. This release produces one document: a recommendation with the reasoning behind it.
The concern¶
Eight releases of feature work added value, but they also added drift. The light-control domain (the original purpose) now shares StatefulModule and ModuleManager with WiFi, Ethernet, OTA, NTP, and a deploy pipeline that has become a second project. StatefulModule.h is 875 lines, Pal.h is 1 397, and a new contributor reading architecture.md cannot say in five minutes what makes the runtime different from any other module system. The documentation reads as a history of decisions rather than a description of the thing. The test suite has four parallel surfaces (unit, live, scenario, code-analysis) that were each added to solve a real problem but were never designed together. Most fixes from recent sprints look like patches rather than alignment with an architecture, because the architecture itself is no longer the load-bearing decision: it is one of many.
The question is whether all of that is recoverable from where we are, or whether the cheapest path is to open a sibling repo, write a core small enough to hold in one head — Module (with setup / loop / loop20ms / loop1s / loop10s / teardown), a ModuleManager, a Scheduler, and a Pal — then port modules back one at a time and let the deploy pipeline and tests grow back deliberately. The v2 would treat lights as the first user of the core, not as part of the core. WiFi, Ethernet, NTP, OTA would be modules, not core concerns. Documentation would target one page per audience (architecture, light domain, deploy), and the sprint history of v1 would be archived as reference rather than read during normal contribution.
The risk of restarting is that the architectural mistakes repeat, just with cleaner code. The risk of not restarting is another eight releases of patches on a base that no one can hold in their head.
Sprint 1 — Produce the recommendation¶
Scope: One person-sprint of code-and-docs reading plus a side-by-side against esp32-hex-starter, recommending one of continue, refactor in place, or restart from a v2 repo, with reasoning. No source files change.
The main driver, in one word, is frugality — frugal in code, in CPU cycles, and in resources. This is not a new pattern: it is essential complexity only (Brooks), Gall's Law (a complex system that works is invariably found to have evolved from a simple system that worked), and the Saint-Exupéry rule that perfection is reached not when nothing more can be added but when nothing more can be taken away. The three dimensions are tested differently: code frugality by whether a reader can hold the core in their head, CPU frugality by loop() time under realistic load on every active core, and resource frugality by static and runtime footprint per module across RAM, flash, and any other constrained resource — most acute on esp32dev without PSRAM, where RAM is the bottleneck, but flash, network bandwidth, and persistent storage all matter on different targets. Frugality applies equally to source, tests, deploy scripts, and documentation — anywhere lines accumulate without paying for themselves. v2 treats lights as one consumer of the runtime, not as a privileged citizen of the core: RGB, pixelBuf, and the layer model live in a lights module that depends on core, not the other way around.
Guardrails are what keep frugality from eroding once AI agents do most of the writing. They have to exist before the first module ships and stay ahead of the codebase, not trail it: an agent producing more lines per sprint than a human can read will otherwise drift the same way v1 did, where focus shifted from adding features to repairing guardrails in arrears. The framework enforces correctness (no allocations or blocking calls in the hot-path loop(), every setup() allocation paired with a teardown() free, GPIO pins drawn from typed board configuration rather than hardcoded literals) and frugality (any diff that adds a new test surface, doc file, or deploy script must justify itself against the existing structure). Enforcement lives at the git layer (pre-commit hooks) and in CI, because that is where an agent's work is checked before it lands. The fact that v1 now contains enough test and deploy scripts to make asking whether the testing system itself needs testing a sensible question is exactly the symptom guardrails-from-day-one prevent — at minimum the growth of the verifier has to be visible and controlled, even when meta-tests are not warranted.
The work has three stages. First, walk StatefulModule.h, ModuleManager.cpp, and Pal.h line by line and tag each section as core / light-domain / infra / debt; walk docs/ and decide page by page what survives in a five-page tree; walk the four testing surfaces (unit, live, scenario, code-analysis) and decide whether they collapse into one pyramid or stay parallel. Second, compare row-by-row against esp32-hex-starter — typed ports vs #ifdef, pre-commit guardrails, board-yaml codegen — and either adopt, reject with a stated reason, or note an existing match. Third, weigh the options against five criteria: time to a v2 that fits in one head, risk of regression, contributor onboarding cost after the work, maintenance load during the transition, and the probability that the same drift recurs unless the constraints that produced it change.
The hot path is the load-bearing decision in the runtime. loop() must do as little as possible at the maximum frequency the platform allows; loop20ms, loop1s, and loop10s exist to drain less urgent work out of the hot path at progressively lower rates so loop() stays short. Equally important, there is not a single loop() on a single core: the runtime is built to exploit every core the platform offers, with several loop() instances running in parallel and connected as a DAG (a graph of stages where data flows one way and never loops back). Two questions follow from this and are answered independently. First, what is the topology — a single linear pipeline (effect → blend → driver), a fan-out (one effect feeding several drivers), or an arbitrary DAG. Second, how does data cross a core boundary — shared buffer with double-buffering, single-producer-single-consumer lock-free queue, FreeRTOS message queue, actor-style mailbox, or hardware-mediated paths like the ESP32 RMT / PPA that need no software buffer at all. Today's EffectsLayer / DriverLayer is one combination (linear pipeline + double-buffered shared state); Sprint 1 surveys the alternatives along both axes and picks the pair that lets every core's loop() stay shortest under realistic load. The chosen model replaces, not coexists with, the current one.
If the recommendation is restart, restart means everything: code, tests, docs, and deploy together. The deploy pipeline reaches into module structure (status generators read schemas, MCP tools call REST), so reusing it without restructuring v2 around it would re-import the coupling that produced today's drift. The general rule is that anything known to be needed is built in week one rather than deferred — the test pyramid, the guardrails framework, and the doc structure are decided before the first module is written.
Definition of Done¶
- [x] Decision section appended to this file with the recommendation (restart)
- [x] Reasoning against the five criteria documented
- [x] One-page v2 core definition produced (restart was the recommendation)
- [x] Concurrency model chosen with one-paragraph justification (arbitrary DAG + SPSC lock-free ring, depth 2)
- [x] Guardrails framework outline produced (pre-commit / CI / structural gates)
- [x] v2 first-release plan produced (six sprints to v1 parity)
- [x] Maintainer signed off (2026-05-11: accept restart)
Decision¶
Recommendation: restart from a v2 repo¶
The evidence below points to restart in every category that matters. Refactor in place is the worst of the three options because the deploy pipeline reaches into module structure (status generators read schemas, MCP tools call REST, scenarios replay in three places), so any in-place restructuring re-imports the coupling that produced today's drift. Continue is unacceptable because the drift is documented across eight release retrospectives and is accelerating: R7S13, R8S12, R8S14 are all patches on patches.
Evidence¶
The core C++ surface is dominated by infrastructure that should be module code or platform abstraction in name only:
| File | Lines | Verdict |
|---|---|---|
Pal.h |
1 397 | ~50 lines of actual platform abstraction (timing, GPIO, PSRAM, logging). Everything else — WiFi 103, Ethernet 88, UDP 262, OTA 173, NTP 44, ~430 lines of system-info queries (heap, flash, OTA partition, reset reasons), filesystem 50, RTOS primitives 100 — is either modules that escaped into the PAL or read-only platform facts that belong behind one query function. The "P" in PAL has become "everything that touches the platform". |
StatefulModule.h |
875 | Conflates module lifecycle (50 lines), JSON control descriptors (250 lines), persistence (60 lines), child management (40 lines), timing windows (60 lines), memory deltas (30 lines), MemBoot/MemLive logging in runSetup (40 lines), pendingProps merge logic (40 lines), meta() lazy JsonDocument (10 lines), select-option heap ownership (30 lines). The module base class has at least five distinct jobs. |
ModuleManager.cpp |
869 | Instantiation (5 passes, 130 lines), runtime add/remove/replace/rewire/reorder (300 lines), REST helpers (getModulesJson, getStateJson, getSystemModulesJson, getHealthJson, fillMemoryJson, 200 lines), state file management (60 lines), dirty/debounce (30 lines), mutex/locking (cross-cut). The "manager" is also the REST adapter, the memory accountant, the state persister, and the dirty-flag debouncer. |
Backlog item "Consolidating ModuleManager and Scheduler — or letting one implement the other. When: experience with both makes the right abstraction boundary obvious" is now overdue: experience has made the answer obvious, and it is that the two should never have been split this way.
The testing surface has the same shape: 27 test files (406 cases) in one binary, plus 14 live tests in deploy/live.py, plus scenario replay in three places (deploy/scenario.py, deploy/live.py, tests/test_scenarios.cpp), plus a 1 600-line code-analysis script. Each surface was added in isolation and "we'll unify it later" never happened. The deploy pipeline itself — 22 Python scripts totalling 6 188 lines, including a 762-line browser UI, a 335-line summariser, and an MCP server — is now a peer project to the runtime it tests.
Documentation totals ~15 400 lines across 90 files. architecture.md alone is 623 lines and still does not say in five minutes what the runtime is. The 13 developer-guide pages overlap (architecture.md/pal.md/network.md/standards.md all describe pieces of the same picture). 33 per-module pages duplicate the JSON schema the UI already renders. 20 release/sprint-history files are the bulk of the developer guide by line count.
The esp32-hex-starter side-by-side reinforces the same conclusion. Of the rows where the two projects differ, the ones worth adopting in v2 (typed adapter ports instead of #ifdef for the fake-on-PC case, board.yaml→Pins.hpp codegen, pre-commit guardrails that block raw GPIO and unbounded allocation, mypy-strict on Python tooling) are all difficult to retrofit and natural to bake in from the first commit. The rows we already match (PC peer build, native testing) are kept. The rows we reject (Copier scaffolding, until a second device project exists) are backlog.
Reasoning against the five criteria¶
Time to a v2 that fits in one head. Restart: ~6–8 sprints to parity (core, Pal-minimum, light pipeline, WiFi/WS, ArtNet, OTA, deploy-3-scripts). Refactor: 12+ sprints to undo accumulated coupling while keeping v1 green; cost includes touching every file that includes StatefulModule.h. Continue: never — the drift accelerates.
Risk of regression. Restart isolates regression to v1 (which keeps shipping until v2 reaches parity, run side by side on different ports). Refactor risks regressions at every step on the only running codebase. Continue has no regression but no improvement either.
Contributor onboarding cost after the work. Restart: a single architecture page, one Pal header that fits on a screen, a Module base class with one job. Refactor: same destination but with archaeological scars that need explaining. Continue: same as today (estimated five-minute "what is this?" failure rate ≈ 100 %).
Maintenance load during transition. Restart: two repos for ~6–8 sprints (v1 in maintenance-only mode, v2 in active development). Refactor: one repo, but every PR has to land cleanly against a moving target. The two-repo cost is bounded and ends when v2 reaches parity; the moving-target cost compounds.
Probability of repeating the drift. Restart with frugality + guardrails from commit 1 is the only path that addresses the cause. Refactor cleans the symptoms but the constraints that produced the drift (no enforced rules, no growth budget, no agent-aware gates) survive into v2. Continue guarantees recurrence.
v2 core — one page¶
Module // the contract
setup() // allocate, configure, wire
loop() — hot path // bounded, no alloc, no block
loop20ms() — sub-hot responsiveness // periodic, lower urgency
loop1s() // monitoring, health
loop10s() // housekeeping, persistence
teardown() // free every setup allocation
ModuleManager // owns Module instances
add / remove / replace / wire // structural ops only
no REST, no JSON helpers, no state files // those are modules
Scheduler // runs the DAG across cores
topology: arbitrary DAG of stages // declared at wire time
data crossing: SPSC lock-free ring per edge // depth 2 = double buffer
pinned tasks one per core // FreeRTOS / std::thread
Pal // platform abstraction, nothing more
timing · gpio · fs · rtos primitives · heap query
~150–200 lines total // everything else is a module
Networking (WiFi, Ethernet, mDNS), persistence, OTA, NTP, HTTP, WebSocket, REST API, the lighting domain (RGB, pixelBuf, EffectsLayer, DriverLayer, all effects, all layouts, all modifiers) are modules, not core concerns. The core never references them; they reference the core.
Chosen concurrency model¶
Topology: arbitrary DAG. Mechanism: SPSC lock-free ring buffer per edge, depth 2 by default.
A linear pipeline (effect → blend → driver) is the depth-1 fan-out case of an arbitrary DAG; we never lose generality. Per-edge SPSC ring buffers give zero contention by construction (one writer, one reader), no allocation after init, bounded memory known at wire time, and degenerate to classic double-buffering at depth 2 — which is what fits on esp32dev without PSRAM. On ESP32-S3 / PC with memory headroom, depth >2 gives pipelining and backpressure for free. The chosen primitive replaces, not coexists with, today's EffectsLayer / DriverLayer double-buffered shared state — that pattern is one instance of this primitive and disappears as a special case.
Guardrails framework outline¶
Three enforcement tiers, all in place before the first module of v2 is written.
Pre-commit (git hooks, fast, block before the AI agent finishes the diff).
clang-format,ruff(already in v1; keep).- No
pinMode(<integer>),digitalWrite(<integer>),gpio_set_*(<integer>), or other GPIO call with a literal pin — pins must come from a typed board configuration. - No
new/malloc/psram_malloc/JsonDocumentinside the body of anyloop(),loop20ms(),loop1s(), orloop10s()(heuristic regex onvoid <name>::loop\\b.*\\{… matching}). - No
delay,vTaskDelay,sleep,usleep, blockingrecvwith positive timeout inside the same hot-path bodies.
CI gates (block PR merge).
cppcheck(already), addclang-tidywithbugprone-*andmodernize-*(rejected in v1 as too expensive; cheap if done from commit 1).- Module footprint baseline:
classSize()per module written tobaselines/footprint.json, PR fails if any value grows without a paired entry in the PR description. - Test-count baseline: per-binary test count tracked; a new module bumps the count by exactly N expected tests; mismatch fails the PR.
- Doc growth budget: docs lines per release capped (proposal: 500); CI fails on overshoot, override requires explicit budget bump in the release plan.
Structural gates (block additions that bypass the design).
- Adding a new file under
deploy/,tests/, ordocs/requires a// WHY:(C++) or top-of-file Python docstring explaining what existing file/surface was insufficient. CI checks the line exists; reviewer judges the content. - Adding a new top-level directory requires an ADR file (
docs/adr/NNNN-*.md) under version control.
The verifier-of-the-verifier question ("test the testing system?") gets a one-line answer: the testing system's growth is gated by the structural rule above (a new test surface requires a written justification), and its correctness is asserted only by the unit test that every module's healthReport() is non-empty. No meta-tests.
v2 plan to parity (if restart is accepted)¶
A new sibling repo projectMM-v2/ is opened. v1 stays on release-08 for maintenance commits only — R9 is its final release; there is no R10 in v1. v2 starts at Release 1 with its own release numbering, and reaches v1 parity over six sprints. The plan below is the starting point for v2's first release; v2 may split it across one or several releases of its own.
| Sprint | Deliverable | Frugality target |
|---|---|---|
| v2 S1 | Guardrails framework + empty Module/Manager/Scheduler/Pal skeleton + CI green | core ≤ 300 LOC |
| v2 S2 | First module (HelloModule) + test pyramid (host unit, on-target unit, integration, HIL) wired |
tests ≤ 5 files |
| v2 S3 | Pal-minimum (timing, GPIO, fs, rtos, heap) on PC + ESP32 | Pal ≤ 200 LOC |
| v2 S4 | Light domain: Producer / Consumer with SPSC ring; one effect, one preview driver | per-module ≤ 200 LOC |
| v2 S5 | Networking modules: WiFi-STA, HTTP, WebSocket, REST API | per-module ≤ 300 LOC |
| v2 S6 | ArtNet, OTA, NTP, state persistence — parity with v1 first-boot pipeline | per-module ≤ 300 LOC |
When v2 reaches parity, v1 is tagged v1.8.x-legacy and frozen; projectMM-v2/ is renamed to projectMM/ and v2 ships its first stable tag (v2.0.0). v1's sprint history is preserved as a static archive read by no one during normal contribution.
If continue or refactor is accepted instead, v1 continues with a Release 10 that returns to its prior scope (ESP32-P4), and the items above become ongoing technical-debt sprints inside v1.
Sign-off¶
2026-05-11 — Accepted: restart. A new projectMM-v2/ repo is opened and proceeds with the six-sprint plan above as its Release 1. v1 stays on release-08 for maintenance commits only and is archived once v2 reaches parity. R9 is v1's final release; v1 has no R10.
Result¶
Walked 3 141 lines of core C++ (StatefulModule.h 875, ModuleManager.cpp 869, Pal.h 1 397), 90 markdown files (~15 400 lines), 27 test files (406 cases), 22 deploy scripts (6 188 lines), and the existing esp32-hex-starter side-by-side. No source files changed. Evidence converged on restart in every category: Pal is ~50 lines of platform abstraction inside 1 397 lines of accumulated infrastructure; StatefulModule is five jobs in one class; ModuleManager is REST adapter, memory accountant, state persister, and dirty-flag debouncer in addition to its name. Decision section appended to this file (121 lines).
Retrospective¶
What went well. Line-count tagging per file gave a concrete, fast verdict — one verdict per file rather than 70. The two-axis framing for concurrency (topology × data-crossing mechanism) made the choice fall out cleanly: per-edge SPSC ring buffers cover the linear-pipeline + double-buffering case as a special case, so there is no need to choose between generality and tight memory. The frugality + guardrails framing made the recommendation feel inevitable rather than discretionary.
What was tricky. Pal.h was the hardest file to summarise because its sections interleave and the line "this belongs in PAL" vs "this belongs in a module that uses PAL" has not been drawn anywhere; the walk had to draw it during the analysis. The line ~50 (out of 1 397) for actual platform abstraction is sensitive to where that line lands, but every reasonable definition puts WiFi, OTA, NTP, and the system-info queries on the module side.
Seeds for v2 Sprint 1. The first v2 sprint's deliverable is the guardrails framework + the empty skeleton; the outline in the Decision section is the spec, and the first commit of v2 is the implementation. loop20ms is the cadence that does not exist in v1 (v1 has runLoop1s only via timing windows); v2 Sprint 1 has to introduce it as a first-class scheduler concern, not as an afterthought. The "deploy pipeline shrinks to 3 scripts" target needs an honest baseline: which of the 22 v1 scripts must survive (build, flash, test) and which were features of v1 specifically (browser UI, MCP server, scenario triplication).
Complexity estimate: Small (S) — one session of code + docs reading, no implementation.
Sprint 2 — Bootstrap projectMM-v2¶
Scope: Open the sibling repo
projectMM-v2/with enough scaffolding that v2 Sprint 1 (guardrails framework + core skeleton) can begin the next day. Doc side only: repo creation, mkdocs, the R9 constraint documents, AI-agent guidance, license, gitignore. No C++ code, no PlatformIO, no CMake, no pre-commit hooks, no CI gates beyondmkdocs build— those are v2 Sprint 1's deliverable, because they are guardrails and a guardrail framework decided before v2's first sprint runs is a guardrail decided without the framework's own rules constraining it.
Frugality says: copy the constraints, write nothing new that hasn't been justified, leave everything else for v2 Sprint 1. The work splits into a small number of concrete actions.
Repo creation. New GitHub repo ewowi/projectMM-v2, MIT license, .gitignore (C++ build artefacts, Python venv, PlatformIO, IDE). Initial commit: README.md that names v2 as the successor of ewowi/projectMM, links to v1's Release 9 § Decision, and states the parity target. v1 stays on release-08 for maintenance commits only.
mkdocs. Same Material theme as v1 for consistency. Five-page nav per the R9 docs plan: index.md (what projectMM is, rewritten for v2 — not copied from v1), architecture.md (the one-page core from R9, fleshed out to no more than ~300 lines), deploy.md (placeholder; filled in v2 Sprint 1), lights.md (placeholder; filled in v2 Sprint 4), development/index.md (release index + links to the constraint docs below).
Constraint documents (ported from v1's R9, not rewritten). Three files copied verbatim from this release, each with a header note linking back to v1's R9 as the source:
docs/development/anti-drift.md— the What stops this from happening again section.docs/development/guardrails.md— the Guardrails framework outline section, ported as the spec that v2 Sprint 1 implements.docs/development/release-01.md— v2's first release; the sprint table is the v2 plan to parity renumbered as Release 1 sprints (S1 through S6), with the frugality targets carried verbatim.
AI-agent guidance. CLAUDE.md for v2, rewritten — not copied from v1. Frugality as rule #1, anti-drift rules linked, the v2 core boundary stated explicitly so an agent cannot put networking or OTA into Pal without flagging it as an architecture change requiring an ADR.
Cross-references. v1's docs/development/index.md gains a top-of-page banner pointing to v2 as the active project. v1's README.md (if it exists) gains the same banner. v2's index.md references v1 only as a frozen reference; no v1 docs are linked from inside v2 except for the R9 Decision (linked once, in development/release-01.md).
CI: docs only. .github/workflows/docs.yml runs mkdocs build --strict on every push. Nothing else. Pre-commit, clang-format, ruff, structural gates, footprint baselines, growth budgets — all wait for v2 Sprint 1.
Tag v1. Tag the v1 repo at the current HEAD as v1.8.0-pre-restart so the lineage is preserved. v1 continues on release-08; this tag is the line of demarcation.
Definition of Done¶
- [ ]
ewowi/projectMM-v2repo exists on GitHub, MIT license, .gitignore in place - [ ] mkdocs builds on PC and in CI; five-page nav (
index,architecture,deploy,lights,development/) all resolve - [ ]
architecture.mdcontains the v2 core one-pager, ≤ 300 lines - [ ]
development/anti-drift.mdanddevelopment/guardrails.mdexist verbatim from R9 with source attribution - [ ]
development/release-01.mdcontains the 6-sprint plan to parity, frugality targets carried verbatim - [ ]
CLAUDE.mdfor v2 in place - [ ]
docs.ymlGitHub Action green on first push - [ ] v1
release-08HEAD taggedv1.8.0-pre-restart - [ ] v1
docs/development/index.mdandREADME.mdcarry the "active development in v2" banner - [ ] Zero C++ files, zero PlatformIO config, zero CMake config in v2
Result¶
[To be filled in when the sprint runs.]
Retrospective¶
[To be filled in when the sprint runs.]
Complexity estimate: Small (S) — bootstrap work, no design decisions left to make (R9 Sprint 1 made them all).
What stops this from happening again¶
A restart is only valuable if the conditions that caused v1's drift do not reproduce in v2. The mechanisms below are the answer; they ship as part of v2's first sprint, and they are themselves constrained by the rules they enforce.
Guardrails ahead, never behind. v2 Sprint 1 lands the guardrails framework before any module exists. No commit in v2 is ever written without pre-commit hooks, CI gates, and structural rules running against it. This inverts the v1 timeline, where the verifier always trailed the code it was meant to constrain.
Growth budget per release. Each release plan declares a LOC budget for src/, tests/, docs/, and deploy/. CI fails on overshoot. Bumping the budget requires an explicit line in the release plan signed off by the maintainer; it cannot be done silently inside a sprint PR.
Mandatory subtraction. Every release removes at least one thing — a deprecated path, an unused module, an obsolete doc page. "What did you remove?" is a sprint-close question alongside "what did you add?". A release with zero removals is allowed only when every existing thing has been re-justified in the retrospective. This is the single rule that matters most: a system that never removes anything will always grow until someone considers a restart.
Frugality is a review gate. Every PR answers one question before any other: does this addition pay for itself, and what does it replace or extend? If the answer is "nothing", the PR is rejected. The rule applies equally to tests, docs, and deploy scripts.
Architecture is a constraint, not a description. The architecture page is short, durable, and the reference for every review. A PR that does not fit the architecture either is rejected or carries a paired ADR file (docs/adr/NNNN-*.md) recording the architecture change explicitly. v1's drift came from the architecture quietly becoming one document among many; v2 makes any architecture change visible by requiring the ADR.
Recurring evaluation sprints. Release 9 is the first instance of a recurring pattern, not a one-time event. Every fifth release of v2 is a no-feature evaluation that walks the code, checks for drift, and proposes removals. The schedule is set at the start of v2 Sprint 1, not at the end of v2 Release 4.
Drift metrics in CI. Per-release deltas published as a status doc: file count, LOC per module, LOC per Pal, LOC per test, ratio of new tests to new code, modules added vs modules removed. Any metric trending the wrong way across three consecutive releases is flagged in the next retrospective.
The verifier obeys the same rules it enforces. Adding a new pre-commit hook, CI gate, or analysis script requires the same structural gate as adding a new module: what it replaces, what it extends. Without this rule the guardrails framework drifts the way deploy/ drifted in v1.
None of these is sufficient on its own. Together they create the friction that prevents drift from accumulating below the threshold where anyone notices it.
Release 9 Backlog¶
All items consolidated into the cross-release backlog.