From dfe6ccbddd5bce8a16852d8c68367d6f1ed744c0 Mon Sep 17 00:00:00 2001 From: daniel-c-harvey Date: Tue, 28 Jul 2026 16:43:34 -0400 Subject: [PATCH] =?UTF-8?q?docs(q-w0):=20T2=20architecture=20audit=20?= =?UTF-8?q?=E2=80=94=20wire-cursor=20hardening=20drift,=205th=20JSON=20dec?= =?UTF-8?q?oder,=205x=20readFileBytes,=20rect-type=20zoo;=2011=20triaged?= =?UTF-8?q?=20findings?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../audit-notes/q-w0-t2-architecture.md | 333 ++++++++++++++++++ 1 file changed, 333 insertions(+) create mode 100644 docs/product/audit-notes/q-w0-t2-architecture.md diff --git a/docs/product/audit-notes/q-w0-t2-architecture.md b/docs/product/audit-notes/q-w0-t2-architecture.md new file mode 100644 index 0000000..efea37c --- /dev/null +++ b/docs/product/audit-notes/q-w0-t2-architecture.md @@ -0,0 +1,333 @@ +# Q-W0 Track 2 — architecture-smell audit (functional lens) + +Static analysis of the whole `src/` tree (extension + `src/vst/`), 2026-07-28, branch +`pq-w0-audit`. Complement to the grep-verified SOLID audit (§2) and naming audit (§2b) in +`docs/product/code-organization.md` — this track reports the **functional** smells those did not +target: duplicated *algorithms* (not merely duplicated responsibilities), reinvented wheels, +poor abstractions, and leaky pure/shell boundaries. Findings already catalogued there (the four +god-modules, the 4× JSON `Parser`, fat headers, `promptText`/`mintBankId` duplication, namespace +flatness, naming families) are **not restated**; where a finding below touches the same file it +is because the functional mechanism is new. + +Every claim below was verified by grep/read of the actual tree. Line numbers are as of this +audit's snapshot. Wave assignments reference PLAN.md §Q-W1..Q-W6. + +--- + +## Findings + +### T2-01 — 3× copy-pasted length-prefixed wire `Cursor`, with security-hardening drift +**Location:** `src/provenance.cpp:56–137`, `src/assignment_request.cpp:25–121`, +`src/sample_usage.cpp:25–89` (plus a fourth sibling: `src/vst/bank_sync.cpp:11–28` +`parseBankGeneration` re-rolls the same guarded decimal accumulate). + +**Mechanism.** The `':'` ext-state wire idiom ("one grammar across every ext-state +seam", per sample_usage's own comment) is implemented as three near-identical `putField` + +`Cursor` copies — and they have **drifted on the hardening**. The two newer copies +(`assignment_request`, `sample_usage`) carry a 20-digit length cap and an overflow guard +(`len > (SIZE_MAX - digit) / 10 → fail`) plus the subtraction-first bounds check +(`len > s_.size() - start`). The oldest copy (`provenance.cpp:65–82`) has **neither**: a crafted +long digit run wraps `len` silently, and the additive bounds check `start + len > s_.size()` +can itself wrap, letting a wrapped length pass. Downstream, `parseFingerprint` +(`provenance.cpp:197–199`) calls `r.trackGuids.reserve(guidCount)` on an **unbounded** count +parsed by the equally unguarded `fieldSizeT` — a corrupt/crafted `Sample.provenance` string in +the bank JSON can drive `reserve(huge)` into `std::length_error`/`bad_alloc` thrown through the +shell. (`sample_usage` fixed exactly this with its `count > wire.size()/4 + 1` sanity bound, +`sample_usage.cpp:121`; the fix was never backported.) `std::string::assign` clamping keeps the +wrap short of UB, but the parse-integrity promise ("never UB, never a partial value") is upheld +in two copies and eroded in the third — the textbook cost of a duplicated algorithm. + +**Severity:** High (the drift already produced a concrete robustness gap on a persisted, +user-editable input; the class of bug will recur with every new wire seam). +**Disposition:** **fix-now, split:** (a) backport the hardened `field()` + a count sanity bound +to `provenance.cpp` **in Q-W0** — small, pure, existing `provenance_tests` covers round-trip and +malformed-input paths; (b) the structural collapse (one shared `wire` codec module beside +`core/json`, all three seams + `parseBankGeneration` consuming it) belongs to **Q-W1**, which is +already the serialization-extraction wave. Rationale: the hazard is cheap to close now; the +dedup is a relocation-adjacent move that should ride the wave already creating `core/`. + +### T2-02 — a FIFTH hand-rolled JSON decoder the §2 audit did not count +**Location:** `src/tail_control.cpp:88–130` (`valueAfterKey` + `deserializeTailSetting`). + +**Mechanism.** The catalogued DRY violation is "JSON `Parser` duplicated 4×" (`bank_model`, +`bank_book`, `view_mode_model`, `owned_manifest`). `tail_control` carries a fifth, structurally +different JSON decode: a substring-scan reader (`json.find("\"key\"")` → skip ws → parse token). +It is correct for the flat single-object payload it reads (the file argues this honestly), but +it is a fifth place JSON-reading behavior is defined, with different tolerance semantics (a key +found inside a *string value* would match — impossible today only because the writer is its own +sole producer). If Q-W1 extracts `core/json` from the four `Parser`s and misses this site, the +"one JSON path" goal is silently not achieved. + +**Severity:** Med (no live bug; a completeness gap in the already-planned fix). +**Disposition:** **fix-now, folded into Q-W1** — add `tail_control` to the Q-W1 consumer list +explicitly. Rationale: zero extra cost when `core/json` lands; a stray fifth decoder afterward +would be a defect of the wave. + +### T2-03 — `readFileBytes` hand-rolled five times, both sides of the artifact split +**Location:** `src/capture.cpp:232`, `src/capture_realtime.cpp:329` (as `readAllBytes`), +`src/ingest.cpp:86` (comment admits: "of capture.cpp's readFileBytes"), +`src/vst/reasampler_processor.cpp:72`, and inline in `src/vst/reasampler_editor.cpp:749–756`. + +**Mechanism.** The identical ifstream-binary-ate/tellg/read whole-file loader exists five times +(two spellings, one anonymous inline). Well past extract-on-third-occurrence, and the copies +already disagree cosmetically (name, empty-on-failure comment placement) — the next divergence +will be behavioral (e.g. one copy gaining a size ceiling the others lack). + +**Severity:** Med. +**Disposition:** **fix-now, folded into Q-W1** — a trivial pure `readFileBytes` helper in the +`core/` utility home Q-W1 creates; both CMake targets link it. Rationale: five occurrences of a +ten-line function is pure debt with a zero-risk fix, but creating its home is exactly Q-W1's +job — doing it days earlier in the flat tree would just move the file twice. + +### T2-04 — the growing `GetProjExtState` read loop, three copies, pure half only half-used +**Location:** `src/persist.cpp:140–157` (`getProjExtStateString`), +`src/vst/reaper_bridge.cpp:93–107` (self-described "mirrors persist.cpp's growing strategy"), +`src/usage_scan.cpp:168–181` (self-described "the persist.cpp idiom"). + +**Mechanism.** The grow-buffer-until-it-fits retry loop over `GetProjExtState` is implemented +three times, in three TUs, on both sides of the split. The fiddly part — interpreting the int +return against the filled buffer — is *already extracted pure* as +`bridge_marshal::decodeGetProjExtState`, but only `reaper_bridge` consumes it; `persist` and +`usage_scan` interpret `rv` inline with their own conventions (persist: `rv <= 0` → absent; +bridge: return AND non-empty buffer). The absent-vs-truncated-vs-empty semantics are precisely +the kind of edge that drifts when defined thrice. `usage_scan`'s copy is prune-safety-adjacent +(an unreadable usage record must abort the prune) — its read loop deserves the tested pure +decode, not an inline reimplementation. + +**Severity:** Med. +**Disposition:** **fix-now, assigned to the downstream wave that opens `persist`** +(Q-W4 per the current wave map; whichever wave splits `persist.cpp` is the moment). Generalize +the retry policy (next-capacity/done decision) into `bridge_marshal` (or its `core/` successor) +and make all three loops consume it. Rationale: touching persist's session machinery outside +its own wave risks the highest-traffic shell for a dedup that has no live bug today. + +### T2-05 — 19 rect structs + ~15 inline point-in-rect predicates across the pure UI family +**Location (structs):** `action_bar.h:61`, `bank_grid.h:23`, `card_drag.h:109`, +`component_geometry.h:28,53,106`, `drag_out.h:40`, `footer_bar.h:41`, `mode_switch.h:21,35`, +`overflow_menu.h:23,39`, `prune_button.h:32,46`, `tab_strip.h:24,51`, `tooltip.h:20`, +`vst/editor_geometry.h:19`, `vst/velocity_curve.h:124`. +**Location (predicates):** inline half-open `px >= r.x && px < r.x + r.width && …` re-typed in +`action_bar.cpp`, `bank_grid.cpp`, `card_drag.cpp` (×2), `component_geometry.cpp` (×2), +`drag_out.cpp`, `footer_bar.cpp`, `mode_switch.cpp`, `overflow_menu.cpp`, `prune_button.cpp`, +`tab_strip.cpp` (×2), `bank_panel.cpp:1049`, plus `vst/editor_geometry.cpp:20`. + +**Mechanism.** §2b.2 catalogued the *naming/collision* half of this (the `footer_bar.h` "NAME +NOTE" hand-checking smell). The functional half is uncatalogued: nineteen structurally identical +axis-aligned `{x, y, w, h}` record types, each with its own hand-typed containment predicate. +Every new pure-UI module re-mints both. This is Daniel's heuristic (b) verbatim: N near-identical +concrete implementations that one shared type collapses at compile time — one `ui::Rect` + one +`contains(Rect, x, y)` free function (both already exist in embryo as `vst/editor_geometry`'s +`Rect`/`contains`), with per-module aliases or thin wrappers only where a struct carries extra +fields (e.g. `TabRect::index`). Zero runtime cost; deletes ~15 chances for the next half-open/ +closed-interval inconsistency to slip in. + +**Severity:** Med. +**Disposition:** **fix-now, folded into Q-W2** (the ui/ relocation wave) — collapsing the type +zoo is nearly free precisely when every one of these files is being moved and re-namespaced; +doing it pre-reorg would churn 19 headers twice. Rationale: same-moment-as-relocation is the +stated principle for renames (§2b intro); it holds identically for type unification. + +### T2-06 — pure-computable layout math stranded in the VST editor shell (the §2 scope gap) +**Location:** `src/vst/reasampler_editor.cpp` — `SampleFaceLayout` (~line 922) and its builder, +`ClusterLayout` (~line 964), `zonesStripArea`/`noteEntryFieldsArea`/`noteEntryFieldRect`/ +`zonesControlPanel`/`zonesDeckArea`/`zonesCurveButton` (lines 992–1042), the channel-toggle +segment rects (~line 1065), banner rect math (~line 1195), among ~49 inline geometry +computations across the 3,065-LOC TU. + +**Mechanism.** The codebase's own grammar homes exactly this class of math in pure modules +(`editor_geometry`, `knob_deck`, `curve_popup`, `capture_browser`, …), yet the editor shell has +accreted a second, untested layout layer: whole named layout structs and pure `Rect → Rect` +functions that take only ints and rects, compiled into the one TU that cannot be unit-tested +without a host window. This is the mirrored form of the pure/shell leak (algorithm math living +untestable in a shell). Note also the audit-scope gap this exposes: §2's god-module catalogue +covered the extension tree only — `reasampler_editor.cpp` (3,065 LOC) and +`reasampler_processor.cpp` (1,164 LOC) repeat the bank_panel pattern on the VST side and appear +in no existing finding. + +**Severity:** Med (no correctness bug found in the stranded math; the cost is untestability and +the growth trajectory — the editor gained ~500 LOC/phase through r11). +**Disposition:** **document-and-defer, with a named reshape:** Q-W0 should surface a downstream +point (the wave that opens `src/vst/`, or a new one) hoisting the Sample-face/Zone-panel layout +into the existing pure homes (`editor_geometry` is the natural owner). Rationale: a hoist is a +behavior-preserving mechanical move best done under the reorg's test discipline, not pre-reorg; +but it must be a recorded point or the layer keeps growing. + +### T2-07 — the extension links the entire voice engine to serialize one preset blob +**Location:** `CMakeLists.txt:383–385` (`instrument_drop` → PUBLIC `sample_map`); +`src/instrument_drop.cpp` includes `vst/sample_map.h`, which pulls `sampler_core.h` → +`pitch_shift.h` + `velocity_curve.h`. + +**Mechanism.** `instrument_drop` (extension side) deliberately reuses +`sample_map::serializeComponentState` so the `.vstpreset` payload and the instrument's own +reader cannot drift — the right DRY call, explicitly documented in CMake. But the shared writer +lives *inside* the module that also owns zone resolution, WAV decode plumbing, and (via header +fan-in) the whole voice engine — so `reaper_reasampler` compiles and links `sampler_core`, +`pitch_shift`, and `velocity_curve` object code it never executes. The abstraction is right; its +*granularity* is wrong: the ComponentState codec is not separable from the engine stack today. + +**Severity:** Low (dead weight in the binary and a misleading dependency edge; no runtime cost — +heuristic (c) is about call chains, which this does not add). +**Disposition:** **document-and-defer to Q-W1/Q-W2 module-homing:** when serialization gets its +`core/` home, split a `component_state` codec module (types + serialize/deserialize only) out of +`sample_map`; both artifacts link the codec, only the VST links the engine. Rationale: purely +structural, zero behavior change, and exactly the kind of module-boundary decision the reorg +waves exist to make once, deliberately. + +### T2-08 — WAV/RIFF byte-format knowledge spread across four modules, two chunk walkers +**Location:** `src/wav_trim.cpp` (canonical parse: `parseWavLayout`/`extractFloatFrames`), +`src/capture_paths.cpp:31–115` (a second, independent RIFF chunk walker for content hashing), +`src/ingest.cpp:108–160` (hand-built 32f WAV writer), `src/capture_realtime.cpp:423` (in-place +RIFF/data size patch). + +**Mechanism.** The tree is disciplined about *decoding* ("no third WAV reader" — sample_map, +editor, processor all route through `wav_trim`), but RIFF *container* knowledge is still minted +per site: `capture_paths` walks chunks with its own tag/size/pad-byte logic to hash `fmt `+`data` +while skipping metadata; `wav_trim` walks the same container shape for layout; `ingest` writes +headers by hand; `capture_realtime` patches sizes by offset. Four places know the RIFF framing +rules (even-byte padding, chunk-header arithmetic); a drift in any one (e.g. pad-byte handling) +would desynchronize hashing from decoding — the dedup-by-hash and null-test invariants both sit +on this. + +**Severity:** Low (all four are currently correct against each other by inspection; the smell is +the maintenance surface, not a live divergence). +**Disposition:** **document-and-defer** — consolidate into a `core/wav` home (walker + layout + +writer + patch) when the reorg assigns module homes. Rationale: pre-reorg consolidation churns +the capture hot path (§3 guardrail) for no functional gain; the reorg wave that relocates +`wav_trim` is the natural moment. + +### T2-09 — the two capture backends' Sample-stamping epilogue is copy-paste with silent divergences +**Location:** `src/capture.cpp:490–537` vs `src/capture_realtime.cpp:505–540`. + +**Mechanism.** The finished-capture metadata stamp — `trackGuids`, `channelCount`, `sampleRate`, +`Master_GetTempo`, the `TimeMap_GetTimeSigAtTime` block, the WAV-aware `hashWavContent` content +hash (comment block duplicated verbatim, ~10 lines), `createdTimestamp` — is written twice, once +per backend. The copies have already diverged in quiet ways: offline passes `proj = nullptr` +(active project) to `TimeMap_GetTimeSigAtTime` while realtime pins `st.proj_`; the sampleRate +fallback logic differs in shape; realtime overrides `lengthSeconds` post-hoc. Some divergence is +semantic (realtime's tail-trim length), but the shared stamp is one concept — a future field +(e.g. a new provenance stamp) must currently be added in two places, and the time-sig +active-project vs pinned-project asymmetry is exactly the kind of drift that produces a +wrong-project stamp during a background-project capture. + +**Severity:** Med. +**Disposition:** **fix-now, folded into Q-W3** (the wave already hoisting capture orchestration +out of `main.cpp` / right-sizing `capture.h`). Extract a `stampCaptureSample(Sample&, const +CaptureRequest&, ReaProject*)` shared helper; the divergent bits (length override) stay in the +realtime caller. Rationale: the fix touches both backend TUs, which Q-W3 opens anyway; doing it +there keeps one review of the precision-invariant-adjacent code. + +### T2-10 — the two thumbnail pipelines' cache-invalidation strategies have drifted +**Location:** `src/bank_panel.cpp:422–483` (extension: `computeThumbnail` + pure +`ThumbnailKey{id, width, generation}` via `bank_grid::thumbnailKeyString`) vs +`src/vst/reasampler_editor.cpp:715–789` (VST: `monoPcmFor` keyed by bare `sampleId`, +`thumbnailFor` keyed by ad-hoc `sampleId + "|" + binCount`, invalidated by wholesale +`clear()` at lines 159–160/894). + +**Mechanism.** The dock panel and the VST browser render the same thumbnails through the shared +`peaks::computeEnvelope`, but the caching layer around it was re-designed independently on each +side: the extension bakes the bank generation into a *pure, tested* key type; the editor +hand-concats a string key with no generation and relies on call-site `clear()`s (bank-refresh, +resize). Both are correct **today** — but correctness on the editor side is distributed across +remembering every clear site, and the bin-clamp guard comment ("computeEnvelope pads binCount > +frameCount…") is duplicated verbatim in both TUs (`bank_panel.cpp:462`, +`reasampler_editor.cpp:780`), marking the copied design. A future refresh path that forgets the +clear shows stale waveforms with no test to catch it. + +**Severity:** Low. +**Disposition:** **document-and-defer** — when T2-06's layout hoist opens the editor, adopt the +pure `ThumbnailKey` (or a shared `thumb_cache` helper) on the VST side. Rationale: no live bug; +unifying cache policy is a natural rider on the editor wave, pointless as standalone churn. + +### T2-11 — ComponentState v1→v11 deserialize chain: sound, but the legacy branches triplicate the shared read +**Location:** `src/vst/sample_map.cpp:775–945` (`deserializeComponentState`). + +**Mechanism.** Audited the full lift chain for functional soundness: the bounded `ByteReader` +latches on truncation, every version's tail fields carry per-field corrupt fallbacks +(previewVelocity → mid default, voiceCount → default-not-clamp, gain → unity, refs → keep-parsed +prefix), and the strict-prefix envelope discipline is honest. **No correctness finding.** The +smell is shape: the v3, v4, and v5 branches each re-implement the mode-byte → marker → idLen/id +→ zones read sequence that the v6+ shared path also implements (three near-copies of the same +cursor walk, lines 806–841 vs 851+), and each new envelope version adds another +`version >= kꞏꞏꞏV*Version` stanza to a function already ~170 lines long. + +**Severity:** Low. +**Disposition:** **document-and-defer, explicitly.** The legacy branches are frozen back-compat +contract code with saved-project blobs as their only callers; rewriting them into a table-driven +lift risks the one thing they must never break, for zero user-visible gain. Record the pattern +so the *next* envelope bump (v12) prefers extending the shared path over minting another branch. +(The unbounded-suffix version-constant naming is §2b territory; not restated.) + +--- + +## Surfaces checked and found clean + +Recorded per the wave's no-silent-omission rule; each was read/grepped this audit. + +- **Pure-module include hygiene, both trees.** Every module CLAUDE.md claims pure was scanned + for REAPER/SWELL/WDL/LICE/VST3-SDK includes: all clean, `src/` and `src/vst/` both. The one + grep hit in `pitch_shift.h` is a comment (the S16 WDL-exclusion note), not an include. The one + cross-tree include (`instrument_drop` → `vst/sample_map.h`) is pure-to-pure — see T2-07 for + the granularity concern; it is not a boundary violation. +- **`bank_sync`** — the generation/consume decision rules are pure, explicit, and exhaustively + commented (rules 1–4); `parseBankGeneration` is overflow-guarded (its duplication is rolled + into T2-01's family, not a separate defect). +- **`bridge_marshal`** — one honest job, done pure, with the S1 string-scan JSON reader + documented as retired (verified: no second JSON parser on the VST side; `sample_map` routes + through `BankBook::deserialize`). +- **The realtime record lifecycle** — *not* an implicit state machine: `RecordPhase` is an + explicit enum with pure per-tick transitions in `realtime_record.h`; `main.cpp` holds only the + handle + project pointer. (Its *residence* in main.cpp is catalogued §2.1; nothing functional + to add.) +- **Project-identity transitions** — `classifyProjectTransition` is pure (capture_paths), the + shell passes `sameProjectObject` as a bool to keep it so; the GUID-primary layering is + decision-tabled in one place. +- **`usage_scan`** — every decision delegated to pure `sample_usage`; container recursion is + depth-bounded with a protect-on-truncation fail-safe; the `std::function` parm-getter + indirection is prune-scan-cold (heuristic (c) satisfied — no hot-path chain). +- **Exception boundaries** — the three `catch (...)` sites (`bank_book.cpp:794` stol guard, + `instrument_drop_win.cpp:74` REAPER-callback boundary, `render_settings.cpp:180` stod guard) + are all documented, narrow, and non-swallowing in intent (each converts to an explicit + failure value). No silent error swallowing found. +- **`FxBypassGuard` (main.cpp) vs `view.cpp` park/restore** — both are snapshot-mutate-restore + over track flags and *look* like a dedup candidate; they are deliberately not one. Different + flag sets, different invariants (precision-neutralization vs Design-View parking), different + failure postures. Duplication of shape, not of concept — correctly left separate. +- **Path resolution** — `capture_paths::resolveBankFile` is the single resolver on both sides + of the split (panel, insert, drag_out, editor, processor). No parallel path logic. +- **WAV decode on the play path** — `wav_trim` is genuinely the only decoder (T2-08 concerns + the *container* knowledge spread, not a second decoder). +- **Draw layer** — the VST editor/embed compile the same `draw_kit`/`theme`/`component_geometry` + the extension uses (verified in CMake + includes); no parallel draw vocabulary grew on the + VST side. +- **Interface cost audit (heuristic (c))** — `ICaptureBackend` is the tree's only virtual + interface; two real implementations, dispatched once per capture (cold). No hot-path virtual + or std::function chain found in `sampler_core`/`pitch_shift`/`sample_map` (all static calls). + No interface-with-one-implementation found anywhere. +- **Boolean-parameter proliferation** — swept `src/` headers for multi-bool signatures; the only + hit is `bank_grid::applyClick(…, bool ctrl, bool shift, …)`, which mirrors physical modifier + keys and reads fine at call sites. Not a finding. + +## Cross-checks against the §2/§2b audits (gaps noted, not restated) + +- §2's evidence base is scoped to the extension tree ("45 files / ~19,800 LOC"); the full tree + is now ~39,000 LOC. The VST shells repeat the god-module pattern uncatalogued + (`reasampler_editor.cpp` 3,065 LOC, `reasampler_processor.cpp` 1,164 LOC) — carried here as + T2-06's scope note so the reorg waves size the `src/vst/` work realistically. +- §2.1's "4× JSON Parser" undercounts by one — T2-02 (`tail_control`). +- §2b.2's shared-rect naming hazard has an uncatalogued functional twin — T2-05. + +## Summary table + +| ID | Finding | Severity | Disposition | Where | +|-------|---------------------------------------------------------------|----------|-------------------|--------------| +| T2-01 | Wire `Cursor` ×3 with hardening drift; provenance unguarded | High | fix-now (split) | Q-W0 backport + Q-W1 dedup | +| T2-02 | Fifth JSON decoder in `tail_control` | Med | fix-now | Q-W1 | +| T2-03 | `readFileBytes` ×5 across both artifacts | Med | fix-now | Q-W1 | +| T2-04 | Growing ext-state read loop ×3; pure decode half-adopted | Med | fix-now | persist's wave (Q-W4) | +| T2-05 | 19 rect structs + ~15 inline point-in-rect predicates | Med | fix-now | Q-W2 | +| T2-06 | Layout math stranded in VST editor shell (+§2 scope gap) | Med | document-and-defer (named reshape) | src/vst wave | +| T2-07 | Extension links voice engine to share the preset serializer | Low | document-and-defer | Q-W1/Q-W2 homing | +| T2-08 | RIFF container knowledge in 4 modules / 2 chunk walkers | Low | document-and-defer | core/wav homing | +| T2-09 | Capture backends' Sample-stamp epilogue copy-paste w/ drift | Med | fix-now | Q-W3 | +| T2-10 | Thumbnail cache-invalidation strategies drifted across split | Low | document-and-defer | editor wave | +| T2-11 | ComponentState legacy lift branches triplicate the shared read | Low | document-and-defer | (pattern note for v12) |