Skip to content

feat(radio): a PTT guard releases only the key it took (#1263) - #1289

Merged
dc0sk merged 1 commit into
mainfrom
feat/1263-ptt-ownership-token
Sep 7, 2026
Merged

feat(radio): a PTT guard releases only the key it took (#1263)#1289
dc0sk merged 1 commit into
mainfrom
feat/1263-ptt-ownership-token

Conversation

@dc0sk

@dc0sk dc0sk commented Sep 7, 2026

Copy link
Copy Markdown
Owner

Part 1 of #1263 — the ownership token. The policy (refuse / defer / force) is PR-2.

The defect

unkey() released the hardware and take()d the deadline regardless of who keyed. So a guard
that outlived its key — a hung transmit the watchdog force-released — dropped the transmitter of
whoever keyed next, and that holder's own release then returned NotKeyed. This is present under
every nesting policy, which is why the token lands first and the policy second.

The owner is the guard

Settled across two review passes (docs/dev/reviews/2026-09-06-1263-ptt-nesting.md).

  • Not a thread id. ARDOP's PTT TRUE and PTT FALSE each run on a different spawn_blocking
    pool thread, so an operator could not release their own key; and the daemon's task migrates across
    tokio workers between awaits, so one logical owner has many thread ids.
  • Not a caller-supplied enum. That is a label any site can claim — the same "only as good as
    every call site" hole my first proposal had, in a new shape.
  • The guard. Holding a live guard is ownership and the generation counter is the identity, so
    "refuse a different owner" later collapses to "refuse any key while a live guard exists", and "the
    same owner emitting under its own hold" means emit without taking a second guard.

My first proposal was rejected, and both reasons matter

  1. Wrong primitive. I proposed that a nested keyed() refuse. keyed() is a thin wrapper; the
    manual paths — ARDOP's key(None), the daemon's hw_assert + arm — never pass through it, so
    the rule would have excluded every path it was written for.
  2. It would have caused a §97.119 miss. Verified in code rather than argued:
    id_timer.mark_identified(now_ms) is called "Advance regardless of PTT success" and sets
    last_id_ms = now and tx_since_id = false. A refused station ID skips a whole interval. A
    fix for a UX defect would have created a regulatory one.

The review's worst case is avoided by construction

F1 was a stale owner record deferring the ID forever while the rig sits unkeyed. There is
deliberately no separate owner field: ownership is "my generation is live and
asserted_at.is_some()", so every existing release path already ends it. The watchdog additionally
bumps the generation, which states the invariant positively rather than relying on it.

The watchdog stays generation-blind, and that is the safety property: a guard sets
released = true and never retries, so on UnkeyOutcome::Failed only the 100 ms watchdog can clear
a stuck rig. unkey_owned checks and acts under one lock acquisition — a check-then-release
across two would race the one thread that can preempt a live key at any instant.

Tests

  • a guard whose key the watchdog ended does not release a later key — asserted on the hardware
    release count
    , not just is_keyed, so a no-op that still touched the rig would fail;
  • a live guard still releases (the control — without it the first test would pass in a build where
    guards released nothing);
  • is_live requires an armed deadline, not merely a matching generation (F1's shape);
  • release() is scoped exactly as Drop is.

Sabotage: restoring the unconditional release fails two of the four with a stale guard released a key it did not take, while both controls keep passing — so the failure is attributable.
Restored by sha256sum.

GATE: PASS c62ac3c7f7c82fa970c39338c379f2ec54d62a3a clean 20260906T200317Z
        suites=325 tests_passed=2468 tests_failed=0

Deferred to PR-2, deliberately

AlreadyKeyed, the three-way KeyedTxError, ID-outranks-manual at the acquisition point, and
deferral scoped to AlreadyKeyed only — the "advance regardless" comment is load-bearing, and
blanket deferral would key/attempt at 20 Hz on a faulted rig's serial line for 180 s. Plus the manual
guard slots, PttRelease force semantics, and the panel.

Both before #1260, which is open and would otherwise be built on the key/unkey surface PR-2
removes.

Review: docs/dev/reviews/2026-09-06-1263-ptt-nesting.md

🤖 Generated with Claude Code

https://claude.ai/code/session_0188ATCj6DZ9aRVQ2vSirua6

`unkey()` released the hardware and took the deadline regardless of who keyed, so a guard that
outlived its key dropped the transmitter of whoever keyed next, and that holder's own release became
a NotKeyed no-op. Present under every nesting policy, so the token comes first and the policy second.

The owner is the GUARD, settled across two review passes. Not a thread id: ARDOP's PTT TRUE and PTT
FALSE run on different spawn_blocking pool threads, so an operator could not release their own key,
and the daemon's task migrates across tokio workers between awaits. Not a caller-supplied enum: that
is a label any site can claim, the same "only as good as every call site" hole the first proposal
had. Holding a live guard IS ownership; the generation counter IS the identity.

My first proposal was rejected. I proposed that a nested keyed() refuse, which (1) names a thin
wrapper the manual paths never pass through, so the rule would have excluded every path it was
written for, and (2) would have caused a §97.119 miss — verified in code, not argued:
mark_identified is called "Advance regardless of PTT success" and clears both last_id_ms and
tx_since_id, so a refused station ID skips a whole interval.

The review's worst case — a stale owner record deferring the ID forever with the rig unkeyed — is
avoided by construction: there is deliberately no separate owner field. Ownership is "my generation
is live AND asserted_at.is_some()", so every existing release path already ends it. The watchdog
also bumps the generation, stating the invariant rather than relying on it.

The watchdog stays generation-blind, which is the safety property: a guard sets released = true and
never retries, so on UnkeyOutcome::Failed only the 100 ms watchdog can clear a stuck rig.
unkey_owned checks and acts under ONE lock acquisition — check-then-release across two would race
the one thread that can preempt a live key at any instant.

Results: 4/4 new tests; two FAIL against the restored unconditional release with "a stale guard
released a key it did not take", while both controls keep passing. Restored by sha256sum. Radio
45 + 11, daemon lib 136, ARDOP 7 + 24, clippy clean.

Deferred to PR-2: AlreadyKeyed, the three-way KeyedTxError, ID-outranks-manual, deferral scoped to
AlreadyKeyed only (blanket deferral would hammer a faulted rig at 20 Hz for 180 s — "advance
regardless" is load-bearing), manual guard slots, PttRelease force semantics, the panel.

Implements: REQ-PTT-01
Review: docs/dev/reviews/2026-09-06-1263-ptt-nesting.md

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0188ATCj6DZ9aRVQ2vSirua6
@dc0sk
dc0sk merged commit 5d444be into main Sep 7, 2026
7 of 8 checks passed
@dc0sk
dc0sk deleted the feat/1263-ptt-ownership-token branch September 7, 2026 05:44
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.

1 participant