feat(aetherd): establish per-session authorization boundary - #5434
Conversation
There was a problem hiding this comment.
🔵 Needs a closer look
It changes a security-sensitive authorization boundary and transport revocation/lifetime behavior that merits final human review despite strong targeted test coverage.
Pull request overview
This PR advances the aetherd Stage-3 control protocol by introducing an explicit per-session authorization context that is supplied by trusted transport/embedding code (not by any hello fields), and by making revocation terminal with synchronous local-transport output purge behavior.
Changes:
- Add
SessionAuthorizationtoControlSession, defaulting sessions to unauthenticated (failhelloclosed) unless a trusted transport explicitly authorizes them. - Enforce
observegrant checks consistently across service dispatch, direct subscribe/unsubscribe entry points, and event delivery; implement terminal, idempotent revocation that clears subscriptions/queued events and aborts transport output. - Add a new socket-free authorization/revocation regression test target and update protocol/catalogue + contributor documentation.
File summaries
| File | Description |
|---|---|
| tests/tests.cmake | Registers new control_authorization_test target for socket-free auth/revocation coverage. |
| tests/control_resource_service_test.cpp | Updates tests to use new ControlSession API (sessionId(), isNegotiated()) and explicit observer authorization. |
| tests/control_authorization_test.cpp | Adds comprehensive socket-free tests for auth boundary, grant enforcement, revocation semantics, and output binding lifetime behavior. |
| src/core/control/LocalControlServer.h | Removes now-unneeded per-socket drain helper declaration after moving output wiring into ControlSession. |
| src/core/control/LocalControlServer.cpp | Constructs observer-authorized sessions for the current-user endpoint and binds transport output via ControlSession::bindOutputTransport(). |
| src/core/control/ControlSession.h | Introduces SessionAuthorization, negotiation/auth/observe helpers, revocation, and transport output binding API. |
| src/core/control/ControlSession.cpp | Implements authorization state, negotiation completion, observation gating, terminal revocation, and shared transport output binding logic. |
| src/core/control/ControlService.h | Documents that authorization is supplied by trusted transport context and cannot be granted by hello. |
| src/core/control/ControlService.cpp | Requires authenticated context for hello, filters capabilities by grant, and enforces observe permission before resource processing. |
| docs/aetherd-control-resource-v1-catalogue.md | Updates v1 catalogue to reflect observe-grant requirements and terminal revocation semantics. |
| docs/aetherd-control-protocol-v1-design.md | Documents the new immutable per-session authorization model and revocation lifecycle behavior. |
| AGENTS.md | Updates project guide status section to reflect the new auth boundary and revocation behavior. |
Review details
- Files reviewed: 12/12 changed files
- Comments generated: 2
- 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
No fixes/closes #NNNN in the body, so there is no linked issue to map requirements against — I reviewed against the PR's own stated intent and against the two design documents it amends. This is a slice of the already-ratified aetherd control-protocol staging (the design doc and catalogue exist on main; #5391 landed the observe-only resource layer immediately before this), and it is narrowing an existing surface rather than adding a user-visible feature, so I do not read GOVERNANCE.md as requiring a fresh RFC. The stated intent — make authorization an explicit, transport-supplied property of ControlSession instead of an implicit consequence of having negotiated — is fully delivered by the diff, and the docs it edits are accurate to the code (I checked each claim; the one imprecision is a nit below).
Scope
| File / group | What it changes | Claimed? | Verdict |
|---|---|---|---|
src/core/control/ControlSession.{h,cpp} |
SessionAuthorization enum, isAuthenticated/canObserve/isRevoked, sessionId+negotiated public fields → accessors, revokeAuthorization(), bindOutputTransport(), observe guards in subscribe/unsubscribe/takePendingFrames/observes |
Yes | In scope |
src/core/control/ControlService.{h,cpp} |
Revoked/unauthenticated guards, per-method observationError(), grant-filtered capabilities() |
Yes | In scope |
src/core/control/LocalControlServer.{h,cpp} |
Constructs sessions as Observer; drainSessionOutput() deleted in favour of bindOutputTransport() |
Partly — the drain rewiring is a refactor, not an authorization change | In scope, but see note |
AGENTS.md, docs/aetherd-control-protocol-v1-design.md, docs/aetherd-control-resource-v1-catalogue.md |
Prose matching the new behaviour | Yes | In scope |
tests/control_authorization_test.cpp, tests/tests.cmake |
New socket-free target | Yes | In scope |
tests/control_resource_service_test.cpp |
Mechanical: pass Observer, sessionId → sessionId() |
Yes | In scope |
The drainSessionOutput → bindOutputTransport move is the only row that isn't literally "authorization", but it is load-bearing for it: revocation has to be able to purge the transport's own unwritten bytes, which the old two-connect wiring in acceptConnections() had no hook for. I would not ask to unbundle it. No CHANGELOG.md touch (correct). No new settings keys, no new wire method, no capability-map change (the seven advertised capabilities are byte-identical for an authorized observer — verified against the diff and the existing socket test's assertion list).
Socket test disclosure: this PR adds and modifies no socket-owning test. tests/control_authorization_test.cpp is socket-free by construction — its BufferedTransport is an injected QObject sink, not a synthetic radio/firmware peer, and tests.cmake compiles only ControlProtocolCodec/ControlResourceStore/ControlService/ControlSession with Qt6::Core. The existing socket-owning local_control_server_test (our own server, legitimate) is untouched but is behaviourally affected by the drain rewrite; see below.
Blockers
None.
Nits (non-blocking)
- Hello validates params and versions before it checks authorization (
ControlService.cpp:141, inline). The resource methods deliberately putobservationError()ahead ofonlyKeys()— the new test even comments "authorization precedes schema processing or resource lookup, so denied callers learn no resource/subscription state." Thehellopath does the opposite, so an unauthenticated caller can distinguishrequest.invalid_paramsfromprotocol.version_unsupported(which echoessupported: [1]) fromauth.required. What leaks is negotiation metadata only, not resource state, so this is a consistency/doc nit rather than a hole — but the design doc's flat "The default context is unauthenticated:helloreturnsauth.required" is true only for a well-formed v1 hello. Either move the check up or soften the sentence. bindOutputTransportsays "Bind once" but nothing enforces it (ControlSession.h:55, inline). A second call duplicates all three connections;abortTransportwould then fire twice per revocation/overflow. Contract is honoured by the single production caller, so this is a guard-rail request, not a defect.revokeAuthorization()andAuthenticatedWithoutGrantshave no production caller. The body discloses this ("these are the lifecycle hooks for subsequent authenticated non-TX control"), and I agree the hooks are cheaper to land with their tests than to bolt on later — recording it so the maintainer can price it deliberately: roughly half the new code and five of six new test functions exercise a path nothing in the shipping app reaches yet.- The revoked-session guard sits ahead of parsing, so its error reply carries an empty
idrather than echoing the client's. Terminal path, so nothing depends on correlating it. control_authorization_testis correctly not added to anyctest -Rgate inci.yml(frozen list, #5405), so it runs on full-suite/sanitizers only. Green checks onf79ef21therefore do not mean this test ran.
What I tried to break
- Can a client grant itself anything over the wire? No.
validateHelloParamsrejectsauthoutright withauth.invalidand rejectsgrantsas an unknown key, both beforecompleteNegotiation();capabilities()readssession.canObserve(), which is a function of the constructor-supplied enum and can never be reached from request JSON. The new test's bearer-token case also asserts the token string never appears in the reply — I confirmed the error detail carries only the field name. - Can a stolen session id transfer permissions? No —
request.sessionId != session->sessionId()is checked per-connection before method dispatch, and the id is a freshQUuidminted only insidecompleteNegotiation(). - Does anything still read the removed public
negotiated/sessionIdfields? Grepped the whole head tree: no residual uses, and noControlSessionis constructed anywhere outsideLocalControlServer.cppand the two test files — so theUnauthenticateddefault argument cannot silently mis-authorize a forgotten call site; it fails closed. - Does the queue leak when
takePendingFrames()early-returns{}without clearing? I traced every enqueue path:observes()gates both store slots and itself returns false when!canObserve(), andrequireResync()is only reachable fromenqueueCoalesced. So a non-emptym_pendingimpliescanObserve()was true, and the only transition to false isrevokeAuthorization(), which clears the queue andm_pendingBytesfirst. No orphaned bytes, no stuckm_pendingBytesagainst the overflow bound. - Lifetime during the drain batch. The
outputReadylambda re-checkssession/transport(bothQPointer) inside the loop, so destroying either endpoint or revoking from withinwriteFramestops the batch — the fourStop::cases assert exactly that. TheauthorizationRevokedconnection is deliberately direct whileoutputOverflowstays queued; I walked the production consequence —socket->abort()emitsdisconnectedsynchronously, which only ever defersdropClientviasingleShot(0), so theControlSessioncannot be destroyed underneath the emit that is still on the stack. That deferral already existed for the same reason and is untouched. - Is the rewritten drain path still covered end-to-end? Yes, and by an existing registered test rather than only the new socket-free one:
local_control_server_testsubscribes over the realQLocalSocketand asserts aresource.changedframe arrives afterresourceStore().upsert()(lines ~214–231), and drives the overflow-abort path atmaxQueuedOutputBytes = 1twice. That was my main worry about deletingdrainSessionOutput— the old version'sm_clients.find()staleness check is now supplied by binding the socket as the connection context object, which is equivalent and strictly tighter. - Would the new tests pass with the guard broken? Emptying
observationError()out ofsubscribe()fails thedirectDeniedassertion; makingcanObserve()returnisAuthenticated()fails the wholenoGrantsblock including the empty-grants capability comparison; makingtakePendingFrames()ignore revocation failsemptyAtRevocationand thedelivered.isEmpty()check. They are not self-confirming. - Every prose claim in the three edited docs was checked against the code — "grant_denied before checking parameters", "revocation discards pending observations", "idempotent and terminal even before negotiation or while a resync notice is pending", "aborts synchronously to discard unwritten output" (Qt's
QLocalSocket::abort()does discard the write buffer). All hold; only thehello-ordering sentence above is loose.
All of the above is reasoned from reading the head checkout at /tmp/aetherclaude/pr-5434; I am headless and did not build, run ctest, or drive a socket. No CodeGuard or Copilot findings were supplied, which means either a clean scan or no scan — I am not reporting it as clean.
Recommendation
Approve with nits. This is the rare security-boundary change that actually fails closed at every seam I could find: the default enum value is Unauthenticated, the grant is supplied by the constructor rather than derivable from any wire field, both the service and the direct ControlSession entry points enforce it, and revocation clears state before it notifies anyone. The three nits are consistency and guard-rail items, none of which change behaviour for a real client. Next step is a maintainer call on whether landing revokeAuthorization() ahead of its production caller is the shape they want; if yes, this is mergeable as-is, and I would only ask for the one-sentence doc softening on the hello ordering.
Thanks for the doc updates landing in the same commit as the code — the catalogue and design-doc paragraphs made the intended boundary easy to test against rather than infer.
🤖 aethersdr-agent · cost: $5.9328 · model: claude-opus-5
ten9876
left a comment
There was a problem hiding this comment.
Verdict: no blockers. Comment-only.
Issue fit. No linked issue; this is a self-declared sub-slice of #3849, following #5391. GOVERNANCE.md exempts "bug fixes with a clear root cause" from the RFC requirement, and the architectural direction here was already ratified by the aetherd Stage-3 design doc that this PR extends, so the process shape is fine. The PR body's own scoping — an authorization boundary and a revocation lifecycle hook, with no wire revocation method, no control/TX grant, no settings, no dependencies — matches the diff exactly.
Scope
| File / group | Claimed in body? | Verdict |
|---|---|---|
ControlSession.{h,cpp} — SessionAuthorization, negotiation/observe accessors, revokeAuthorization(), bindOutputTransport() |
Yes | In scope |
ControlService.{h,cpp} — auth required for hello, grant-filtered capabilities, observe check on the three resource methods |
Yes | In scope |
LocalControlServer.{h,cpp} — Observer at construction, output wiring moved into the session, drainSessionOutput deleted |
Yes | In scope |
tests/control_authorization_test.cpp + tests/tests.cmake |
Yes | In scope; socket-free, registered, enters the unfiltered suites |
tests/control_resource_service_test.cpp |
Yes | In scope — purely mechanical (sessionId → sessionId(), negotiated → isNegotiated(), explicit Observer); I diffed every changed line and no assertion was weakened |
docs/aetherd-control-protocol-v1-design.md, docs/aetherd-control-resource-v1-catalogue.md, AGENTS.md |
Yes | In scope |
Single commit, single author date, nothing bundled. No CHANGELOG.md entry (correct — it is release-prep only). No settings keys, no credentials, no capability-map changes, no CI edits. Everything in the diff is explained by the stated change. The body's checklist holds up on every item I could check.
One deleted-behavior check: LocalControlServer::drainSessionOutput() is removed, and with it the m_clients.find(socket) guard it used to do before writing. That is safe — sendFrame() already rejects a null or UnconnectedState socket, the new connection's context object is the socket rather than the server, and dropClient is still deferred through QTimer::singleShot(0, …). Strictly narrower lifetime than before, not wider.
Blockers
None.
Nits (all non-blocking)
- Two of the new guards are not pinned by any test — see the inline comment on
ControlSession.cpp. I ran seven mutations; five were caught, these two were not. bindOutputTransport's same-thread invariant is debug-only — see inline.revokeAuthorization()andAuthenticatedWithoutGrantshave no production caller — see inline. Disclosed in the body as a lifecycle hook, so this is a maintainer's call on landing surface ahead of its consumer, not a defect.AGENTS.mdphrasing — see inline.
What I tried to break (and what held)
Built the PR head clean (RelWithDebInfo, Ninja, Linux/Qt6) and ran the tests rather than reading them.
- The tests are real. Seven deliberate mutations of the production source, rebuilt and re-run each time. Caught: dropping the
isAuthenticated()check inhello; makingrevokeAuthorization()a no-op; deleting theobservationError()check fromresource.get; changing theauthorizationRevoked→ abort connection toQt::QueuedConnection; deleting the directsubscribe()/unsubscribe()guards. Restored source passes. The queued-connection mutation being caught is the interesting one — the synchronous-purge property really is asserted, not just asserted-about. - "No behavior change for the existing local endpoint" — verified end-to-end, not argued. Built
aetherd, ran it with an isolatedXDG_RUNTIME_DIR, and drove the realQLocalServerfrom a Python client.hellostill returnsgrants: ["observe"]and exactly the seven capabilities (server.read,radioSession.read,slice.read,panadapter.read,resource.get,resource.subscribe,resource.unsubscribe);resource.getandresource.subscribeonserverboth return the live snapshot at revision 3. The unchangedlocal_control_server_testalso passes on the head. - Tried to smuggle authorization through
helloon fresh connections.{"auth":{"scheme":"bearer","token":"SECRET123"}}→auth.invalid("authentication is not accepted by this local endpoint"), and I byte-checked the response: the token is not echoed back.{"grants":["transmit","observe"]}→request.invalid_paramswithfield: "grants". Neither negotiates. - Looked for a use-after-free in the new synchronous abort path.
authorizationRevoked→abortTransportis deliberately a direct connection, and inLocalControlServerthat runssocket->abort(), which can emitdisconnectedsynchronously →dropClient→ destroy theClientand itsControlSessionwhilerevokeAuthorization()is still on the stack. It holds: thedisconnectedhandler defersdropClientbehindQTimer::singleShot(0, …). The test'sStop::DestroySession/Stop::DestroyTransportcases cover the analogous teardown-inside-the-write-callback shapes, and theQPointerre-check inside the drain loop covers the rest. - Looked for a queue-accounting leak from
takePendingFrames()early-returning without clearingm_pending/m_pendingBytes. Not reachable: the only enqueue path isobserves()→canObserve(),requireResync()is only reachable fromenqueueCoalesced(),m_authorizationisconst,m_sessionIdis write-once, and revocation clears the queue. So it is robustness, not a bug — but it is the same code that the mutation run showed nothing pins. - Ran the repo gates:
check_engine_boundary,check_test_registration,check_ci_test_gate,check_network_timeoutsall pass, andgen_touchpoint_manifest.pyregenerates to a clean tree. - Did not drive the GUI.
LocalControlServeris instantiated only bysrc/aetherd/main.cpp:29; nothing in theAetherSDRbinary constructs aControlSession. The automation bridge cannot reach this change, so theaetherdsocket session above is the equivalent end-to-end proof. No radio was connected and no TX path exists in this surface.
Test-boundary preflight: the new control_authorization_test is socket-free — the bind() in BufferedTransport is a local helper name, not a socket bind, and the target compiles only the codec/store/service/session sources. No new socket-owning test is introduced; local_control_server_test is unchanged. Its tests.cmake block correctly says so.
Both paragraphs edited in f7474b2 were left mid-reflow: AGENTS.md broke after "Credential" at 31 columns, and the protocol design's authorization paragraph ran to 99 columns on the sentence about invalid envelopes. Rewrap both to the ~78-column fill the surrounding prose uses. No wording changes. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
ten9876
left a comment
There was a problem hiding this comment.
Approved.
Every blocker-shaped concern raised on this PR — mine, Copilot's, and the agent's — is addressed in f7474b2c, and I re-ran the adversarial pass against that commit rather than taking the replies on faith. I pushed one commit of my own (b6c45b80, docs-only) for the last nit.
What I re-verified on f7474b2c
Mutation battery — the fixes are pinned, not just present. Rebuilt and re-ran control_authorization_test against five deliberate breakages of the production source:
N1 single-bind guard removed : CAUGHT
N2 caller/endpoint affinity checks removed : CAUGHT
N3 hello auth check moved back after params : CAUGHT
N4 revoke abort forced Qt::QueuedConnection : CAUGHT
N5 denied-path queue clear removed : survived (disclosed in the body)
Restored source passes. N5 is the one guard that cannot be pinned without a test backdoor into private state, because the state it defends is unreachable; declining to add that backdoor was the right call, and the new comment carries the invariant instead. Combined with the earlier run against f79ef219, ten of twelve mutations are caught.
The hello reorder does not regress the shipping endpoint. This was the change I most expected to have a tail, since it moves an authorization check across version negotiation. Built aetherd, ran it against an isolated XDG_RUNTIME_DIR, drove the production QLocalServer from a raw socket client:
hello {"versions":[1]} -> grants ['observe'], 7 capabilities
resource.get server -> health "ok"
resource.subscribe server -> sub-1
hello {"versions":[2]} -> protocol.version_unsupported
hello {"versions":[]} -> request.invalid_params
hello {...,"auth":{"bearer","SECRET"}} -> auth.invalid (token not echoed)
hello {...,"grants":["transmit"]} -> request.invalid_params
An Observer session still gets real version negotiation, so the new early auth.required is reachable only for Unauthenticated contexts — which the current-user transport never constructs. No behavior change for existing clients, confirmed against the daemon rather than argued from the diff.
The binding contract holds where it matters. testOutputBindingContract covers the case I would have missed: a caller on a foreign thread whose endpoint affinity nonetheless matches. Removing either half of that check fails the test. Retaining AutoConnection plus a release-build guard is the correct resolution of the Copilot suggestion — forcing Qt::DirectConnection would have traded a lost guarantee for a cross-thread socket->abort(), which is worse.
Everything else: all four control tests pass (control_authorization_test, control_resource_service_test, control_protocol_codec_test, and the unchanged socket-binding local_control_server_test). check_engine_boundary, check_test_registration, check_ci_test_gate, check_network_timeouts, check_a11y all pass; gen_touchpoint_manifest.py regenerates to a clean tree; no trailing whitespace in the diff. The new logging category follows house convention (lcControlSession / "aether.control.session").
What I pushed
b6c45b80 — docs only, no wording changes. Both paragraphs edited in f7474b2c were left mid-reflow: AGENTS.md broke after "Credential" at 31 columns, and the protocol design's authorization paragraph ran to 99 columns on the sentence about invalid envelopes. Rewrapped both to the ~78-column fill the surrounding prose uses.
Scope, one more time
Still clean across both commits. Two commits, one author, no bundled work, no CHANGELOG.md entry, no settings keys, no credentials, no capability-map changes, no CI edits, no new socket-owning test. The AuthenticatedWithoutGrants / revokeAuthorization() staging question was the one thing needing a ruling and I have made it on the thread: accepted, on the strength of the fail-closed default and the explicit staged-status wording in AGENTS.md and the design doc.
Nice work on the turnaround — the replies were specific about which mutation proves which guard, which made this pass fast to verify. Enabling auto-merge.
## Summary Refs #3849. Continue the approved Stage 3 sequence after #5434 with a bounded, observe-only headless radio catalogue. This is one incremental RFC slice and does not close #3849. - Add a QtCore normalized discovery interface, with native Flex/HL2/ANAN and optional RTL-SDR adapters below the vendor boundary. Desktop discovery/autoconnect is unchanged. - Publish singleton `radioCatalogue` through existing `resource.get`, `resource.subscribe` and `resource.unsubscribe`, with observe-gated `radioCatalogue.read` capability. Entries are validated, sorted and bounded to 64; IDs are family+serial-based and survive display/address updates; duplicates do not churn revisions; stop clears entries and rejects late callbacks. - Keep daemon startup passive. `--discover-local` opts into LAN/USB discovery; `--discover-sim` publishes demo metadata only. Neither option connects a radio. Source/running metadata describes configuration/lifecycle, not successful hardware discovery, and `limited` is sticky for the catalogue lifetime. - Fix the pre-publication review finding: the daemon loads `AppSettings` only for native discovery, before model/discovery consumers, so saved HL2/ANAN Identity nicknames remain available. Passive/simulator-only startup does not load the store; normal migration, recovery and read-only safeguards remain in force. No remote listener, credential provisioning, radio commands, receive-control methods, TX surface, meters or desktop adapter are added. Icom manual setup, SmartLink and external receiver directories remain excluded. New public CLI/resource contracts need maintainer review within the accepted RFC. Coordination: #3849 remains assigned to @ten9876 under the previously confirmed arrangement; the implementation plan is [recorded on the issue](#3849 (comment)). Implementation and local review were Codex-assisted. ## Constitution principle honored **Principle VII — Validate All Inputs at Trust Boundaries:** bound and validate normalized identity, display and endpoint fields before publishing them; reject invalid UTF-16 and ambiguous identities instead of truncating them. Also preserve **Principle VI** (discovery cannot connect or key TX) and **Principle XI** (behavioral regression tests and mutation proof). ## Test plan - [x] macOS ARM64 desktop and daemon build passes using the dedicated ARM64 toolchain/build directory, with RADE enabled. Verified ARM64 host/system/executables, no RNNoise x86 sources and no QtWidgets dependency in `aetherd`. - [x] Nine focused registered CTests pass: codec, authorization, resource service, catalogue, production passive/sim source, daemon nickname startup, and existing nickname-roundtrip/locked-store/newer-schema settings safeguards. - [x] New `aetherd_discovery_startup_test` writes isolated Identity documents, then reads them in a fresh process through the real daemon startup helper and native nickname helpers. Native sources are never started. Passive and simulator-only startup are checked without opening the store. - [x] Mutation proof: omitting the settings load fails persisted nickname recovery; loading unconditionally fails passive-startup isolation. Earlier catalogue/source mutation checks also detect family identity collisions, relaxed capacity, post-stop callbacks, invalid UTF-16 and missing simulator publication. - [x] Mac headless smoke on the final code: two isolated daemon instances, passive then simulator-only; hello/capabilities/get/subscribe/unsubscribe pass, with 0/1 catalogue entries respectively, `DEMO-0001` for sim, observe-only grants and disconnected radio sessions. Both owned instances stopped. The running desktop app was untouched. - [x] Strict engine-boundary and test-registration checks, frozen CI-gate check, touchpoint-manifest freshness, and diff whitespace checks pass. - [x] Final-commit security diff review accounts for all 16 changed files, with no reportable vulnerabilities and no deferred source-review work. Runtime limits below remain explicit. - [ ] Existing tests pass in PR CI — awaiting remote jobs. - [ ] Real-radio/physical discovery verification — not performed. No LAN/USB scan, radio connection or TX was used in local verification. Windows/Linux and RTL-enabled runtime remain untested locally. All three new CTests are socket-free and unconditionally registered in `tests/tests.cmake`; none changes the frozen per-PR test gate. The only socket smoke targets our actual daemon server, is manual, and uses unique current-user local endpoints rather than a synthetic firmware peer. The GUI automation bridge is not a proof path for this slice because the desktop does not consume this headless catalogue yet. ## Checklist - [x] Commits are signed (`docs/COMMIT-SIGNING.md`). - [x] No new flat-key `AppSettings` calls; existing scoped Identity documents are read. - [x] Code is clean-room, not derived from a proprietary binary. - [x] All meter UI uses `MeterSmoother` — no meter/UI changes. - [x] Protocol/resource and milestone documentation updated; no `CHANGELOG.md` changes. - [x] Security-sensitive changes reference a GHSA if applicable — no GHSA applies; no vulnerability was found in the final diff review. --------- Co-authored-by: Jeremy [KK7GWY] <kk7gwy@aethersdr.com> Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
Summary
Continues #3849 after merged #5391. This is the first, deliberately small sub-PR of authenticated non-TX control: establish an explicit per-session authorization boundary before any receive-control handler is exposed.
This does not complete the authentication/non-TX milestone. Credential verification/provisioning, typed receive intents, discovery/connect, remote transport, meters, desktop migration and Stage 4 TX arbitration remain separate work. There are no new control/TX grants, mutation methods, persisted settings, or dependencies. Revocation is an owning-thread lifecycle hook, not a new wire/admin operation; already-delivered bytes cannot be recalled.
Constitution principle honored
Principle VII — authorize at the protocol boundary and fail closed before resource processing. Principle VI — no transmit method or grant exists. Principle XI — socket-free behavioral regression tests are mutation-checked rather than relying on source-text assertions.
Test plan
RelWithDebInfobuild using the required local toolchain and RADE enabled:AetherSDR,aetherd, and the four focused test targets. Verify host/system ARM64, no RNNoise x86 sources, and ARM64 executables.control_protocol_codec_test,control_authorization_test, andcontrol_resource_service_test: socket-free checks for negotiation, grant denial, session isolation, queued/new event revocation, revocation before hello and during resync, and preservation of existing resource behavior.local_control_server_test: exercises our ownQLocalServercurrent-user transport; no fake radio. Requires a permitted local socket bind; passed outside the Codex sandbox. No new socket-owning test is introduced.resource.getgrant enforcement each make the new test fail. Restored production source passes.tests/tests.cmakeand enters the normal unfiltered main/weekly suites. The frozen per-PR test allow-list is unchanged.Review follow-up (
f7474b2c)AutoConnection; do not force a cross-thread socket abort.observes()andtakePendingFrames()permission checks as defence-in-depth guards not independently mutation-pinned in any currently reachable state; defensively clear pending byte accounting on denied drains.Checklist
AppSettingscalls (no settings changes).CHANGELOG.mdentry.f79ef219).