Add opt-in aetherd local connection control. Principle VII. - #5458
Conversation
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.
…3-connection-control
There was a problem hiding this comment.
🔵 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
SessionAuthorizationsupport for control vs observe (independent grants) plus a per-session post-handshake request budget (100/s, burst 200; terminal on exhaustion). - Introduces a trusted
RadioConnectionTargetseam and implements typedradio.connect/radio.disconnectdispatch inControlService, gated behind control authorization and catalogue revision/entry checks. - Wires
aetherd --allow-local-controlto install the model-backed connection target, expose lifecycle observation (connectionControl) onradioSession, 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.
There was a problem hiding this comment.
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.cpp — per-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 getstransport.limit_exceededand a terminal close —m_requestLimitExceededis 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. errorCodeis never cleared on return toidle. (inline:ModelRadioConnectionTarget.cpp:22) After a timeout or failure the target settles toIdlebutm_errorkeeps its value until the nextconnectRadio()callsm_error.clear(). Clients observing{"state":"idle","errorCode":"engine.timeout"}can read a stale error as current. Either clear it insetState(State::Idle)or say in the doc thaterrorCodeis last error, not current.- A failed connect that emits only
connectionStateChanged(false)sits inconnectingfor the full 30 s. The!connected && m_state == Connectingbranch is intentionally a no-op (family-replacement filter, and the comment explains why).RadioModel::onConnectionErroris 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 unlessm_localAuthorization == ObserverController, butLocalControlServer(parent, limits, &target, /*allowLocalControl=*/false)installs a target while every local session is Observer-only. It is inert (handleConnectionrejects on!canControl(),capabilities()skips the branch), so this is consistency not exposure — but the asymmetry invites a future caller to get it wrong. radioSessionis length-bounded byProtocolLimits::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.cpphas no automated coverage at all. 163 lines containing the whole risky part — 30 s timer, two deferredsingleShothops, the settlement gate, legacy-reconnect cancellation, the family-swap filter — andcontrol_connection_testexercises a stubTargetinstead. I do not think this is a blocker:RadioModelis 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 inControlService.cpp:170-192passescloseAfterWrite = 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:elapsedis floored at 0,m_lastRequestTimeusesstd::maxso a non-monotonic clock cannot mint tokens, and the cap is applied to the sum not the increment. The test'snow += 10s → 200 succeed → 201st failssequence would fail if thestd::min(kRequestBurst, …)cap were dropped. - Startup ordering / bind race.
listen()runs beforemakeDiscoverySource()and beforeRadioSessionconstruction, andbindConnectionTarget()requiresm_clients.empty()while the server is already listening. A client cannot slip in, becauseQLocalServer::newConnectionis only delivered from the event loop andapp.exec()has not been reached. The extendedaetherd_discovery_startup_testasserts the settings dir is never created on a failed listen, for both flag states. - Target destruction mid-flight.
ControlServiceandRadioResourceAdapterboth holdQPointer. Qt zeroes theQWeakPointerstrongref at the top of~QObject, beforedestroyed()is emitted, so the adapter'sdestroyed → publishRadioSessionslot sees a null pointer and correctly dropsconnectionControlfrom the published value rather than reading a dangling target.m_targetBoundstays 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 duringConnecting, then have the attempt fail with no furtherconnectionStateChanged. Refuted —RadioModel::disconnectFromRadio()clearsm_connectAttemptActivesynchronously (RadioModel.cpp:3797) and stopsm_reconnectTimer, so the settlement turn's!isConnectAttemptInFlight()guard passes. The synchronous-backend case (SimBackendemittingdisconnected()inside the teardown call) is also covered:m_settlementQueuedde-dupes the re-entrantscheduleSettlement()and the surviving turn still sees!m_teardownQueued. - Client-supplied endpoint smuggling.
onlyKeys()restrictsradio.connectto exactly{radioSession, radioId, catalogueRevision}, and everyRadioInfofield is copied from the store's own catalogue entry viacatalogueRadio(). The test driveshost/port/auth/family/command/forceand getsrequest.invalid_paramsfor each. - Observer probing the target through error codes.
handleConnection()checkscanControl()first, so an observer getsauth.grant_deniedwithout learning whether a target exists, whether the catalogue is running, or what state the engine is in. Asserted inauthorization(). - Session-ID confusion. Verified the
request.sessionId != session->sessionId()check sits above theradio.connectbranch, 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 on37a7fdd3(build, check-macos, check-windows, Static checks, sanitizer-configure all success), but the frozen per-PR gate does not runcontrol_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
|
Cisco CodeGuard — static analysis of this PR (6 finding(s))
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
left a comment
There was a problem hiding this comment.
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)
errorCodeoutlives the failure it describes (inline,docs/aetherd-local-connection-control.md:76).m_erroris only cleared inconnectRadio(), so afterengine.failed/engine.timeoutthe target settles toidlestill 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.- A one-shot event-loop window between
listen()andbindConnectionTarget()(inline,src/aetherd/main.cpp:52). With--discover-local,AppSettings::load()→importLegacyXml()→persistVaultToKeychain()pumps a boundedQEventLoopon a first-run legacy import. A client that connects during it makesm_clientsnon-empty, the bind refuses, and the daemon exits 1. Fail-closed, first run only, narrow — worth a comment, not a code change. - 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 terminaltransport.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.connectandradio.disconnect→auth.grant_denied;radioSessionhas noconnectionControl. - Control daemon:
grants:["observe","control"],radio.connectadvertised. Three connect/disconnect cycles:{"connected":true,"connectionControl":{"state":"connected","errorCode":""}}, slice 0 readable, second connect →request.conflict, capabilities flip toradio.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;hostkey →request.invalid_params {"field":"host"};radio-2→resource.not_found; fractional revision →request.invalid_params. - Client A connected then closed its socket; client B saw
connected:trueand could disconnect it (doc's shared-authority and no-implicit-disconnect claims hold). - 205
capabilities.geton one socket: 201 ok, thentransport.limit_exceededand 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::maxon bothelapsedandm_lastRequestTime. - Target destroyed mid-flight:
QPointeris nulled beforedestroyed()fires, so the adapter republishes without the sub-object and the service fails closed;m_targetBoundstays latched. - Session-ID confusion and observer probing:
session.invalid/auth.grant_deniedland before any target or catalogue lookup. - Endpoint smuggling:
onlyKeysplus catalogue-onlyRadioInfoconstruction.
- 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
left a comment
There was a problem hiding this comment.
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.
## 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>
## 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>
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.
--allow-local-controlgrants every client admitted by the current-user endpoint observe + control; it is not per-application consent or remote authentication. Discovery remains independently opt-in.radio.connect/radio.disconnectfor the daemon-ownedradio-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.accepted:trueacknowledges intent, not RX readiness. Controllers share disconnect authority; client loss does not undo accepted intent.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
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.37a7fdd30f8d5565f6f5c2961066621a86bcb82aagainst merged catalogue/main9a90802a577eabba3bdb9127340a59e46272ac30: no remaining actionable findings, no deferred source-review work. Current-main17d61de5prospective merge is conflict-free; its later changes are Windows Store packaging/tests.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
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.