Skip to content

Add opt-in aetherd local connection control. Principle VII. - #5458

Merged
ten9876 merged 7 commits into
aethersdr:mainfrom
rfoust:codex/aetherd-stage3-connection-control
Sep 10, 2026
Merged

Add opt-in aetherd local connection control. Principle VII.#5458
ten9876 merged 7 commits into
aethersdr:mainfrom
rfoust:codex/aetherd-stage3-connection-control

Conversation

@rfoust

@rfoust rfoust commented Sep 6, 2026

Copy link
Copy Markdown
Collaborator

Summary

Continues RFC #3849 after merged #5455 with opt-in, same-user non-TX connection control for the headless daemon. This is one Stage 3 sub-slice, not completion of the RFC.

  • Default local clients remain observers. --allow-local-control grants every client admitted by the current-user endpoint observe + control; it is not per-application consent or remote authentication. Discovery remains independently opt-in.
  • Add typed radio.connect / radio.disconnect for the daemon-owned radio-1. Connect selects an existing catalogue ID at its exact current revision; arbitrary endpoints, credentials, force flags and raw commands are rejected. Busy sessions cannot be replaced.
  • Keep native translation below the vendor seam. The injectable target reserves connection/teardown state, publishes bounded lifecycle/error observations, and cancels legacy reconnect on failure/disconnect. accepted:true acknowledges intent, not RX readiness. Controllers share disconnect authority; client loss does not undo accepted intent.
  • Enforce the advertised per-session 100/s, burst-200 request budget on every post-negotiation frame, including malformed/rejected requests. Exhaustion is terminal. This applies to every negotiated client, observers included, so it is a protocol-wide behavior change for any pre-existing observe-only client above that rate.
  • Integrate the maintainer's Add observe-only headless radio discovery catalogue (#3849) #5455 catalogue changes unchanged. Claim the endpoint before settings/model construction, load native settings before model consumers, bind the target once before dispatch, and stop delivery before teardown.

No receive setters, transmit grants/methods, remote listener, credential provisioning, Icom manual setup, desktop migration, or default native discovery/connection is introduced. The existing 30-second timer requests cancellation; it does not bound synchronous backend teardown.

Constitution principle honored

Principle VII — typed, bounded boundary input validation and catalogue-only destination selection. Principle VI — no protocol transmit authority or keying surface. Principles VIII/XI — source-backed review and demonstrated regression checks. Principle XIV — preserve settings initialization ordering and avoid touching settings on failed listen.

Test plan

  • ARM64 macOS app and daemon built using the dedicated local toolchain, RelWithDebInfo and RADE enabled. Host/system processor and final executables are arm64; no RNNoise x86 sources; daemon has no QtWidgets dependency.
  • Seven focused registered CTests pass: control_protocol_codec_test, control_authorization_test, control_resource_service_test, radio_catalogue_test, control_connection_test, radio_discovery_source_test, aetherd_discovery_startup_test.
  • Mutations individually fail their assertions: control-grant guard, catalogue revision, busy reservation, request charging, terminal rate latch, startup ordering, and pre-dispatch target-binding latch. Fixed tests return green. Mutations were confined to scratch copies.
  • Actual daemon, isolated settings/wisdom and endpoint, production simulator only: default observer connect refusal; three connect/slice-0/peer-observation/disconnect cycles; three immediate queued cancellations; authoritative subscription delivery. Test daemons stopped; existing desktop process untouched.
  • Strict test registration, frozen CI-test gate, engine-boundary ratchets, generated touchpoint manifest and whitespace checks pass. No frozen per-PR gate expansion.
  • Final source/security review covers all 20 changed files at 37a7fdd30f8d5565f6f5c2961066621a86bcb82a against merged catalogue/main 9a90802a577eabba3bdb9127340a59e46272ac30: no remaining actionable findings, no deferred source-review work. Current-main 17d61de5 prospective merge is conflict-free; its later changes are Windows Store packaging/tests.
  • PR CI results pending.
  • Physical-radio convergence and Windows/Linux runtime verification were not performed. No LAN/USB scan or RF/TX test was authorized or attempted. Simulator evidence is not hardware proof.

Socket/process disclosure: the new protocol test is socket-free and injects the target/source. The expanded startup test launches our real daemon with an invalid logical endpoint name, which fails before socket binding; no firmware stand-in or native discovery is used. Only the manual daemon integration smoke opens isolated current-user endpoints belonging to the server under test. Existing fresh-process settings readback stays isolated.

Local build limitations: optional DFNR/RTL libraries were unavailable on this Mac. Compiled source matches the reviewed commit; the desktop's embedded SHA reflects the pre-commit configure. This does not claim a full unfiltered suite or cross-platform runtime pass.

Checklist

  • All branch commits have verified signatures.
  • No new flat-key AppSettings calls or persistence format.
  • Clean-room implementation; no proprietary binary derivation.
  • No meter/UI changes.
  • Protocol catalogue, Stage 3 documentation and AGENTS migration status updated; no CHANGELOG entry.
  • No reportable vulnerability requiring a GHSA was found in the final diff.

Tracking note: #5455 automatically closed #3849 when it merged. This PR deliberately uses a non-closing reference; remaining RFC work is still outstanding. Existing issue coordination with @ten9876 is preserved.

Advance RFC aethersdr#3849 with bounded observe-only discovery metadata and explicit daemon-local discovery options. Keep desktop discovery, radio connection, controls and TX unchanged.

Initialize the settings store only for native daemon discovery so saved HL2/ANAN Identity nicknames survive startup. Cover the startup prerequisite in a process-separated socket-free regression test.
Advance RFC aethersdr#3849 with opt-in same-user control, catalogue-selected connection intents, and bounded non-TX lifecycle dispatch. No TX, remote credentials, desktop migration, or receive setters.
@rfoust rfoust self-assigned this Sep 6, 2026
@rfoust
rfoust marked this pull request as ready for review September 8, 2026 00:58
@rfoust
rfoust requested review from a team as code owners September 8, 2026 00:58
Copilot AI lite review requested due to automatic review settings September 8, 2026 00:58

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🔵 Needs a closer look

It introduces new security-relevant control-plane surface area (authorization, lifecycle, rate limiting, daemon wiring) that warrants final human review plus CI/runtime confirmation.

Pull request overview

This PR advances RFC #3849 Stage 3 by adding an opt-in, local-only, non-TX connection-control surface to aetherd, allowing trusted current-user clients to issue bounded radio.connect / radio.disconnect intents against the daemon’s single engine session (radio-1) while preserving observe-only behavior by default.

Changes:

  • Adds SessionAuthorization support for control vs observe (independent grants) plus a per-session post-handshake request budget (100/s, burst 200; terminal on exhaustion).
  • Introduces a trusted RadioConnectionTarget seam and implements typed radio.connect / radio.disconnect dispatch in ControlService, gated behind control authorization and catalogue revision/entry checks.
  • Wires aetherd --allow-local-control to install the model-backed connection target, expose lifecycle observation (connectionControl) on radioSession, and extends tests/docs accordingly.
File summaries
File Description
tests/tests.cmake Registers control_connection_test and passes the aetherd binary path into aetherd_discovery_startup_test.
tests/control_connection_test.cpp Adds socket-free regression coverage for grants, connect/disconnect validation, lifecycle conflicts, and rate-limit behavior.
tests/aetherd_discovery_startup_test.cpp Extends startup-policy coverage by launching aetherd with an invalid logical endpoint name and asserting no settings/model side effects.
src/core/control/RadioResourceAdapter.h / .cpp Optionally publishes connectionControl state/errorCode into the radioSession resource when a target is installed.
src/core/control/RadioConnectionTarget.h Defines the trusted non-TX connection lifecycle seam and factory for a model-backed target.
src/core/control/LocalControlServer.h / .cpp Adds opt-in local control authorization and startup-only binding of the connection target.
src/core/control/ControlSession.h / .cpp Adds control/observe separation and enforces the per-session post-hello request token bucket.
src/core/control/ControlService.h / .cpp Adds connection-control method handling, startup binding guard, owning-thread enforcement, and rate charging for post-handshake frames.
src/core/backends/ModelRadioConnectionTarget.cpp Implements RadioModel-backed connect/disconnect lifecycle with timeout and teardown settlement gating.
src/aetherd/main.cpp Adds --allow-local-control flag and wires endpoint-claim ordering, target binding, and teardown delivery ordering.
docs/architecture/aetherd-touchpoint-tags.json Tags the new connection-control seam as a universal touchpoint for boundary enforcement.
docs/aetherd-local-connection-control.md Documents the local connection-control contract: methods, lifecycle, observation shape, and rate limits.
docs/aetherd-control-resource-v1-catalogue.md Cross-references the optional connectionControl schema/methods documentation.
docs/aetherd-control-protocol-v1-design.md Updates authorization narrative to include the opt-in control sub-slice and references its spec.
CMakeLists.txt Adds the new target implementation source to the core library build.
AGENTS.md Updates Stage 3 status text to include the new opt-in local control and typed connect/disconnect surface.
Review details
  • Files reviewed: 20/20 changed files
  • Comments generated: 0
  • Review effort level: Lite

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

@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.

Issue fit

RFC #3849 (closed, stage-3 plan) is the parent. The RFC's staged scope puts "engine-side TX arbitration + per-client auth" alongside the protocol promotion, and this PR delivers the non-TX half of that: two typed methods, an explicit opt-in flag, and a socket-free engine seam. The acceptance bar the RFC names — "a malicious or buggy client must not be able to key the transmitter" — holds here: there is no transmit grant representable in SessionAuthorization, no TX verb in the dispatch table, and control_connection_test asserts transmit.setMox / tx.acquire return request.unknown_method and that grants never contains transmit. Yes, with one open RFC question it does not close (see Blockers §1).

Coverage layer is correct per AGENTS.md: control_connection_test is socket-free with an injected RadioConnectionTarget stub and injected normalized discovery — no fake radio peer, no backend, no settings store. It would fail against the unfixed code (grant matrix, stale-revision conflict, unknown-key rejection, foreign-thread guard, and the burst/refill boundaries are all real assertions, not tautologies).

Socket-test note (recording, not a finding): this PR adds no socket-owning test. control_connection_test links Qt6::Network but opens nothing. aetherd_discovery_startup_test gains a QProcess peer that spawns the real aetherd binary — but deliberately with --socket invalid/name, so resolveEndpoint() fails and no socket is ever bound; the process is asserted to exit 1. Neither runs on the frozen per-PR gate; both are full-suite/sanitizer targets.

Scope

File / group What it changes Claimed? Verdict
src/core/control/RadioConnectionTarget.h (new) Abstract non-TX lifecycle seam Yes In scope
src/core/backends/ModelRadioConnectionTarget.cpp (new) RadioModel translation + lifecycle state machine Yes In scope
ControlService.{h,cpp} radio.connect/radio.disconnect, capability advertisement, thread guards Yes In scope
ControlService.cppper-frame token bucket New rate limit charged to every post-negotiation frame, terminal on exhaustion Only in the new doc See Nit 1 — behavior change for existing observe-only clients, not mentioned in title/AGENTS.md summary
ControlSession.{h,cpp} Controller/ObserverController, canControl(), bucket + injectable clock Yes In scope
LocalControlServer.{h,cpp} allowLocalControl → session authorization; bindConnectionTarget Yes In scope
RadioResourceAdapter.{h,cpp} connectionControl observation, target-destroyed republish Yes In scope
src/aetherd/main.cpp --allow-local-control; listen-before-settings reorder; explicit server.close() Yes In scope (the reorder is load-bearing and tested)
CMakeLists.txt Two source entries (header listed for AUTOMOC — correct, RadioConnectionTarget.h has Q_OBJECT with no same-named .cpp) Yes In scope
AGENTS.md, 3 docs, touchpoint tags Match the code Yes In scope
tests/* New target registered at tests.cmake:140-153; startup test extended Yes In scope

No unrelated files, no formatting churn, no CHANGELOG.md entry (correct), no deleted guards. Every - line I read is either a doc sentence being updated or a constructor signature being widened.

CodeGuard: all six findings are in src/gui/MainWindow.cpp and tests/hl2_live_band_filter_probe.cpp — neither file is in this diff. Dropped as not-this-PR.

Blockers

1. Needs maintainer decision — new public surface, on an RFC question that is still open. (inline: docs/aetherd-local-connection-control.md:1)

This adds a CLI flag, two protocol methods, two advertised limits fields, and a new resource sub-object. The docs are unusually honest about what the flag is ("grants every client admitted by the current-user local endpoint both observe and control. It is not per-application consent"), which is exactly why it needs a ruling rather than a review sign-off: RFC #3849's open question 3 — "Auth model for remote clients — reuse SmartLink identity, or a separate aetherd credential?" — was never answered, and the RFC is closed. This ships the local half of that answer as a blanket per-user grant. It is defensible (opt-in, off by default, non-TX only, no client-supplied endpoints), and I am not calling it wrong — I am saying per §3 a protocol/CLI addition of this shape is a maintainer call, not mine.

Nothing else rises to blocker.

Nits (non-blocking)

  • The token bucket is a behavior change for clients that predate this PR. (inline: ControlSession.cpp:66) Any already-shipped observe-only client that exceeds 100 req/s (burst 200) now gets transport.limit_exceeded and a terminal close — m_requestLimitExceeded is never reset, so the session cannot recover even after refill. That is the right design for a control surface, and it is spelled out in the new doc, but the PR title and the AGENTS.md paragraph both describe this PR purely as connection control. Worth one sentence in the body and in AGENTS.md.
  • errorCode is never cleared on return to idle. (inline: ModelRadioConnectionTarget.cpp:22) After a timeout or failure the target settles to Idle but m_error keeps its value until the next connectRadio() calls m_error.clear(). Clients observing {"state":"idle","errorCode":"engine.timeout"} can read a stale error as current. Either clear it in setState(State::Idle) or say in the doc that errorCode is last error, not current.
  • A failed connect that emits only connectionStateChanged(false) sits in connecting for the full 30 s. The !connected && m_state == Connecting branch is intentionally a no-op (family-replacement filter, and the comment explains why). RadioModel::onConnectionError is the recovery path, but it is gated on !m_rebootInProgress. The 30 s timer bounds it, so this is latency not a hang — but the doc's lifecycle section reads as if failure is always prompt.
  • The ctor path lacks the guard the bind path has. (inline: LocalControlServer.cpp:57) bindConnectionTarget() refuses unless m_localAuthorization == ObserverController, but LocalControlServer(parent, limits, &target, /*allowLocalControl=*/false) installs a target while every local session is Observer-only. It is inert (handleConnection rejects on !canControl(), capabilities() skips the branch), so this is consistency not exposure — but the asymmetry invites a future caller to get it wrong.
  • radioSession is length-bounded by ProtocolLimits::kMaxRequestIdChars. (inline: ControlService.cpp:332) Cosmetic reuse of a request-id limit for a session name, and redundant given the very next check requires the exact string "radio-1".
  • ModelRadioConnectionTarget.cpp has no automated coverage at all. 163 lines containing the whole risky part — 30 s timer, two deferred singleShot hops, the settlement gate, legacy-reconnect cancellation, the family-swap filter — and control_connection_test exercises a stub Target instead. I do not think this is a blocker: RadioModel is not injectable, connectToRadio() reaches a real backend, and the honest options are a new seam or hardware validation, not a fake peer. But the doc's "Native firmware convergence requires separate hardware validation" undersells it — the state machine, not just firmware convergence, is unverified.

What I tried to break

  • Pre-negotiation rate-limit bypass. The bucket is only charged when session->isNegotiated(), so I looked for a window to spam unmetered frames inside the 5 s handshake timeout. Refuted: every pre-negotiation failure path in ControlService.cpp:170-192 passes closeAfterWrite = true (malformed envelope, non-hello first frame, bad hello params, version mismatch). The first bad frame closes the connection.
  • Burst-cap and refill math. Walked consumeRequest() by hand: elapsed is floored at 0, m_lastRequestTime uses std::max so a non-monotonic clock cannot mint tokens, and the cap is applied to the sum not the increment. The test's now += 10s → 200 succeed → 201st fails sequence would fail if the std::min(kRequestBurst, …) cap were dropped.
  • Startup ordering / bind race. listen() runs before makeDiscoverySource() and before RadioSession construction, and bindConnectionTarget() requires m_clients.empty() while the server is already listening. A client cannot slip in, because QLocalServer::newConnection is only delivered from the event loop and app.exec() has not been reached. The extended aetherd_discovery_startup_test asserts the settings dir is never created on a failed listen, for both flag states.
  • Target destruction mid-flight. ControlService and RadioResourceAdapter both hold QPointer. Qt zeroes the QWeakPointer strongref at the top of ~QObject, before destroyed() is emitted, so the adapter's destroyed → publishRadioSession slot sees a null pointer and correctly drops connectionControl from the published value rather than reading a dangling target. m_targetBound stays true so a lost target cannot be silently replaced — limitsAndLifetime() asserts exactly that.
  • Teardown/settlement deadlock. I tried to strand the target in Disconnecting: cancel during Connecting, then have the attempt fail with no further connectionStateChanged. Refuted — RadioModel::disconnectFromRadio() clears m_connectAttemptActive synchronously (RadioModel.cpp:3797) and stops m_reconnectTimer, so the settlement turn's !isConnectAttemptInFlight() guard passes. The synchronous-backend case (SimBackend emitting disconnected() inside the teardown call) is also covered: m_settlementQueued de-dupes the re-entrant scheduleSettlement() and the surviving turn still sees !m_teardownQueued.
  • Client-supplied endpoint smuggling. onlyKeys() restricts radio.connect to exactly {radioSession, radioId, catalogueRevision}, and every RadioInfo field is copied from the store's own catalogue entry via catalogueRadio(). The test drives host/port/auth/family/command/force and gets request.invalid_params for each.
  • Observer probing the target through error codes. handleConnection() checks canControl() first, so an observer gets auth.grant_denied without learning whether a target exists, whether the catalogue is running, or what state the engine is in. Asserted in authorization().
  • Session-ID confusion. Verified the request.sessionId != session->sessionId() check sits above the radio.connect branch, so client A's session ID cannot authorize an intent on client B's connection (session.invalid).
  • Could not check: anything at runtime. I have no build and no radio here — every statement above is reasoned from the head checkout at /tmp/aetherclaude/pr-5458, not reproduced. CI is green on 37a7fdd3 (build, check-macos, check-windows, Static checks, sanitizer-configure all success), but the frozen per-PR gate does not run control_connection_test, so its five suites are unproven by CI on this PR.

Recommendation

Needs maintainer decision. The code is the most carefully guarded thing I have reviewed on this protocol: authorization precedes every lookup, the client cannot supply a single connection parameter, the thread guards are real in release builds, target loss fails closed, and the test is genuinely adversarial rather than self-confirming. My nits are all small and none of them should hold a merge. The one thing I cannot sign off is §1 — --allow-local-control answers RFC #3849's open question 3 for the local case by fiat, and a blanket per-user grant with no per-application consent is a policy ruling. Next step: a maintainer rules on the flag's semantics; the six nits can land as follow-ups or in a fixup commit here, whichever the maintainer prefers.

Nice work, and thank you for the doc — stating plainly that the flag "is not per-application consent" is what made the one real question easy to find.


🤖 aethersdr-agent · cost: $5.9052 · model: claude-opus-5

Comment thread docs/aetherd-local-connection-control.md
Comment thread src/core/control/ControlSession.cpp
Comment thread src/core/backends/ModelRadioConnectionTarget.cpp
Comment thread src/core/control/LocalControlServer.cpp
Comment thread src/core/control/ControlService.cpp Outdated
@aethersdr-agent

Copy link
Copy Markdown
Contributor

Cisco CodeGuard — static analysis of this PR (6 finding(s))

  • [MEDIUM] CG-PATH-001 — Potential path traversal in src/gui/MainWindow.cpp /tmp/aetherclaude/pr-5458/src/gui/MainWindow.cpp:8967
  • [MEDIUM] CG-PATH-001 — Potential path traversal in src/gui/MainWindow.cpp /tmp/aetherclaude/pr-5458/src/gui/MainWindow.cpp:9079
  • [MEDIUM] CG-PATH-001 — Potential path traversal in src/gui/MainWindow.cpp /tmp/aetherclaude/pr-5458/src/gui/MainWindow.cpp:9198
  • [MEDIUM] CG-PATH-001 — Potential path traversal in src/gui/MainWindow.cpp /tmp/aetherclaude/pr-5458/src/gui/MainWindow.cpp:9199
  • [MEDIUM] CG-PATH-001 — Potential path traversal in src/gui/MainWindow.cpp /tmp/aetherclaude/pr-5458/src/gui/MainWindow.cpp:9200
  • [MEDIUM] CG-PATH-001 — Potential path traversal in tests/hl2_live_band_filter_probe.cpp /tmp/aetherclaude/pr-5458/tests/hl2_live_band_filter_probe.cpp:98

Automated static scan by Cisco DefenseClaw CodeGuard on the changed files. Advisory — some may be false positives; the review above verifies them.


🤖 aethersdr-agent · cost: $6.5608 · model: claude-opus-5

@ten9876 ten9876 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.

Issue fit

RFC #3849 is the parent (closed by #5455, referenced non-closing here). This PR delivers the non-TX half of the RFC's Stage 3 "per-client auth + control" line: an explicit opt-in flag, two typed catalogue-selected methods, a socket-free engine seam, and a per-session request budget. The RFC's acceptance bar — no client can key the transmitter — holds: no transmit grant is representable, no TX verb dispatches, and the new test asserts both. Yes, it does what it says. The one thing that is not mine to sign off is the semantics of --allow-local-control (blanket grant to every current-user client, no per-application consent) — the doc states it plainly, and it is a maintainer ruling, not a defect. Flagging it to @ten9876.

Scope

File / group What it changes Claimed? Verdict
RadioConnectionTarget.h (new), ModelRadioConnectionTarget.cpp (new) Trusted non-TX lifecycle seam + RadioModel translation/state machine Yes In scope
ControlService.{h,cpp} radio.connect/radio.disconnect, capability advertisement, startup-only bind, owning-thread guard, per-frame rate charge Yes (rate charge only in the new doc) In scope; see nit 3
ControlSession.{h,cpp} Controller/ObserverController, canControl(), token bucket + injectable clock Yes In scope
LocalControlServer.{h,cpp} Flag → session authorization; bindConnectionTarget Yes In scope
RadioResourceAdapter.{h,cpp} connectionControl in radioSession, republish on target loss Yes In scope
src/aetherd/main.cpp --allow-local-control; listen-before-settings/model reorder; explicit server.close() Yes In scope (reorder is tested)
CMakeLists.txt Two source entries Yes In scope
AGENTS.md, 3 docs, touchpoint tags Match the code Yes In scope
tests/* New socket-free target; startup test now spawns the real daemon with an invalid endpoint name Yes, disclosed In scope

Everything in the diff is explained by the PR body. Commits are all authored 2026-09-06, none predate the branch. No CHANGELOG entry, no flat-key settings calls, no UI change, no deleted guards — the body's checklist holds.

Test-boundary note (recorded, not a finding): control_connection_test links Qt6::Network but opens nothing. aetherd_discovery_startup_test now launches the real aetherd binary via QProcess with --socket invalid/name; resolveEndpoint() rejects that against ^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$ before any lock or bind, and the test asserts exit 1 plus no settings directory. Disclosed in the PR body, named in the tests.cmake comment. Neither test is on the per-PR CI gate.

Blockers

None.

Nits (non-blocking)

  1. errorCode outlives the failure it describes (inline, docs/aetherd-local-connection-control.md:76). m_error is only cleared in connectRadio(), so after engine.failed/engine.timeout the target settles to idle still carrying the code. Either document it as the last error or clear it in the settlement path. Reasoned from code — the simulator cannot fail a connect, so I could not reproduce it.
  2. A one-shot event-loop window between listen() and bindConnectionTarget() (inline, src/aetherd/main.cpp:52). With --discover-local, AppSettings::load()importLegacyXml()persistVaultToKeychain() pumps a bounded QEventLoop on a first-run legacy import. A client that connects during it makes m_clients non-empty, the bind refuses, and the daemon exits 1. Fail-closed, first run only, narrow — worth a comment, not a code change.
  3. The token bucket is a behavior change for existing observe-only clients (inline, AGENTS.md:491). Any pre-existing client above 100 req/s (burst 200) now gets a terminal transport.limit_exceeded. The new doc says so; the AGENTS.md summary and the title do not. One sentence would do.

Verified empirically vs. read

Built the PR head (37a7fdd3) locally with tests on; the seven named CTests pass. Then I broke the code on purpose eight ways and rebuilt only control_connection_test: no rate charge, no revision check, no busy conflict, observer-can-control, no burst cap, no terminal latch, no inUse check, nickname not copied. All eight were caught (1–9 failing assertions each); the restored baseline passes.

Drove two isolated daemon instances (scratch AETHER_SETTINGS_DIR + XDG_RUNTIME_DIR, --discover-sim only — no LAN scan, nothing touched the operator's radio) over the raw local socket:

  • Default daemon: grants:["observe"]; radio.connect and radio.disconnectauth.grant_denied; radioSession has no connectionControl.
  • Control daemon: grants:["observe","control"], radio.connect advertised. Three connect/disconnect cycles: {"connected":true,"connectionControl":{"state":"connected","errorCode":""}}, slice 0 readable, second connect → request.conflict, capabilities flip to radio.disconnect, disconnect → {"state":"idle"} / connected:false, idle disconnect idempotent.
  • Three connect-then-immediate-disconnect pairs all settled to idle.
  • Stale revision → request.conflict; unknown 64-char id → resource.not_found; host key → request.invalid_params {"field":"host"}; radio-2resource.not_found; fractional revision → request.invalid_params.
  • Client A connected then closed its socket; client B saw connected:true and could disconnect it (doc's shared-authority and no-implicit-disconnect claims hold).
  • 205 capabilities.get on one socket: 201 ok, then transport.limit_exceeded and the server closed the socket; a second client was unaffected. 201 malformed { frames: 200 × protocol.invalid_json, then limit — malformed frames are charged.

Not verified: hardware convergence, the 30 s timeout and engine.failed paths (the simulator cannot fail), Windows/macOS runtime. ModelRadioConnectionTarget.cpp itself has no automated coverage; its state machine was walked by hand (teardown de-dupe, synchronous disconnectFromRadio() clearing m_connectAttemptActive, legacy reconnect timer stopped before it fires).

What I tried to break that held

  • Pre-negotiation bypass of the budget: every pre-hello failure path closes the socket, so there is no unmetered window.
  • Non-monotonic clock minting tokens: std::max on both elapsed and m_lastRequestTime.
  • Target destroyed mid-flight: QPointer is nulled before destroyed() fires, so the adapter republishes without the sub-object and the service fails closed; m_targetBound stays latched.
  • Session-ID confusion and observer probing: session.invalid / auth.grant_denied land before any target or catalogue lookup.
  • Endpoint smuggling: onlyKeys plus catalogue-only RadioInfo construction.

Comment thread docs/aetherd-local-connection-control.md Outdated
Comment thread src/aetherd/main.cpp
Comment thread AGENTS.md
- Document errorCode as the last lifecycle error that persists through
  idle until the next accepted radio.connect, and say so at the seam.
- Name the one-shot legacy-import event-loop window between listen()
  and bindConnectionTarget() in the daemon startup comment.
- Disclose the per-session request budget in AGENTS.md; it applies to
  every negotiated client, not only controllers.
- Install the connection target in the LocalControlServer constructor
  only when local control is granted, mirroring bindConnectionTarget().
- Drop the request-id bound reused for radioSession; the exact-match
  check that follows already bounds it.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

@ten9876 ten9876 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.

Approving at 7106351. All eight review threads are addressed in that commit and resolved: errorCode documented as the last lifecycle error, the startup event-loop window named, the request budget disclosed in AGENTS.md and the PR body, the constructor guard mirrored, and the request-id bound dropped. Maintainer ruling on the opt-in blanket local grant recorded on its thread.

Verified locally on the fix commit: the seven named CTests plus local_control_server_test pass; whitespace clean. Earlier on 37a7fdd: eight deliberate mutations were each caught by control_connection_test, and a raw-socket session against two isolated --discover-sim daemons confirmed the observer refusal, three connect/disconnect cycles, immediate cancellations, stale-revision/bad-id/extra-key rejections, shared disconnect authority across clients, and the terminal per-client rate limit.

@ten9876
ten9876 enabled auto-merge (squash) September 10, 2026 15:27
@ten9876
ten9876 merged commit 981b7fe into aethersdr:main Sep 10, 2026
5 checks passed
ten9876 added a commit that referenced this pull request Sep 10, 2026
## What it does

Adds `sweep-closed-prs` to `cache-cleanup.yml`: a daily (`17 4 * * *`)
plus on-demand job that deletes caches on `refs/pull/N/merge` once the
PR is no longer open.

Those caches live on a ref only that PR can read. When it closes they
become permanently unreachable but keep holding budget for up to the
full 7-day retention, which is not configurable.

## Why scheduled, not `pull_request: closed`

The obvious trigger cannot work, which is why #5008 pulled the original
version rather than shipping it: for a `pull_request` event whose head
is a fork, GitHub issues a **read-only** `GITHUB_TOKEN`, and a
`permissions:` block can only lower that ceiling, never raise it. `gh
cache delete` returns 403 and the job fails. **44 of the last 60 closed
PRs here are from forks** (measured 2026-09-10).

`pull_request_target` would get a writable token and is the
documented-safe shape for a job that checks nothing out. It's
deliberately not used: a schedule needs no privileged trigger at all, is
idempotent, and **self-heals** — it picks up PRs closed while the
workflow was broken, renamed, or disabled, which an event-driven job
misses forever.

The cost is latency, and it is not quite free: while the repo is over
its allowance, dead entries compete with main's live ones for LRU until
the sweep runs. If that bites, the job is idempotent and cheap enough to
also hang off the existing `workflow_run` trigger, with the schedule
kept as the backstop. Not done here; see follow-ups.

## Measured premise (2026-09-10, 25 days after #5008)

The original draft held this PR until it could be shown that anything
still accumulates on PR refs after #5008's save-on-main split. It does:

```
PR-ref caches: 20 entries, 6881 MiB
repo usage:    23 entries, 9.88 GiB of 10 GiB

#5458 MERGED 1720 MiB   ← unreachable, swept by this job
#5462 OPEN    396 MiB
#5539 OPEN   1323 MiB
#5542 OPEN   1720 MiB
#5547 OPEN   1720 MiB
```

That is past the "> 2 GiB, or any single Qt-sized entry → merge"
threshold the draft set for itself. The "PRs should restore rather than
write" hypothesis did not hold: each open PR carries its own copy of the
same 1,297 MiB Qt key main holds, because at the allowance LRU evicts
main's copy between runs and the next PR re-saves it. This job reclaims
the closed-PR leg of that loop; the loop itself is a ci.yml matter
(follow-ups).

## Failure behaviour

| case | behaviour |
|---|---|
| PR state unresolvable | Skipped and retried next run — a transient API
error must not become data loss. Counted, and surfaced as a
`::warning::` with gh's stderr so a persistent cause (missing scope,
rate limit) cannot hide |
| `--limit 500` truncation | Emits `::warning::`, because silent
truncation looks exactly like "nothing to clean" |
| Individual delete fails / LRU took it first | Deleted by id like
prune-main; a 404 is logged, does not abort the loop, and does not count
toward `freed` |
| Dispatch overlaps the cron | `concurrency` group queues the second run
|

## Cannot be exercised on this PR

`schedule` and `workflow_dispatch` only fire from the **default
branch**. First real run is after merge: trigger it via
`workflow_dispatch` immediately rather than waiting for 04:17, and read
the log before trusting it. That first run is also what confirms the
`pull-requests: read` grant is sufficient for `gh pr view` under
`GITHUB_TOKEN`.

## Not addressed here

- Caches on **tag refs** (`refs/tags/v*`) from the release workflows are
swept by neither job. Low volume; noted so it isn't silently forgotten.
- The open-PR duplication loop above. The durable fix is giving the
dependency caches (Qt, FFTW3, DeepFilterNet3, qtkeychain) the same
restore-on-PR / save-on-main split #5008 gave the compiler caches;
`install-qt-action`'s built-in cache has no restore-only mode and needs
its own decision.

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

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
ten9876 added a commit that referenced this pull request Sep 10, 2026
## Summary

Continues #3849 after #5458 with one bounded receive-control method:
`slice.setFrequency` for an existing owned slice. Coordination:
#3849 (comment).

- Retains observe-only startup and the existing explicit
`--allow-local-control` opt-in. No TX method, raw command, new
credential path, discovery behavior or desktop migration.
- Validates the control grant, canonical session/slice identity, exact
observed resource revision, integer Hz, live ownership/lock/connection
state and backend coverage before one typed dispatch.
- Separates backend frequency observations from the existing optimistic
desktop value. `accepted:true` is not a hardware ACK or convergence
guarantee; revisions are freshness checks, not CAS or controller
reservations.
- Refuses TX-capable sessions until explicit idle readback, invalidating
that knowledge on local/radio TX activity and connection/backend
transitions.
- Documents backend authority and coverage explicitly. Sim is the
exercised path. RTL-SDR can qualify when built; TX-enabled HL2, Flex and
ANAN remain unavailable where readback or verified bounds are missing.
Icom stays outside the daemon's current catalogue connection scope.
Existing desktop tuning remains unchanged.

See `docs/aetherd-local-slice-frequency-control.md` for the precise
contract, errors, competing-controller behavior and limitations.

## Constitution principles

Principle II: accepted intent never fabricates observed frequency.
Principle VI: no TX operation or grant is added. Principle VII: strict
boundary validation and fail-closed coverage/idle checks. Principle XI:
behavioral tests and deliberate guard mutations demonstrate the safety
claims.

## Verification

- ARM64 `AetherSDR` and `aetherd` built with the dedicated Mac
toolchain, RADE enabled; ARM64 processors/executables verified, no
RNNoise x86 sources, no QtWidgets daemon dependency.
- Nine focused CTests passed: `control_protocol_codec_test`,
`control_authorization_test`, `local_control_server_test`,
`control_resource_service_test`, `radio_catalogue_test`,
`control_connection_test`, `control_slice_frequency_test`,
`radio_discovery_source_test`, `aetherd_discovery_startup_test`.
- New socket-free production-target tests cover grants, schema,
ownership, limits, delayed/differing/same-value observations, optimistic
desktop separation, revision conflicts/recreation, multiple controllers,
TX-idle admission, lifetime, owning thread and request budgets.
- Mutation checks: removing revision validation and assuming idle at
construction each failed the new test. A separate local-keying
regression failed before the fix; all tests pass with guards restored.
- Real daemon/local-server smoke with production simulator: observe-only
refusal, opt-in connection/tuning, subscription events, two controllers,
stale revisions and disconnect.
- Native Cocoa desktop MCP proof on the exact rebuilt ARM64 artifact:
authenticated identity, isolated settings, `txAllowed:false`,
`DEMO-0001` tune/disconnect/reconnect/tune. The test instance was
stopped afterward.
- Engine boundary, generated touchpoint manifest, test registration,
frozen CI gate and whitespace checks pass. Existing engine-boundary
legacy warnings remain unchanged.
- Git merge-tree confirms clean integration with refreshed upstream/main
`df0520ad7725153a2802b1643d8c84edb01b450a`, preserving both subsequent
upstream changes. Runtime evidence is for the reviewed branch on the
#5458 merge base, not a rebuilt synthetic merge.

The new CTest does **not** bind sockets or simulate third-party
firmware. Its `LocalControlServer` objects are inert binding-policy
checks. The existing local-server test and manual daemon smoke bind our
actual current-user local service; the server test required an
unsandboxed run to bind on macOS. The daemon used `--discover-sim`,
never `--discover-local`. Desktop startup retains its normal discovery
listeners, but only `DEMO-0001` was selected: no hardware connection or
TX testing occurred. RTL-SDR is not available in this local build;
hardware convergence and Windows/Linux runtime validation remain
untested. Per-PR CI is pending.

## Review

The functional review found and fixed a missing invalidation edge: a
local transmitting/MOX/TUNE active interval must discard previously
confirmed idle, and a subsequent local false edge must not restore it.
The regression failed before the fix and passes afterward.

The independent architecture/control-path reviews and the security diff
review found no remaining actionable findings. Security scan
`eaf6de71-33fa-4562-9d92-597bb4b13a7e` reviewed the exact pre-commit
working-tree patch on `981b7fe6593699ac8a5317c4cbe513ca1f56b6d7` and
recorded zero findings. **Report artifact caveat:** its exporter
retained two already-completed checkpoint items as deferred, so the
sealed report labels coverage partial. The preserved final submitted
checkpoint explicitly has complete coverage, no deferred items, and all
five surfaces reviewed; the sealed report also includes those five
completed surfaces. The sealed artifacts were preserved unchanged, and
this is not presented as a clean automated coverage verdict. The
hardware/platform limitations above still apply.

## Checklist

- [x] Signed commit `abd7d72f29faa567cc17255e7362ebe74769f214`; local
SSH signature verification passed.
- [x] No new settings keys or dependencies; no `CHANGELOG.md` change.
- [x] Clean-room typed backend integration; Flex range uncertainty is
left unavailable rather than using commented-out FlexLib limits.
- [x] Protocol/resource/capability documentation and test registration
updated; no frozen PR test-gate expansion.

---------

Co-authored-by: Jeremy [KK7GWY] <kk7gwy@aethersdr.com>
Co-authored-by: Claude Fable 5.1 <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.

RFC: AetherD — headless engine + UI decoupling (libaethercore, versioned protocol, thin clients)

3 participants