feat(aetherd): 2.3 — complete MeterModel conversion (meter-status decode behind the seam)#4066
Conversation
…ode behind the seam) Moves the SmartSDR meter-status wire decode out of RadioModel into FlexBackend, completing the MeterModel touchpoint. MeterModel already held only core state (the MeterDef catalog + smoothed values the UI renders) — the Flex decode was RadioModel::handleMeterStatus — so this is the last Flex meter wire-decode to move behind the seam. Behavior-neutral. - IRadioBackend: meterDefined(int index, QVariantMap fields) + meterRemoved(int). - FlexBackend::decodeMeterStatus parses the "N.key=value#…" definition body and the "N removed" line (verbatim from the old handleMeterStatus, incl. the num base-0 parse and present-only field extraction), emitting the normalized signals. QMap grouping preserves ascending-index emit order. - RadioModel: handleMeterStatus is now a thin forwarder to decodeMeterStatus (driven from the status choke point → live + deferred/replayed covered); the ctor-wired handlers reconstruct MeterDef present-only and drive defineMeter/ removeMeter exactly as before (defineMeter's SLC/LEVEL catalog caching + the m_manifestSliceContext side-effect run identically). FlexBackend is main- thread, so the emit→apply chain is a synchronous DirectConnection. - Meter *values* remain on the VITA-49 data plane (step 5), untouched. - Test: aetherd_meter_decode_test pins definition/removal/multi-meter/malformed. 66/66 tests green; boundary --strict green; full app builds clean. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
ten9876
left a comment
There was a problem hiding this comment.
Code review — high-effort recall pass (8 finder angles → verify)
Verdict: clean, faithful move-refactor — no functional regression. The meter-status wire decode relocates verbatim from RadioModel::handleMeterStatus into FlexBackend::decodeMeterStatus, emitting meterDefined/meterRemoved → ctor handlers → MeterModel. Independent finders confirmed every field mapping, guard, grouping, and ordering is preserved byte-for-byte; the QVariant round-trip is type-lossless; the connect signatures match; there's no bypassed second parse path; and the new decode test pins it. Smallest and cleanest of the 2.3 touchpoints.
Two things noted but NOT flagged: the m_flexBackend teardown gate is intended fail-closed (and here the dtor disconnects m_connection/m_panStream/m_wanConn before nulling the alias — cleaner than the #4065 waterfall path); MeterDef full-replace on partial status is pre-existing (Flex sends full definitions).
Three findings inline, all low/medium — none blocking.
rfoust
left a comment
There was a problem hiding this comment.
Reviewed PR #4066 at the merge ref. No blocking findings.
The meter-status decode move from RadioModel::handleMeterStatus() into FlexBackend::decodeMeterStatus() looks behavior-equivalent: definitions/removals still flow back through the ctor-wired meterDefined / meterRemoved handlers into MeterModel, and meter values remain on the VITA-49 data path.
Validated locally:
git diff --check- CMake configure
aetherd_meter_decode_test- adjacent
aetherd_pan_decode_test - direct
meter_model_test tools/check_engine_boundary.py --strict- full
AetherSDRtarget build
There was a problem hiding this comment.
Reviewed the full diff against the pre-move handleMeterStatus. The move is behavior-neutral as claimed, and the mechanics line up with the sibling PanadapterModel touchpoint (#4065). Thanks @ten9876 — this is clean.
Verified
- Field-by-field parity with the old inline parse:
src→source,num→sourceIndex (base-0toInt(nullptr,0)),nam→name,unit,low/hi→double,desc→description, all present-only; index set fromit.key()on emit →def.index = indexon receive. The removal branch is equivalent (!words.isEmpty()== the oldwords.size() >= 1;contains("removed")preserved verbatim, including its pre-existing quirk of matching "removed" anywhere in the body — unchanged, so not a regression). - Single choke point, no dual parse:
handleMeterStatushas exactly one caller (processStatus→body.mid(6)), and it's the sole meter parser in the tree. - Ordering preserved:
meterDefinedis same-thread (AutoConnection ⇒ DirectConnection), sodefineMeterruns inline in ascending-indexQMaporder beforedecodeMeterStatusreturns — definition/choke ordering is intact. m_flexBackendguard mirrors the sibling handlers (handlePanStatus, slice mode) and is the same teardown-null-safety pattern already in use — consistent, not a new behavior gate.- CMake test wiring mirrors
aetherd_pan_decode_testexactly.
On the two Copilot notes:
-
Untyped
QVariantMapvs a typedmeterDefined(int, const MeterDef&)— I'd lean toward keeping the map here, and I don't think the panState-vs-typed distinction is the deciding factor. The seam's established convention is a stringly-keyedQVariantMap(sliceChanged(int, QVariantMap),panState), andIRadioBackend.hdeliberately pulls in no model headers —MeterDeflives inmodels/MeterModel.h. A typed signal would couple the backend-agnostic interface down to a model struct, inverting the layering this RFC is establishing. So the untyped hop is consistent, not an oversight.That said, the actionable nugget in that comment is real: the receive half is untested. The new test pins only the emit side; a key-string typo (
sourceIndexon emit vs a mismatched key on receive) would silently drop a field to its default with no build error and no test failure. Cheap to close with a small round-trip assertion (decode → RadioModel reconstruction →MeterDeffields), or at minimum a comment pinning the two key lists as a matched pair. Non-blocking, but worth it given the two-places coupling. -
Unguarded
num/low/hiparses — accurate that they're unguarded, but this is verbatim-preserved from the old code and behavior-neutral. The Principle VII "you're at the boundary now, align with the guarded siblings / FlexLib'sTryParse+continue" argument is reasonable, but it's the same optional call as on #4065'sdecodePanRange— author's discretion, not a blocker for a behavior-preserving move.
Nothing blocking here. The one thing I'd genuinely encourage is a receive-side test to lock the key contract; the rest is fine to merge as-is.
🤖 aethersdr-agent · cost: $5.2247 · model: claude-opus-4-8
NF0T
left a comment
There was a problem hiding this comment.
Independent red-team pass
Traced this against the actual source (not just the diff) rather than re-asserting the sibling PR's reasoning. Smaller, cleaner move than #4065 — one real finding, plus a couple of things worth confirming rather than assuming.
What checked out
- Field-by-field verbatim. Compared old
handleMeterStatusto the newdecodeMeterStatus+ ctor-handler split line by line:source/name/unit/descriptionare straight string carries, the meter-index grouping key keeps itsok-guard on both sides, andQMap's ascending-key iteration (confirmed Qt semantics, not assumed) preserves definition emit order exactly as before. - Dtor safety already inherited correctly. This branch was cut from
mainafter #4065 merged, so~RadioModel()already has them_flexBackend-nulled-before-m_backend.reset()+m_wanConn-disconnect ordering, andhandleMeterStatus's newif (m_flexBackend)guard sits on the safe side of it. (I initially flagged a cross-PR sequencing risk here against an earlier commit of this review — retracted after re-checking against the actual squash-merge commit rather than #4065's pre-squash branch tip; no action needed, just flagging that I checked rather than assumed.) - CI is now fully green — all 7 checks, including
check-windowsand CodeQL, which were still in flight at PR-open time.
One finding: parse-guard asymmetry with the sibling touchpoint
decodeMeterStatus's sourceIndex (num field) and low/high (hi) fields parse via unguarded .toInt(nullptr, 0) / .toDouble() — a malformed present value silently becomes 0 / 0.0 rather than being dropped. This isn't a new regression (the old inline handleMeterStatus had the identical unguarded calls), but it's the same shape of gap #4065's fix commit closed for min_dbm/rfgain on the reasoning that "this decode is now the formal validation boundary" (Principle VII) — same author, same migration series, ~35 minutes earlier. A malformed low/hi here has the same failure mode as the min_dbm bug just fixed: a meter's displayed scale silently collapsing to 0 instead of the field being dropped as unchanged/undefined. Suggest carrying the same ok-guard pattern here for consistency with the standard this series has now set for itself — cheap, and it's the one path in this PR that's actually a wire-format validation boundary.
CODEOWNERS gate — not blocking on content, but not clearable by us or @rfoust's approval alone
This diff touches CMakeLists.txt (new test target registration), which CODEOWNERS gates Tier 2 to @aethersdr/infrastructure (@ten9876, @jensenpat). @rfoust's approval satisfies the Tier-3 default for the rest of the diff, but doesn't cover that gate, and @ten9876 can't self-approve — hence reviewDecision sitting at REVIEW_REQUIRED despite an approval on file. Same shape as #4065's Tier-1 situation, one tier down, so it has an actual path this time: an approval from @jensenpat clears it cleanly, or a maintainer merge-override (the path #4065 took) works if the CMakeLists.txt addition is judged trivial enough.
Net: this is the cleanest of the 2.3 touchpoints so far — verbatim relocation, correct dtor inheritance, tests that actually pin the behavior. The one thing worth fixing before merge is the low/hi/num guard, purely for consistency with the bar this series already set for itself.
…ok-guarded numeric parses Resolves ten9876's #4068 findings. - TYPED PAYLOAD (#2, strategic): the slice-status payload becomes a compiler- checked SliceDelta struct (std::optional per field) instead of the stringly- keyed QVariantMap. A field-name typo between FlexBackend::decodeSliceStatus and SliceModel::applyChanges is now a compile error, not a silently-dropped field. IRadioBackend::sliceChanged(int, const SliceDelta&); SliceDelta lives in src/core/backends/ so the interface stays free of model deps. The vendor-neutral rename win is unchanged — applyChanges still reads only canonical fields. Verified: 69 delta fields set in the decode, 69 read in the model, set-diff empty both ways (now also enforced by the compiler). - PARSE GUARDS (#1): the decode's numeric parses are ok-guarded — a malformed present field is dropped, not applied as 0/0.0. Higher-stakes here than pan/meter: a garbled RF_frequency would otherwise retune the slice to 0 Hz. Matches FlexLib's TryParse+continue and the standard set on #4065/#4066. - DOC FIXES: the sliceChanged connect comment now accurately describes the AutoConnection + same-thread assumption (not a hard "DirectConnection", which would be wrong if a backend ever moves to a worker thread) (#4); the stale "applyStatus already called below" comment reworded to reference decodeSliceStatus (#5). - Tests: aetherd_slice_decode_test now asserts on the typed delta (incl. a malformed-RF_frequency-dropped case); slice_model_letter_test / antenna_alias_test build SliceDelta directly. 67/67 tests green; boundary --strict green; full app builds clean. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…lice-status decode behind the seam) (#4068) Completes the **SliceModel** touchpoint of aetherd RFC 2.3 — the largest of the five mixed-model splits. ~70 fields of Flex slice-status wire decode move out of `SliceModel` into `FlexBackend`, with a **full vendor-neutral rename** of the seam contract (per the 2026-07-05 scope decision). Behavior-neutral. ## What moved - **`FlexBackend::decodeSliceStatus(sliceId, kvs)`** owns ALL the SmartSDR wire knowledge and emits the normalized, canonically-named, typed change map via `IRadioBackend::sliceChanged`: - key renames (`RF_frequency`→`frequency`, `filter_lo`/`filter_hi`→`filterLow`/`filterHigh`, `lock`→`locked`, `audio_level`→`audioGain`, `tx`→`txSlice`, `dax`→`daxChannel`, `index_letter`→`letter`, …) - `"1"`→bool; `esc` `"1"`/`"on"`→bool; comma-split + trim for antenna/mode lists (with `rx_ant_list` precedence over `ant_list`); `fm_tone_mode`/`repeater_offset_dir` lowercase. - **`SliceModel::applyStatus` → `applyChanges(const QVariantMap&)`** reads only canonical keys; **every** piece of model business logic is unchanged — the mode-dependent filter-polarity flip, the `audio_mute` / `speex_nr_level` / `rtty_mark` override re-pushes, the diversity change-detection block, `fmToneValue` formatting, and each field's exact emit gate (unconditional-when-present vs change-gated). - **`RadioModel::handleSliceStatus`** routes both `applyStatus` call sites through `decodeSliceStatus`; a ctor-wired `sliceChanged` handler applies to `slice(id)`. `FlexBackend` is main-thread → synchronous `DirectConnection`, so a freshly-appended slice is populated before the `sliceAdded` UI notify (ordering preserved). - `splitAntennaList` relocated from `SliceModel` into the backend decode. ## Behavior-neutral — verified three ways 1. **Programmatic key symmetry**: the backend inserts **69 canonical keys**, the model reads **69** — set-difference is **empty in both directions**, so there is no name mismatch (silent field death) and no key the model waits forever for. 2. **Adversarial field-by-field review** across 8 dimensions (name consistency, no dropped field, type correctness incl. the `float`↔`double` gain fields, `"1"`→bool + the `esc`/`play`/`in_use` special cases, present-only + combined-block "keep current when absent", all side-effects, per-field emit conditionality, RadioModel routing + synchronous ordering) — **no regressions**. 3. **Tests**: `slice_model_letter_test` + `antenna_alias_test` converted to canonical `applyChanges`; new **`aetherd_slice_decode_test`** pins the wire→canonical mapping (renames, `"1"`/`"on"`→bool, list split, `rx_ant_list` precedence, lowercase, raw `play`/`step_list`, present-only). ## Gates - **66/66 tests green**, engine-boundary `--strict` green, full app builds clean. ## Notes - The seam-contract keys are now fully canonical (vendor-neutral) — an HL2 or other backend emits the same map for the fields it supports; unknown/absent keys are simply not applied. - `client_handle` slice ownership + slice lifecycle remain RadioModel-level (the doc's SliceModel "extension" bucket), unchanged by this PR. Refs #3849 (aetherd RFC tracking) — part of the step-2.3 touchpoint burndown. ## 2.3 progress PanadapterModel ✅ (#4065) · MeterModel 🟡 (#4066) · **SliceModel (this PR)** · TransmitModel + RadioModel residual next. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…tDelta, all 5 status planes behind the seam) The last of the five mixed-model splits. All five Flex transmit-family status decoders move out of TransmitModel into FlexBackend, behind a typed compiler-checked TransmitDelta payload. Behavior-neutral (adversarially reviewed field-by-field + 57/57 set-read symmetry). - FlexBackend: decodeTransmitStatus / decodeInterlockStatus / decodeAtuStatus / decodeApdStatus / decodeApdSamplerStatus own all the SmartSDR wire knowledge — key names, "1"→bool, ok-guarded + clamped numeric parses (per-field ranges), uppercase, list split, the compander/dexp alias precedence, and the sampler INTERNAL-prepend — emitting IRadioBackend::transmitChanged(TransmitDelta). - TransmitModel: applyTransmitStatus/Interlock/Atu/Apd/ApdSampler → one merged applyChanges(const TransmitDelta&). Every emit group preserved exactly (stateChanged / tuneChanged / micStateChanged / phoneStateChanged / txFilterCutoffChanged / inline maxPowerLevelChanged+txSliceModeChanged / atuStateChanged / apdStateChanged / apdEqualizerResetReceived / apdSamplerChanged), plus the model-side business logic: compander→both companderOn/dexpOn pairs, the ATU enum parse, and the per-antenna sampler map + selected-fallback. - Fail-closed: malformed present numerics drop instead of applying 0/0.0 (freq/max_power_level/interlock were previously unguarded) — consistent with the slice/pan standard. No well-formed input changes behavior. - RadioModel: the 6 transmit-family call sites route through the decode methods; a ctor-wired transmitChanged handler drives the model (synchronous, main-thread). - Also folds in the deferred #4066 meter parse-guards (num/low/hi ok-guarded) + a malformed-value meter test. - Tests: transmit_model_test + transmit_model_apd_test converted to typed deltas; new aetherd_transmit_decode_test pins the wire→delta mapping (clamps, ok-guard, compander/dexp aliasing, ATU raw, APD bare-flag, sampler INTERNAL-prepend). Registered transmit_model_apd_test (was built but never run). 68/68 tests green; boundary --strict green; full app builds clean. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…tDelta, last mixed model) (#4071) Completes the **TransmitModel** touchpoint — the **last of the five mixed-model splits** in aetherd RFC 2.3. All five Flex transmit-family status decoders move out of `TransmitModel` into `FlexBackend`, behind a typed, compiler-checked `TransmitDelta` payload (the pattern established for slices in #4068). Behavior-neutral. ## What moved - **`FlexBackend::decodeTransmitStatus` / `decodeInterlockStatus` / `decodeAtuStatus` / `decodeApdStatus` / `decodeApdSamplerStatus`** own all the SmartSDR wire knowledge: key names, `"1"`→bool, **ok-guarded + clamped** numeric parses (per-field ranges), uppercase, list split, the **compander/dexp alias precedence** (`compander` wins; `dexp`/`noise_gate_level` alias only when the compander key is absent), and the sampler `INTERNAL`-prepend. Each emits `IRadioBackend::transmitChanged(const TransmitDelta&)`. - **`TransmitModel`**: the five `apply*Status` methods → one merged **`applyChanges(const TransmitDelta&)`**. Every emit group is preserved exactly — `stateChanged` / `tuneChanged` / `micStateChanged` / `phoneStateChanged` / `txFilterCutoffChanged` / inline `maxPowerLevelChanged`+`txSliceModeChanged` / `atuStateChanged` / `apdStateChanged` / `apdEqualizerResetReceived` / `apdSamplerChanged` — along with the model-side business logic: `compander` → both `companderOn`/`dexpOn` (+ mic/phone flags), the **ATU enum parse**, and the **stateful per-antenna APD-sampler map** + selected-not-in-available→`INTERNAL` fallback. - **`RadioModel`**: the 6 transmit-family call sites route through the decode methods; a ctor-wired `transmitChanged` handler drives the model (synchronous, main-thread). ## Behavior-neutral — verified - **Adversarial field-by-field review** across the 5-method merge's central risks — emit-group mapping (every field lands in its original group; `met_in_rx`→`changed` not phone; compander sets both mic+phone; interlock emits nothing), cross-contamination (single-plane deltas isolate each group's emits), clamp ranges (all match), ATU/sampler logic + idempotency, routing, and the `optional<bool>` presence-vs-value footgun — **no regressions**. - **57 delta fields set in the decode = 57 read in the model**, empty set-diff both ways (now compiler-enforced by the typed struct). ## Fail-closed (intentional, not a regression) Malformed *present* numerics now drop instead of applying `0`/`0.0` — `freq`, `max_power_level`, and the interlock ints were previously unguarded (a garbled `max_power_level` would have emitted `maxPowerLevelChanged(0)`). No well-formed input changes behavior. Consistent with the slice/pan standard. ## Also in this PR - **Folds in the deferred #4066 meter parse-guards** (`num`/`low`/`hi` ok-guarded — a live fail-closed gap on `main`) + a malformed-value meter test. - **Registered `transmit_model_apd_test`** — it was built but never run (pre-existing CMake omission). ## Tests / gates - `transmit_model_test` + `transmit_model_apd_test` converted to typed deltas; new **`aetherd_transmit_decode_test`** pins the wire→delta mapping (clamps, ok-guard-drop, compander/dexp aliasing, ATU raw token, APD bare-flag, sampler `INTERNAL`-prepend + empty-`tx_ant` no-emit). - **68/68 tests green**, engine-boundary `--strict` green, full app builds clean. ## Scope note — "RadioModel residual" This finishes the five mixed models. RadioModel's own remaining *direct* Flex decoders are the radio-**global** status handlers (`handleRadioStatus`/`handleGpsStatus`/`handleMemoryStatus`/`handleProfileStatus`) — not a sub-model, and a distinct smaller concern. Kept out of this PR (already the largest) as a separate follow-up rather than bloating it. Refs #3849 (aetherd RFC tracking). Related: #4065, #4066, #4068, #4070 (typed-payload parity for pan/meter). ## 2.3 scoreboard Panadapter ✅ (#4065) · Meter ✅ (#4066) · Slice 🟡 (#4068) · **Transmit (this PR)** — the five mixed models complete. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…y decode helpers (#4075) Closes #4070 — the seam-consistency follow-up from the RFC 2.3 mixed-model series. Behavior-neutral (the three decode tests pin the exact wire→output mapping). ## Typed meter payload `MeterDef` moves from `models/MeterModel.h` to **`core/backends/MeterDef.h`** (pure data), so `IRadioBackend` carries it directly: **`meterDefined(const MeterDef&)`** instead of the stringly-keyed `QVariantMap`. `FlexBackend::decodeMeterStatus` builds the `MeterDef`; `RadioModel` hands it straight to `defineMeter()` — the ~15-line key-string reconstruction is gone, and a field-name typo is now a **compile error**. `MeterModel.h` re-includes the core header so its existing users are unaffected. ## Shared decode carriers New **`core/backends/flex/FlexKvCarry.h`** single-sources the present-only + fail-closed parse contract (`carryStr/Bool/Int/Clamp/Real` for optional delta fields; `carryInto/IntInto/RealInto` for plain struct fields; `splitList`). The slice (`oStr/oInt/…`), transmit (`tBool/tInt/…`), and meter (inline guards) decoders now **all route through it** — resolving the "3rd/4th re-roll of the same guard" that ten9876 flagged across #4066/#4068/#4071. A future tweak (base-0 parse, whitespace trim) can no longer be applied to one copy and silently skipped in the others. `FlexBackend.cpp` net-shrinks ~90 lines. ## Deliberately NOT done (documented decisions) - **`panState`/`panWnb` stay on the generic `extensionStatus(ns, kind, QVariantMap)` channel** — that channel exists precisely to keep Flex-specific status *off* the vendor-neutral typed interface; typing them would invert that design. - **`TransmitDelta`/`SliceDelta` kept flat** (not per-plane sub-structs — ten9876's #4071 option): the flat structs are verified/tested, and the `assign()` helper + these shared carriers already address the maintainability concern; a sub-struct rewrite of two just-merged deltas isn't worth the churn. Revisitable if a 6th touchpoint ever copies the pattern. ## Gates - `qRegisterMetaType<MeterDef>()` added at startup alongside `Slice`/`TransmitDelta`. - `aetherd_meter_decode_test` asserts on the typed `MeterDef` (incl. the malformed-drop guard → default). - **69/69 tests green**, engine-boundary `--strict` green, full app builds clean. Refs #3849. Concludes the RFC 2.3 mixed-model split seam cleanup — the `IRadioBackend` seam now carries pan/meter/slice/transmit with compiler-checked typed payloads and one shared decode-helper set. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Completes the MeterModel touchpoint of aetherd RFC 2.3 — the SmartSDR meter-status wire decode moves out of
RadioModelintoFlexBackend, behind the seam. Behavior-neutral.MeterModel was already the cleanest of the five mixed models: it holds only core state (the
MeterDefcatalog + the smoothed values the UI renders viaMeterSmoother), and the Flex-specific decode lived inRadioModel::handleMeterStatus. This moves that last piece behind the seam.What moved
meterDefined(int index, QVariantMap fields)+meterRemoved(int index)— the normalized meter-definition catalog signals. (Meter values stream on the VITA-49 data plane, step 5 — untouched here.)"N.key=value#…"definition body and the"N removed"line — verbatim from the oldhandleMeterStatus, including thenumbase-0 parse, the#/./=tokenizer, and present-only field extraction.QMapgrouping preserves ascending-index emit order.handleMeterStatusis now a thin forwarder todecodeMeterStatus(driven from the status choke point, so live + deferred/replayed meter status both convert). The ctor-wiredmeterDefined/meterRemovedhandlers reconstructMeterDefpresent-only and calldefineMeter/removeMeterexactly as before — sodefineMeter's SLC/LEVEL catalog caching and them_manifestSliceContextside-effect run identically.Behavior-neutral — verified
MeterDefis byte-identical to the old inline parse (src→source,num→sourceIndex base-0,nam→name,unit,low/hi→double,desc→description; all present-only).defineMeterdoes a full replace (m_defs[index] = def) — the present-only reconstruction preserves exactly what the old code passed it.QMap<int,…>iterates ascending index in both the backend group loop and (previously) the RadioModel loop, and eachmeterDefinedemit is a synchronousDirectConnection(FlexBackend is main-thread) that runsdefineMeterinline — so definition order and the choke-point ordering are preserved.Tests / gates
aetherd_meter_decode_test: full definition,"N removed", multiple meters in one body (grouped), malformed index token skipped.--strictgreen, full app builds clean.Refs #3849 (aetherd RFC tracking) — part of the step-2.3 touchpoint burndown (
docs/architecture/aetherd-touchpoints.md).2.3 progress
PanadapterModel ✅ (#4065) · MeterModel (this PR) · SliceModel → TransmitModel + RadioModel residual next.
🤖 Generated with Claude Code