Skip to content

feat(aetherd): 2.3 template (complete) — decode + encode + extension facets on PanadapterModel/SliceModel#4063

Merged
ten9876 merged 3 commits into
mainfrom
aetherd-step2.3-model-splits
Jul 5, 2026
Merged

feat(aetherd): 2.3 template (complete) — decode + encode + extension facets on PanadapterModel/SliceModel#4063
ten9876 merged 3 commits into
mainfrom
aetherd-step2.3-model-splits

Conversation

@ten9876

@ten9876 ten9876 commented Jul 5, 2026

Copy link
Copy Markdown
Collaborator

RFC step 2.3 (mixed-model split), proving the full three-facet template — decode, encode, extension — on the panadapter/slice touchpoints before expanding the blast radius. Scoped narrow, but end-to-end on all three facets so the mold is complete for the rest of 2.3.

The three facets (the template the rest of 2.3 follows)

1. DECODE — a universal field flows into the backend and out as a normalized typed signal:

wire → RadioModel::handlePanadapterStatus (the status choke point — so both live and deferred/replayed status convert, not just a live observer) → FlexBackend::decodePanCenterBandwidthIRadioBackend::panCenterBandwidthChangedPanadapterModel::setCenterBandwidth → GPU render.

  • decodePanCenterBandwidth() emits only when present (negative = "unchanged" for an omitted field); center/bandwidth removed from applyPanStatus.

2. ENCODE — a command intent routes through a backend verb, TX-safety kept above the seam (RFC §6):

SliceModel::setModemodeChangeRequestedFlexBackend::setSliceMode → the TX-inhibit-guarded slice sink (setSliceCommandSinkRadioModel::sendSliceCommand).

  • Wire string is byte-identical to the old direct sendCommand. The guard is inert for slice set mode= (a non-tx= verb), so this is a plumbing move, not a behavior change — it establishes where the guard lives for the transmitting verbs that convert later.
  • Scope note: setSliceFrequency/setSliceFilter are rewired to the same guarded sink but have no caller yet — dead template code today, exercised when those verbs convert.

3. EXTENSION — a Flex-specific field decodes to a namespaced channel, not the core profile:

FlexBackend::decodePanExtensions (WNB group) → IRadioBackend::extensionStatus("flex","panWnb", …)PanadapterModel::applyWnbExtension.

  • IRadioBackend: panCenterBandwidthChanged(panId, center, bw) + extensionStatus(ns, kind, fields).
  • RadioModel: a transitional concrete FlexBackend* alias drives decode from the choke point and services modeChangeRequested; legacy panadapterInfoChanged preserved. Behavior-neutral.

Why this scope

The status plane is a ~921-line monolith entangled with pan lifecycle + deferred-status buffering — no clean tiny increment, but a clean narrow one. Proving all three facets end-to-end validates the whole approach (typed signal, choke-point driving, normalized setter, guarded encode seam, namespaced extension, deferred-path coverage) at minimal risk. If a TX-inhibited-slice-mode regression ever surfaces in a bisect, it traces here — the encode + extension paths land in this PR, not a later one.

Known gaps (disclosed, deferred to the 2.3 burndown — not regressions)

  • Waterfall dual-parse: PanadapterModel::applyWaterfallStatus still independently parses center/bandwidth, so they aren't single-sourced from the backend yet. Pre-existing, untouched by this diff. (FlexLib treats waterfall bandwidth as an explicit no-op, Waterfall.cs:1116.) Converges onto setCenterBandwidth in a follow-up.
  • The remaining Flex-specific pan fields (client_handle, loopa/loopb, min/max dBm, …) stay in applyPanStatus until they convert.

Verification

Clean build; 65/65 tests (adds aetherd_pan_decode_test covering all three facets incl. the malformed-wnb_level guard); boundary --strict green; live panadapter GPU grab on a FLEX-8600 renders the correct frequency axis — direct visual proof center/bandwidth flowed through the new backend path (a broken path would mis-scale the X axis). Full automation-bridge battery: 10/10 (6 core + 4 facet), session log clean (0 crash/assert markers).

Next (after this template is reviewed/merged)

Same mold for the remaining universal pan fields (min/max dBm, waterfall id), then MeterModel → SliceModel → TransmitModel → RadioModel.

🤖 Generated with Claude Code

…exBackend — Principle I.

The first converted touchpoint of RFC step 2.3, proving the model-split
pattern on the smallest genuine slice before expanding the blast radius.

The universal panadapter display fields (center/bandwidth) now flow:
  wire → RadioModel status choke point (handlePanadapterStatus, so both live
  and deferred/replayed status convert) → FlexBackend::decodePanCenterBandwidth
  → IRadioBackend::panCenterBandwidthChanged (normalized typed signal) →
  PanadapterModel::setCenterBandwidth → GPU render.

- IRadioBackend: new panCenterBandwidthChanged(panId, centerMhz, bandwidthMhz).
- FlexBackend: decodePanCenterBandwidth() parses the Flex "display pan" kvs and
  emits the normalized signal (only when the fields are present; negative =
  "unchanged" for a field the radio omitted).
- PanadapterModel: new setCenterBandwidth() normalized setter; center/bandwidth
  removed from applyPanStatus (the 13 Flex-specific pan fields stay there until
  they convert).
- RadioModel: transitional concrete FlexBackend* alias drives the decode from
  its status choke point; the typed signal is routed to the addressed pan and
  the legacy panadapterInfoChanged is preserved. Behavior-neutral.

Verified: clean build; 64/64 tests; boundary --strict green; LIVE panadapter
GPU grab on a FLEX-8600 renders the correct frequency axis (7.14–7.28 MHz,
center 7.22) — i.e. center/bandwidth flowed correctly through the new path.

This is the template. The remaining universal pan fields (min/max dBm,
waterfall) and the other four mixed models follow the same mold.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@ten9876 ten9876 requested a review from a team as a code owner July 5, 2026 20:09
@ten9876 ten9876 self-assigned this Jul 5, 2026

@NF0T NF0T left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Human-directed independent review (Claude / @NF0T)

Tier: All 7 changed files (IRadioBackend.h, FlexBackend.{cpp,h}, PanadapterModel.{cpp,h}, RadioModel.{cpp,h}) are plain src/ paths — Tier 3, default CODEOWNERS rule.

CI at review time: build, check-macos, and both static-analysis jobs green. check-windows/CodeQL were still pending — worth a final glance before merge.

Verified independently (not just re-asserting the PR body)

  • Principle I: confirmed against FlexLib source (Panadapter.cs:918,951) — "center"/"bandwidth" are the exact wire status keys, both MHz doubles, matching this PR's decode exactly.
  • Principle II: the new path only reads wire kvs and never writes back to the radio — one-way reconciliation preserved.
  • Choke-point driving: decodePanCenterBandwidth is called from handlePanadapterStatus itself (not a live-only observer), so deferred/replayed status is genuinely covered as claimed.
  • Pan-resolution consistency: the new ctor-wired lambda's m_panadapters.value(panId) → activePanadapter() fallback exactly matches the pre-existing fallback used elsewhere in the same function — no divergence between old and new pan resolution.
  • Thread safety: the decode call happens synchronously from already-main-thread status processing, so the emitted signal resolves to a direct connection regardless of FlexBackend's own thread affinity.
  • Touchpoint manifest: correctly left unconverted for models/PanadapterModel.h since this is a partial field-level conversion, consistent with the documented incremental methodology in aetherd-iradiobackend-design.md.
  • Default member values (m_centerMhz{14.1}/m_bandwidthMhz{0.2}) never collide with the -1.0 "unchanged" sentinel.

Findings

1. (Moderate, non-blocking, pre-existing but undisclosed) The "single source of truth" claim is incomplete. PanadapterModel::applyWaterfallStatus() (PanadapterModel.cpp, right after the line_duration handling) still independently parses "center"/"bandwidth" straight out of kvs and writes m_centerMhz/emits infoChanged, entirely bypassing the new FlexBackend::decodePanCenterBandwidth() path. This code isn't touched by this diff, so it's not a regression — but it means center/bandwidth aren't actually single-sourced from the backend yet, and this second ingestion surface isn't mentioned anywhere in the PR body's field inventory (understandably, since it isn't even in applyPanStatus). Cross-checked against FlexLib's Waterfall.cs: "center" is genuinely live there (line 799); "bandwidth" is an explicit FlexLib no-op (line 1116, grouped purely to suppress a debug warning) — so this duplicate may be tracking a field FlexLib itself doesn't treat as meaningful in waterfall scope. Recommend disclosing this as a known gap or folding it into scope before calling this "the template" for the rest of 2.3.

2. (Minor, non-blocking) No test coverage for the new decode/normalize chain. FlexBackend::decodePanCenterBandwidth(), PanadapterModel::setCenterBandwidth(), and the new RadioModel wiring have zero unit tests in this diff, despite existing precedent for testing kvs-driven PanadapterModel setters directly (tests/panadapter_model_rx_antenna_test.cpp). Given this PR is explicitly the reusable template for the rest of RFC 2.3, shipping it without a test sets a weak precedent for the many follow-on conversions.

3. (Nit) ~RadioModel() doesn't null m_flexBackend. RadioModel.cpp nulls m_connection/m_panStream after m_backend.reset() as a defensive pattern against dangling non-owning pointers, but the new m_flexBackend alias isn't included. Harmless today (nothing reads it afterward), but inconsistent with the file's own convention.

Verdict

No functional regression confirmed — the core claim (byte-identical behavior + live GPU verification) holds up independently, and the design is sound for the slice it claims to cover. Posting as COMMENT rather than APPROVE/REQUEST_CHANGES pending your call on whether finding 1 or 2 should hold this before merge.

…— Principle I & VI.

Extends #4063 so the model-split template proves all THREE facets before the
blast radius expands, not just decode:

FACET 2 — universal ENCODE through a backend verb, TX-safety preserved:
- SliceModel::setMode now expresses intent (modeChangeRequested) instead of
  building "slice set N mode=…"; RadioModel routes it to FlexBackend::setSliceMode.
- The slice verbs route through a dedicated guarded slice sink
  (setSliceCommandSink → RadioModel::sendSliceCommand), so the TX-inhibit
  interlock stays ABOVE the seam (RFC §6). Verified LSB↔USB round-trip on a
  live FLEX-8600.

FACET 3 — Flex-specific field → namespaced extension:
- New IRadioBackend::extensionStatus(ns, kind, fields) — the status counterpart
  to invokeExtension, the vendor-namespaced UP channel.
- WNB (wideband noise blanker) decode moves out of PanadapterModel::applyPanStatus
  into FlexBackend::decodePanExtensions → extensionStatus("flex","panWnb") →
  PanadapterModel::applyWnbExtension. Verified: WNB button tracks toggles
  precisely (the state flows the new path).

Both driven from RadioModel's status choke point (live + deferred), same as the
decode facet. Behavior-neutral.

Verified: clean build; 64/64 tests; boundary --strict green; on a live FLEX-8600
— slice-0 mode change round-trips through the backend verb, WNB toggle tracks
through the extension path, panadapter GPU grab intact. Template now complete;
the remaining pan fields + the other four models follow this exact mold.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@NF0T NF0T self-assigned this Jul 5, 2026
@ten9876 ten9876 changed the title feat(aetherd): 2.3 template — pan center/bandwidth decode moves to FlexBackend feat(aetherd): 2.3 template (complete) — decode + encode + extension facets on PanadapterModel/SliceModel Jul 5, 2026
@ten9876

ten9876 commented Jul 5, 2026

Copy link
Copy Markdown
Collaborator Author

Template completed — all three facets now proven (836a8a6)

Per the "prove the template before expanding the blast radius" plan, this PR now covers all three facets a full 2.3 model-split needs, each verified on a live FLEX-8600:

  1. Universal DECODE (original) — pan center/bandwidth → FlexBackend::decodePanCenterBandwidth → typed panCenterBandwidthChangedPanadapterModel::setCenterBandwidth. Verified: live panadapter grab renders the correct frequency axis.
  2. Universal ENCODE, TX-safety preservedSliceModel::setMode expresses intent → FlexBackend::setSliceMode → a dedicated guarded slice sink (sendSliceCommand), keeping the TX-inhibit interlock above the seam (§6). Verified: LSB↔USB round-trips through the backend verb.
  3. Flex-specific field → namespaced EXTENSION — new IRadioBackend::extensionStatus(ns, kind, fields); WNB decode moves out of applyPanStatus into FlexBackend::decodePanExtensionsextensionStatus("flex","panWnb")PanadapterModel::applyWnbExtension. Verified: the WNB button tracks toggles precisely (state flows the new path).

All three are driven from RadioModel's single status choke point (so live and deferred status convert), and are behavior-neutral. This is the complete mold: the remaining pan fields (min/max dBm, waterfall) and the other four models (Meter/Slice/Transmit/Radio) are now mechanical repetition of these three shapes.

Verified: clean build, 64/64 tests, boundary --strict green, live radio.

@NF0T NF0T left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Human-directed independent review, update (Claude / @NF0T) — covers commit 836a8a6

Follow-up to the earlier review of 7897704 (decode facet only). This pass covers the newly pushed encode + extension facets. Local build verified clean on this commit; CI: build/check-macos/static-analysis green, check-windows/CodeQL pending at review time.

Verified against FlexLib (Principle I)

  • wnb/wnb_level/wnb_updating are the correct wire keys (Panadapter.cs:1226,1244,1262).
  • "slice set N mode=X" is the correct wire format (Slice.cs:295) — unchanged, correctly preserved through the move.
  • sendSliceCommand(nullptr, cmd) correctly falls back to this->slice(txCommand.sliceId) for ID-based resolution — no null-deref, no dangling risk, behaviorally equivalent to passing a real pointer.
  • extensionStatus dispatch guard and pan-resolution fallback match the established pattern from the decode facet — no divergence.

Findings

1. (Moderate — regression, independently confirmed against FlexLib) Dropped input validation on wnb_level. Old PanadapterModel::applyPanStatus parsed wnb_level via kvs["wnb_level"].toInt(&ok) and only applied it if (ok && ...) — mirroring FlexLib's own validation exactly (Panadapter.cs:1244: uint.TryParse + range check, "Invalid value" + skip on failure). The new FlexBackend::decodePanExtensions() uses kvs.value("wnb_level").toInt() with no ok check — a malformed/non-numeric wnb_level on the wire now silently becomes 0 and gets applied to the model (spurious wnbChanged/wnbStateChanged) instead of being rejected as before. Low-probability trigger, but a real Principle VII (Untrusted Input Validated At The Boundary) regression that both the prior AetherSDR code and FlexLib itself guarded against. Recommend restoring the ok-checked parse before merge.

2. (Minor — overstated claim, not a functional bug) "TX-inhibit interlock stays above the seam" doesn't actually change behavior for mode changes. TransmitInhibitPolicy::parseSliceTxCommand only recognizes commands containing a literal tx= kv. A "slice set N mode=X" command was already routing through the same guarded sendSliceCommand() before this PR (via SliceModel::commandReady), and the guard was already a no-op for mode changes both before and after. This commit is a pure architecture/plumbing change — the commit message overstates what the "guarded slice sink" accomplishes here specifically.

3. (Minor — quietly expanded scope, harmless) setSliceFrequency/setSliceFilter rerouted but unreachable. The diff also switches FlexBackend::setSliceFrequency()/setSliceFilter() to the new sendSlice() path, but neither has any caller yet (confirmed via git grep pinned to 836a8a6 — the only caller of any of the three is the new setSliceMode call). Dead code today, harmless, but not mentioned in the PR's facet description.

4. (Nit — documentation loss) The removed WNB block's comment — "FlexLib v4.2.18 exposes wnb_updating on display pan status while the radio normalizes the SCU-level WNB threshold; keep it distinct from the per-pan WNB enable flag" — is real, non-obvious wire-quirk knowledge that wasn't carried into decodePanExtensions/applyWnbExtension.

5. (Minor — recurring gap) Still zero test coverage. decodePanExtensions, applyWnbExtension, modeChangeRequested, and setSliceMode/sendSlice have no unit tests (confirmed via git grep pinned to 836a8a6), despite precedent (tests/slice_model_letter_test.cpp, tests/panadapter_model_rx_antenna_test.cpp). Same gap flagged in the original decode-facet review, now repeated across two more facets in what's explicitly meant to be the reusable template for the rest of RFC 2.3.

Assessment

No live-behavior-breaking bug found. Finding 1 is the one with real teeth — a narrow but genuine input-validation regression relative to both prior AetherSDR code and the FlexLib reference it was modeled on. Findings 2-5 are framing/completeness/hygiene, not correctness. Across both commits reviewed so far, the pattern is consistent: sound architecture, correct on the happy path, but validation rigor and test coverage slipping at the edges — worth tightening before this template gets copied three more times for the rest of 2.3.

@aethersdr-agent aethersdr-agent Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks @ten9876 — this is a clean, well-documented realization of the 2.3 template. I traced the whole path (wire → handlePanadapterStatusdecodePanCenterBandwidth/decodePanExtensions → normalized signals → setCenterBandwidth/applyWnbExtension) plus the slice encode seam, and the decode side is genuinely behavior-neutral. A few observations, none blocking. CI is green across all 7 checks (build, check-windows, check-macos, CodeQL, Engine/UI dependency direction).

Correctness — verified, no bugs found

  • The sendSliceCommand(nullptr, cmd) sink is safe: parseSliceTxCommand classifies slice tune/set mode/filt as non-TX-enabling, so the guard is inert for these verbs and never dereferences the null slice. When a real TX-enable command does route here later, the guard resolves the target from the command string (this->slice(txCommand.sliceId)) exactly as intended.
  • The ctor-wired pan lookup (m_panadapters.value(panId)activePanadapter() fallback) mirrors handlePanadapterStatus's existing resolution, so the addressed-vs-active behavior is preserved. The legacy panadapterInfoChanged is emitted exactly once (the old inline block is replaced by the handler), so no double-emit.
  • SliceModel::setModemodeChangeRequestedsetSliceMode produces a byte-identical slice set N mode=… string to the old direct sendCommand, and reaches the same sendSliceCommand. Behavior-neutral.

1. The body undersells the scope (worth a note for future reviewers/bisects). The description frames this as "the smallest genuine slice — two universal panadapter fields (center/bandwidth)," but the diff also lands (a) the full slice encode path — setSliceCommandSink/sendSlice/m_sliceSink, SliceModel::modeChangeRequested, and the setSliceFrequency/Mode/Filter rewiring to the guarded sink — and (b) the WNB extension facet end-to-end. The title ("decode + encode + extension facets") is accurate; it's just the "Why this scope" section that reads narrower than reality. No change needed to the code, but if a TX-inhibited-slice-mode regression ever surfaces, it traces to this PR, not a later one — good to have that on record.

2. Minor: wnb_level parse robustness regression. The old applyPanStatus guarded with toInt(&ok) and only applied the value when ok. The new FlexBackend::decodePanExtensions uses a bare .toInt(), so a malformed wnb_level=<non-numeric> now writes 0 (clamped) into the model instead of being ignored. Real Flex firmware won't send that, so severity is low, but preserving the ok-check when inserting the key keeps parity:

if (kvs.contains(QStringLiteral("wnb_level"))) {
    bool ok = false;
    const int lvl = kvs.value(QStringLiteral("wnb_level")).toInt(&ok);
    if (ok) wnb.insert(QStringLiteral("wnb_level"), lvl);
}

3. Latent (fine today): modeChangeRequested is only serviced when m_flexBackend is set. With Flex as the only backend this is correct, but once a non-Flex backend exists, setMode becomes a silent no-op on the wire while still emitting modeChanged. A // TODO(2.x): route via IRadioBackend once encode is backend-owned at the connect site would flag it for whoever generalizes the seam.

Nice work proving the template on the live RX plane. Points 2 and 3 are optional polish — I'd take this as-is.


🤖 aethersdr-agent · cost: $5.7085 · model: claude-opus-4-8

@ten9876 ten9876 left a comment

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code review — high-effort recall pass (8 finder angles → verify)

Verdict: no confirmed functional regression. Ship-shape as a behavior-neutral seam step, modulo one narrow decode delta. Two independent finders (decode-half + encode/lifetime-half) plus a manual line-by-line pass agree the 2.3 template is behavior-preserving: the mode-change wire string is byte-for-byte identical, synchronous, and connected for every slice (incl. the stale-slice reclaim path); the -1.0/absent-field sentinels are correct; extension routing targets the right pan; and the emit-order change has no coupled consumer.

One real delta and two template-hardening items follow inline. I've kept them separate from @NF0T's earlier review, which already covers:

  • Waterfall dual-parse of center/bandwidth — pre-existing (applyWaterfallStatus still parses independently); this PR neither introduces nor fixes it. Not a regression; worth a follow-up to converge both ingests on the new setCenterBandwidth path.
  • No test coverage for the new decode/encode facets — agreed; the decode facets (decodePanCenterBandwidth, decodePanExtensions) are pure and cheap to unit-test and would have caught the wnb_level delta below.

Confirmed-safe, no action needed: m_sliceSinkm_sink fallback is dead-but-harmless in the wired RadioModel; setSliceFrequency/setSliceFilter are dead template code today (still on the old guarded sendCommand path via SliceModel) — when they convert, wire them like modeChangeRequested and mind the null-slice inhibit gap noted below.

Comment on lines +220 to +223
if (kvs.contains(QStringLiteral("wnb_level"))) {
wnb.insert(QStringLiteral("wnb_level"),
kvs.value(QStringLiteral("wnb_level")).toInt());
}

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[behavior delta — the one real regression] Lost the wnb_level numeric guard.

The old inline path did int v = kvs["wnb_level"].toInt(&ok) and only applied it under if (ok && ...), so a malformed / non-numeric wnb_level was ignored. Here .toInt() (no ok) returns 0 on failure, which is inserted, flows through extensionStatusPanadapterModel::applyWnbExtension (writes m_wnbLevel = 0 and emits wnbChanged), snapping the WNB-level UI to 0 instead of leaving it untouched.

Low real-world probability (FlexLib emits an integer here), but it contradicts the "decode is behavior-neutral" invariant this step is trying to hold. Restore the guard:

Suggested change
if (kvs.contains(QStringLiteral("wnb_level"))) {
wnb.insert(QStringLiteral("wnb_level"),
kvs.value(QStringLiteral("wnb_level")).toInt());
}
if (kvs.contains(QStringLiteral("wnb_level"))) {
bool ok = false;
const int level = kvs.value(QStringLiteral("wnb_level")).toInt(&ok);
if (ok) {
wnb.insert(QStringLiteral("wnb_level"), level);
}
}

Comment thread src/models/RadioModel.cpp
flex->setModelProvider([this]{ return m_model; });
m_connection = flex->connection(); // non-owning; the backend owns it
m_panStream = flex->panStream(); // non-owning; the backend owns it
m_flexBackend = flex.get(); // transitional alias (2.3)

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[latent UAF / template hygiene] m_flexBackend alias is never nulled on teardown.

~RadioModel deliberately disconnect(...)s m_connection/m_panStream before m_backend.reset() (the #502 ASAN fix), but it does not null this alias or tear down the slice→modeChangeRequested / backend→handler lambdas. After reset(), m_flexBackend dangles, and every if (m_flexBackend) guard (here, in handleSliceStatus's mode lambda, and in handlePanadapterStatus's decode calls) passes on the dangling pointer — false safety.

Not reachable in normal teardown (event loop isn't spinning past reset()), so PLAUSIBLE not CONFIRMED — but note the asymmetry: handlePanadapterStatus is also reachable via the WAN statusReceived connection, which the dtor never disconnects; only the non-spinning-loop timing saves it. As 2.3 wires more decode off these choke points this becomes the reachable-UAF template. One-liner, right after m_backend.reset() in ~RadioModel:

m_backend.reset();
m_flexBackend = nullptr;   // alias now dangles; drop it so guards fail closed

Comment thread src/models/RadioModel.cpp
flex->setCommandSink([this](const QString& cmd){ sendCommand(cmd); });
// Slice verbs route through the TX-inhibit-guarded slice sink (§6), so
// moving slice encode behind the seam keeps TX safety above it.
flex->setSliceCommandSink([this](const QString& cmd){

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[latent, not reachable in this PR] Null-slice TX-inhibit bypass on the slice sink.

Routing slice verbs through sendSliceCommand(nullptr, cmd) is correct today — the guard re-resolves the slice from the command text, and no verb currently on this path emits tx=1 (setSliceMode/Frequency/Filter are all non-transmitting), so parseSliceTxCommand(...).txEnabled is false and the inhibit block is skipped entirely. Verified null-safe.

The latent gap: if a tx-enabling verb is ever added to the sendSlice path, transmitInhibitMessageForSlice(null) returns empty for a since-removed slice → guard skipped → tx=1 sent, where the old per-slice-pointer path still held the real SliceModel*. Before any transmitting verb rides this sink, have it carry/re-resolve the slice or reject tx-enabling commands on the null path. Flagging so it's on record with the template, not asking for a change here.

…d, alias teardown, decode tests

Resolves the review findings on #4063. No behavior change to the live path
beyond restoring the one dropped guard.

- Restore the wnb_level numeric guard in FlexBackend::decodePanExtensions
  (the one real regression, flagged by NF0T/ten9876/aethersdr-agent): the
  move to a bare .toInt() would silently apply a malformed wnb_level as 0
  and snap the WNB-level UI. Back to toInt(&ok) + if(ok) — parity with the
  old applyPanStatus path and FlexLib's own uint.TryParse guard
  (Panadapter.cs:1244). Principle VII.
- Null m_flexBackend in ~RadioModel after m_backend.reset() so the decode
  guards fail closed instead of dereferencing the dangling transitional
  alias — handlePanadapterStatus is also reachable via the WAN
  statusReceived connection, so this is the reachable-UAF template as 2.3
  wires more decode onto the choke points.
- Restore the wnb_updating wire-quirk comment lost in the extension move.
- TODO(2.x) at the modeChangeRequested connect site: it no-ops on the wire
  for a non-Flex backend today (m_flexBackend null) while still emitting
  modeChanged. Fine while Flex is the only backend.
- Add aetherd_pan_decode_test covering all three facets (decode /
  extension / model sinks) incl. the malformed-wnb_level guard case that
  would have caught the regression — closing the test-coverage gap all
  three reviewers flagged on the template PR.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@ten9876 ten9876 requested a review from a team as a code owner July 5, 2026 21:59
@ten9876

ten9876 commented Jul 5, 2026

Copy link
Copy Markdown
Collaborator Author

Review response — pushed 478a31f

Thanks @NF0T @ten9876 @aethersdr-agent — thorough passes. Consensus was clear (behavior-neutral seam step, one real delta), and I've resolved every finding rather than deferring:

Fixed in code

  • wnb_level guard restored (the one regression — all three of you) — decodePanExtensions is back to toInt(&ok) + if(ok), so a malformed wnb_level is dropped, not applied as 0. Parity with the old applyPanStatus path and FlexLib's uint.TryParse (Panadapter.cs:1244). Principle VII.
  • m_flexBackend nulled in ~RadioModel after m_backend.reset() (@ten9876's reachable-UAF-template point — handlePanadapterStatus is also reachable via the WAN statusReceived connection, so the guards now fail closed instead of dereferencing the dangling alias).
  • wnb_updating wire-quirk comment restored (@NF0T) into decodePanExtensions.
  • TODO(2.x) at the modeChangeRequested connect site (@aethersdr-agent) — flags the silent-no-op-on-non-Flex-backend for whoever generalizes the seam.

Test coverage (flagged by all three)

  • Added aetherd_pan_decode_test — all three facets (decode / extension / model sinks), including the malformed-wnb_level case that would have caught the regression. 65/65 green.

Disclosed in the PR body (deferred, not regressions)

  • Waterfall dual-parse of center/bandwidth (@NF0T/@ten9876) — now called out as a known gap converging on setCenterBandwidth in a follow-up; noted FlexLib treats waterfall bandwidth as a no-op.
  • Scope framing corrected — the body now describes the encode + extension facets it actually lands, so a future bisect traces mode-encode here.
  • setSliceFrequency/setSliceFilter dead-template-code status disclosed.

On record, no change (not reachable here)

  • Null-slice TX-inhibit bypass (@ten9876) — correct today; will carry/re-resolve the slice before any tx=-enabling verb rides sendSlice.
  • "TX-inhibit above the seam" is a no-op for mode changes specifically — the body now frames it as establishing where the guard lives for later transmitting verbs, not as changing current behavior.

@aethersdr-agent aethersdr-agent Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reviewed the full diff against main. This is a clean, well-scoped increment — the three-facet template (decode / encode / extension) is coherent, the choke-point driving is correct, and the test coverage pins the behavior that matters. Thanks for the thorough write-up and the self red-team, @ten9876.

The three self-review findings are all resolved in the current head

For the record, since these were flagged as open concerns — the final commit (478a31f0, "address 2.3 template red-team findings") already fixes them, so they're now no-ops against the current diff:

  1. wnb_level guard — restored. FlexBackend::decodePanExtensions (FlexBackend.cpp:224) does toInt(&ok) and only inserts under if (ok), and aetherd_pan_decode_test pins the malformed-input drop. Confirmed the "decode is behavior-neutral" invariant holds. ✅
  2. m_flexBackend alias teardown~RadioModel now nulls it immediately after m_backend.reset() (RadioModel.cpp:690), so the if (m_flexBackend) guards fail closed. No event processing runs between reset() and the null in the dtor, so the WAN-statusReceived-reachable window is closed. ✅
  3. Null-slice TX bypass — accurately characterized as latent/not-reachable. Verified: parseSliceTxCommand("slice set N mode=…").txEnabled is false, so the inhibit block in sendSliceCommand is skipped entirely and the nullptr slice is never dereferenced (RadioModel.cpp:941). Correct to flag-for-record rather than change here.

Verification I ran

  • Pan resolution is consistent. The ctor-wired panCenterBandwidthChanged / extensionStatus handlers resolve the target pan via m_panadapters.value(panId)activePanadapter() fallback — byte-identical to handlePanadapterStatus's own lookup, so decode + WNB land on the same model as the old inline path. Behavior-neutral. ✅
  • Encode path parity. Old setMode already routed through sendSliceCommand (via commandReady); the only delta is snullptr, and the guard re-resolves by sliceId from the command text. Wire string unchanged. ✅
  • Dependency-direction CI check passed despite the transitional concrete FlexBackend* in RadioModel — good, the alias is documented as going away when status ingress becomes backend-owned.
  • Scope is tight: all 11 files are on the stated touchpoint, test is registered in CMake, no stray files.

Minor (non-blocking) — defense-in-depth for the model sink

PanadapterModel::applyWnbExtension (PanadapterModel.cpp:97) does fields.value("wnb_level").toInt() with no ok check, relying entirely on FlexBackend having guarded upstream. That's fine for the single caller today, and the std::clamp(…, 0, 100) bounds the worst case, but as 2.3 adds more producers on the extensionStatus channel this becomes an implicit contract worth making explicit — either re-guard here or add a comment noting the sink trusts pre-validated input. Not a change I'd block on.

Overall: LGTM as a template. The narrow-but-end-to-end scoping is the right call for de-risking the rest of 2.3, and the "known gaps" disclosure (waterfall dual-parse, remaining Flex pan fields) is accurate against the tree. Nice work.

(CI still in progress at review time — build/macos/windows/analyze pending; dependency-direction and accessibility checks green.)


🤖 aethersdr-agent · cost: $2.3279 · model: claude-opus-4-8

@NF0T NF0T left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Human-directed independent review, final pass (Claude / @NF0T) — 478a31f

Pulled the branch locally, built it (clean), and independently re-verified rather than just trusting the write-up:

  • wnb_level guard fix — confirmed in the diff: back to toInt(&ok) + if (ok), matching FlexLib's own uint.TryParse/skip-on-failure behavior.
  • m_flexBackend teardown fix — confirmed: nulled immediately after m_backend.reset() in ~RadioModel().
  • New test aetherd_pan_decode_test — built and ran it myself (all checks passed), and read the source: it specifically pins the malformed-wnb_level-must-be-dropped-not-zeroed regression, plus decode-sentinel and model-sink coverage for all three facets.
  • PR body — now honestly discloses the waterfall dual-parse gap and reframes the TX-inhibit claim accurately (a plumbing move for now, not a live behavior change).

Every finding raised across our two reviews, the bot's two passes, and @ten9876's self red-team is either fixed in code or disclosed as a deliberate, pre-existing, or deferred item. One optional nit remains on record (defense-in-depth ok-check in PanadapterModel::applyWnbExtension, not blocking — the single caller already validates upstream).

We're good to go from our side. Given how load-bearing this template is for the rest of RFC 2.3, leaving the merge call to @ten9876 rather than approving ourselves.

@ten9876 ten9876 merged commit cc412b9 into main Jul 5, 2026
6 checks passed
@ten9876 ten9876 deleted the aetherd-step2.3-model-splits branch July 5, 2026 22:39
ten9876 added a commit that referenced this pull request Jul 5, 2026
…decode behind the seam

Finishes the PanadapterModel touchpoint in one pass: applyPanStatus and
applyWaterfallStatus are REMOVED — the model holds zero wire decode. Every
remaining field moved to FlexBackend, driving the model via normalized signals
wired in RadioModel's ctor. Behavior-neutral (adversarially reviewed field-by-
field); FlexBackend is main-thread so the emit→apply chain is synchronous and
status ordering is preserved.

UNIVERSAL typed signals (rfgain + antenna promoted per the 2026-07-05
classification — cross-radio concepts, so a second backend emits them and the
existing UI just works):
- panRfGainChanged, panRxAntennaChanged, panAntennaListChanged,
  panWaterfallLineDurationChanged (+ center/bandwidth, min/max dBm from prior).
- Model gains setRfGain/setRxAntenna/setAntList/setWaterfallLineDuration.

FLEX EXTENSION channel (namespaced extensionStatus):
- "panState" bundle: wide, loop A/B, fps, preamp, DAX-IQ channel, MultiFlex
  client_handle (#3977 fail-closed re-stamp preserved verbatim), waterfall
  stream-id — applied via PanadapterModel::applyStateExtension. "panWnb" stays.
- fps ok-guard + reported-always quirk, line_duration ok-guard, loopA/loopB
  mutual-exclusion all preserved.

CONVERGENCES:
- RadioModel's duplicate ant_list parse now folds onto panAntennaListChanged.
- Waterfall center/bandwidth + line_duration decode via the same backend path.

GUI (restores a latent #4063 regression): six call sites used
applyPanStatus({{"center",…}}) as an optimistic pre-echo model nudge. #4063
moved center/bandwidth decode out of applyPanStatus, silently turning those into
NO-OPS (zoom/tune/pan-follow waited a radio round-trip to recenter). Swapped to
setCenterBandwidth — the optimistic repaint works again.

TESTS: aetherd_pan_decode_test extended (rfgain/antenna/panState/#3977
ownership/line_duration guard); panadapter_model_rx_antenna_test uses the new
setters. 65/65 green; boundary --strict green; full app builds clean. Design
doc pan row + classification decision updated.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
ten9876 added a commit that referenced this pull request Jul 6, 2026
…atus decode behind the seam) (#4065)

Completes the **PanadapterModel** touchpoint of aetherd RFC 2.3 in a
single pass — `applyPanStatus` and `applyWaterfallStatus` are
**removed**; the model holds zero wire decode. Every Flex status field
now decodes in `FlexBackend` and drives the model via normalized signals
wired in `RadioModel`'s ctor. This is the first *fully converted* mixed
model (the template PR #4063 proved the mold on two fields; this
finishes the model).

## What moved behind the seam

**Universal typed signals** (rfgain + antenna promoted to universal per
the 2026-07-05 classification — cross-radio concepts, so a second
backend emits them and the existing UI works with zero vendor-specific
handling):
- `panCenterBandwidthChanged`, `panRangeChanged` (min/max dBm),
`panRfGainChanged`, `panRxAntennaChanged`, `panAntennaListChanged`,
`panWaterfallLineDurationChanged`.
- Model gains normalized setters: `setRfGain` / `setRxAntenna` /
`setAntList` / `setRange` / `setWaterfallLineDuration`.

**Flex extension channel** (namespaced `extensionStatus`):
- `"panWnb"` (from #4063) + new `"panState"` bundle: `wide`, loop A/B,
`fps`, preamp, DAX-IQ channel, MultiFlex `client_handle`, waterfall
stream-id → `PanadapterModel::applyStateExtension`.

**Convergences** (closing dual-parse gaps):
- RadioModel's duplicate `ant_list` parse folds onto
`panAntennaListChanged`.
- Waterfall `center`/`bandwidth` + `line_duration` decode through the
same backend path as pan status (single-sourced; the
`center`/`bandwidth` gap was disclosed on #4063).

## Behavior-neutral — verified

Adversarially reviewed field-by-field (dropped guards, emit-timing,
sentinels, ordering, pan-null path). Preserved verbatim:
- `fps` ok-guard + "reported-always / changed-on-change" quirk;
`line_duration` ok-guard; loopA/loopB mutual-exclusion.
- #3977 `client_handle` fail-closed re-stamp (`parsed != 0 && parsed !=
current`) — the re-stamp moves from first→last field within
`handlePanadapterStatus`, but the only ownership readers
(`noteForeignPanWriteIfAny`, pan-reclaim) run at the **top** of status
dispatch before this handler, so the ordering #3977 relies on is
untouched. Pinned by a test.
- `-1.0` (center/bw) and `NaN` (dBm, signed) "unchanged" sentinels; the
`setDbmRange` GPU side-effect + legacy
`panadapterInfoChanged`/`panadapterLevelChanged` emits.
- FlexBackend is main-thread, so every `decodePan*` → signal → setter
chain is a synchronous `DirectConnection` completing before the
`y_pixels`/`configurePan` bookkeeping — status ordering is preserved.
- **One deliberate exception:** the old code emitted
`panadapterLevelChanged` even on the pan==null path (synthesized
`-130`/`-20` defaults). That signal now has **no live `connect()`**
(spectrum consumer removed; per-pan `levelChanged` used instead), so the
no-pan emit is intentionally dropped rather than resurrected as dead
code.

## ⚠️ Also fixes a latent regression from #4063

Six GUI call sites used `applyPanStatus({{"center",…}})` as an
**optimistic pre-echo model nudge** (zoom, tune, pan-follow, ATU
pre-tune). #4063 moved center/bandwidth decode out of `applyPanStatus`,
silently turning those calls into **no-ops** — so since #4063 merged,
those workflows have waited a full radio round-trip to recenter (visible
flicker/lag on `main`). Swapped to `setCenterBandwidth`, restoring the
immediate repaint. This slipped #4063's review because the GUI callers
weren't in that diff's scope.

## Tests / gates
- `aetherd_pan_decode_test` extended: rfgain/antenna decode, `panState`
bundle, #3977 ownership fail-closed, `line_duration` malformed-guard,
NaN/`-1` sentinels.
- `panadapter_model_rx_antenna_test` uses the new setters.
- **65/65 tests green**, engine-boundary `--strict` green, full app
builds clean.
- Design doc pan row + classification decision (§6.5) updated.

## Next in 2.3
One complete PR per remaining mixed model: MeterModel → SliceModel →
TransmitModel + RadioModel residual.

Refs #3849 (aetherd RFC tracking) — part of the step-2.3 touchpoint
burndown (`docs/architecture/aetherd-touchpoints.md`).

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants