feat(voice): harden Android audio and channel joins

This commit is contained in:
Edison Jwa
2026-05-19 01:58:07 +09:00
parent 29a553d4e1
commit 8c253f1d4d
23 changed files with 2948 additions and 363 deletions
+79 -3
View File
@@ -3,7 +3,7 @@
**Document type:** SAD / Software Architecture Description
**Process alignment:** ASPICE SWE.2 Software Architectural Design
**Version:** 0.9.8
**Version:** 0.9.11
**Status:** Baseline Candidate
**Language:** English
**Product:** Chanora
@@ -1478,15 +1478,77 @@ A single SRS item may be intentionally allocated to more than one SAD item when
Pinned at SAD layer to remove a downstream ambiguity that would otherwise surface as an open question in the SAD-090 SDD unit: the 🟡 "within tolerance but trending" marker (SRS-218 clause 3, third element of the marker set) shall be defined as follows: a metric is rendered 🟡 if and only if (a) the metric is within the SRS-219 per-metric tolerance window relative to the merge-base baseline (i.e. it is not 🔴), AND (b) the absolute delta between the PR's current measurement and the **previous run of the SAD-090 workflow on the default branch** (i.e. the most recent default-branch CI bench result, not the baseline JSON itself) is greater than 50% of the SRS-219 tolerance window for that metric. Metrics whose SRS-219 tolerance is exactly zero (the heap-allocation-count metric, SRS-219 clause a) cannot be 🟡 because the 50% trigger is undefined when the window has zero width; for those metrics only 🟢 (zero allocations) and 🔴 (any non-zero allocation count) apply. Rationale for pinning at SAD rather than deferring to SDD: the question "is yellow relative to baseline or relative to previous-run-on-default" is an architectural choice about what state the workflow consumes (baseline JSON only, vs baseline JSON + a previous-runs cache), not an implementation detail; pinning it here fixes the workflow's required inputs at the architecture layer so SDD has a fully determined input set. Operationally, the "previous-run-on-default" datum may be sourced from the SAD-091 baseline (treating the committed baseline as the latest default-branch run, which is the simpler and recommended SDD realization) or from a separate run-history artifact; SDD selects which.
## 27. Updated SRS-to-SAD Coverage Matrix
## 27. Channel Join Pending-State Architecture
**SAD-092**: The software architecture shall assign authoritative current-channel membership ownership to Rust Core and the State Synchronization Engine. The authoritative current channel is the last channel membership state confirmed by Rust Core from the protocol path or from a reconciled server snapshot. Flutter state, Flutter feature screens, and bridge DTO consumers shall treat that value as read-only render state and shall not create, overwrite, or finalize authoritative membership locally.
- Status: Baseline Candidate
- Type: Software Architecture Item
- Stage: P0 / MVP
- Allocated to: Rust Core, State Sync
- Source SRS: SRS-023, SRS-054, SRS-056, SRS-057, SRS-058
- Related SysDes: SysDes-069
- Verification method: Architecture Review, Integration Test, System Test
**SAD-093**: The software architecture shall model a channel-join request as user intent crossing the bridge, not as an immediate local membership mutation. Flutter may expose the requested target channel as a non-authoritative pending target for visual feedback while continuing to render the authoritative current channel from SAD-092 until Rust Core/protocol confirmation changes that authoritative state. The bridge shall provide command and event contracts that distinguish join intent, pending target visibility, confirmed membership, and user-safe failure reporting; the bridge and Flutter layers shall not directly mutate authoritative channel membership.
- Status: Baseline Candidate
- Type: Software Architecture Item
- Stage: P0 / MVP
- Allocated to: Flutter UI, Flutter State, Bridge Facade, Rust Core
- Source SRS: SRS-023, SRS-049, SRS-054
- Related SysDes: SysDes-069
- Verification method: UI Test, Integration Test, System Test
**SAD-094**: The software architecture shall preserve the previous authoritative current channel on channel-join failure or timeout and shall clear the non-authoritative pending target through Rust Core / State Sync events. Join denial, network/protocol error, timeout, and cancellation-like outcomes shall be surfaced as user-safe errors without reclassifying the pending target as the current channel. Diagnostics shall record the join lifecycle at sanitized event level, including request, confirmation, failure, timeout, stale outcome, and reconciliation decisions, without leaking secrets or raw protocol payloads.
- Status: Baseline Candidate
- Type: Software Architecture Item
- Stage: P0 / MVP
- Allocated to: Rust Core, State Sync, Protocol Adapter, Bridge Facade, Diagnostics, Flutter State
- Source SRS: SRS-023, SRS-048, SRS-049, SRS-054, SRS-056, SRS-057, SRS-058
- Related SysDes: SysDes-069
- Verification method: Integration Test, System Test, Diagnostics Review
**SAD-095**: The downstream SDD shall define deterministic State Sync handling for channel-join edge cases while preserving the SAD-092 authoritative ownership boundary. Required SDD coverage includes duplicate join requests, a different target requested while a join is pending, leave/switch races, stale acknowledgements or failures for superseded requests, reconnect and fresh-snapshot reconciliation, and per-connection ordering boundaries. The architecture requires a single deterministic reducer/state-machine ownership point for those cases in Rust Core / State Sync; Flutter may render only the resulting authoritative and pending-view states.
- Status: Baseline Candidate
- Type: Software Architecture Item
- Stage: P0 / MVP
- Allocated to: State Sync, Rust Core, Protocol Adapter, Bridge Facade, Flutter State, Verification Support
- Source SRS: SRS-023, SRS-049, SRS-054, SRS-056, SRS-057, SRS-058
- Related SysDes: SysDes-069
- Verification method: Architecture Review, SDD Inspection, Unit Test, Integration Test, System Test
**SAD-096**: The software architecture shall expose an explicit Protocol Adapter command seam for leaving the current voice channel / moving the current client out of channel membership; downstream design may name the seam `leave_channel`, `move_out_of_channel`, or an equivalent verb, but it shall not require Rust Core, Bridge, Flutter, or the State Sync reducer to invent a synthetic channel id or rely on implicit `move_to_channel` behavior to realize `voice_leave()`. Rust Core shall invoke this Protocol Adapter seam only as a side effect of the State Sync reducer returning a leave action, and the Protocol Adapter shall report command lifecycle separately from authoritative membership. Authoritative current-channel membership remains owned by Rust Core / State Sync under SAD-092: a successful leave/move-out command acknowledgement is command acceptance/completion evidence only, while the membership value changes to `None` or to a server-provided replacement channel only after a live protocol self-move/client-leave delta or a reconciled snapshot confirms that state. Protocol errors, denials, timeouts, and stale outcomes shall be returned to Rust Core as user-safe error categories and sanitized diagnostics; raw protocol payloads, passwords, server addresses, and unredacted external strings shall not cross into logs or Flutter DTOs.
- Status: Baseline Candidate
- Type: Software Architecture Item
- Stage: P0 / MVP
- Allocated to: Rust Core, State Sync, Protocol Adapter, Bridge Facade, Diagnostics, Verification Support
- Source SRS: SRS-021, SRS-023, SRS-038, SRS-054, SRS-056, SRS-057, SRS-058, SRS-204
- Related SysDes: SysDes-069
- Verification method: Architecture Review, Integration Test, System Test, Diagnostics Review
### SWE.5 verification implications
SWE.5 verification shall include evidence that channel-join pending-state behavior remains server-authoritative end to end: Flutter sends intent only, authoritative current-channel state changes only after Rust Core/protocol confirmation or snapshot reconciliation, failure/timeout keeps the prior current channel, and edge-case sequences named in SAD-095 are deterministic. Tests shall cover both UI-visible pending feedback and Rust Core / State Sync reducer behavior through bridge-level integration seams. SWE.5 shall also verify the SAD-096 leave/move-out seam end to end: `voice_leave()` / `leave_channel()` intent reduces to a single Protocol Adapter leave/move-out command side effect, no synthetic target channel id is generated by upper layers, command success alone does not clear authoritative membership, and live-delta/snapshot confirmation is required before the projection reports `current_channel = None` or a server-provided replacement.
## 28. Updated SRS-to-SAD Coverage Matrix
| SRS Range | SAD Coverage |
|---|---|
| SRS-001 through SRS-184 | Covered by inherited SAD baseline `SAD-001` through `SAD-060` |
| SRS-021 (client join/leave/move events reflected in channel display) | Covered specifically for current-channel leave/move-out confirmation by `SAD-096` |
| SRS-023 (channel join confirmation before authoritative UI state) | Covered specifically by `SAD-092` through `SAD-096` |
| SRS-038 (Rust Core bridge command suite) | Covered specifically for voice/channel leave command routing by `SAD-096` |
| SRS-049 (Protocol Adapter channel join success/failure reporting) | Covered specifically by `SAD-093` through `SAD-095` |
| SRS-054 (State Sync connection state model) | Covered specifically for current-channel membership by `SAD-092` through `SAD-096` |
| SRS-056, SRS-057, SRS-058 (deterministic deltas, ordering, reducers) | Covered specifically for pending-join and leave/move-out edge cases by `SAD-092`, `SAD-094`, `SAD-095`, and `SAD-096` |
| SRS-185 through SRS-194 | Covered by `SAD-061` through `SAD-070` |
| SRS-195 through SRS-199, SRS-201 through SRS-203 | Covered by `SAD-071` through `SAD-079` |
| SRS-200 | Covered by `SAD-080` |
| SRS-204 through SRS-207 | Covered by `SAD-081` through `SAD-083` |
| SRS-204 through SRS-207 | Covered by `SAD-081` through `SAD-083`; the protocol leave/move-out realization required by `voice_leave()` is covered by `SAD-096` |
| SRS-208 | Covered by `SAD-084` |
| SRS-209 | Covered by `SAD-085` |
| SRS-111 (Android foreground voice service) | Covered by `SAD-086` (Android-specific allocation; SAD-036 retains the cross-cutting diagnostics allocation for the SRS-111124 range per §24.1 dual-allocation pattern) |
@@ -1539,6 +1601,20 @@ Pinned at SAD layer to remove a downstream ambiguity that would otherwise surfac
| 0.9.6 | 2026-05-17 | Propagated reconciled P0 Android SRS updates into SAD. SAD-063 text extended in place (ID preserved) to record the API 28 minimum runtime baseline per DEC-004 (Accepted 2026-05-14, superseding the earlier API 24 recommendation), while keeping the existing SRS-187 / SRS-188 trace. Added SAD-084 (Android in-call audio mode controller in the platform audio adapter, bound to the voice-session lifecycle, sourced from SRS-208). Added SAD-085 (Android `RECORD_AUDIO` runtime permission flow on the existing Android permission adapter, naming listen-only — `capture_active = false` with output stream open per SAD-081 — as a first-class operating mode at the architecture layer, sourced from SRS-209). Added SAD-086 (`AndroidVoiceForegroundService` allocation for SRS-111, bound to `voice_join` / `voice_leave` lifecycle rather than UI lifecycle, coordinating with SAD-084 and SAD-085). SAD-018 (Android back intent, SRS-163) and the existing Android AAB allocation in §9 / SAD-037 (SRS-119) were re-read and left unchanged because they remain coherent with the reconciled SRS. Strict layered sourcing preserved (`SAD -> SRS` only). |
## Baseline Candidate 0.9.10 Update
| Version | Date | Description |
|---|---|---|
| 0.9.10 | 2026-05-18 | Added dedicated channel-join pending-state architecture items SAD-092 through SAD-095. The new items assign authoritative current-channel membership to Rust Core / State Sync, constrain Flutter to user intent plus non-authoritative pending-target rendering, preserve the prior authoritative channel on failure or timeout, require sanitized diagnostics for join lifecycle outcomes, and push deterministic duplicate/different-target/leave-race/stale-ack/reconnect-reconciliation handling into SDD under the Rust Core / State Sync ownership boundary. Added specific §28 coverage rows for SRS-023, SRS-049, SRS-054, and SRS-056 through SRS-058, with SysDes-069 recorded as a related upstream design anchor while retaining SRS as the direct SAD source. |
## Baseline Candidate 0.9.11 Update
| Version | Date | Description |
|---|---|---|
| 0.9.11 | 2026-05-19 | Added SAD-096 to close the protocol leave/move-out architecture seam blocking SDD-121 implementation. The Protocol Adapter must expose an explicit leave/move-out command seam for `voice_leave()` / `leave_channel()` side effects; upper layers must not synthesize channel ids or rely on implicit `move_to_channel` behavior. Command acknowledgement remains separate from authoritative membership: Rust Core / State Sync clear or replace `current_channel` only from authoritative live deltas or reconciled snapshots. SWE.5 implications and §28 SRS coverage rows were updated for SRS-021, SRS-023, SRS-038, SRS-054, SRS-056 through SRS-058, and SRS-204. Strict layered sourcing preserved (`SAD -> SRS` only). |
## Baseline Candidate 0.9.7 Update
| Version | Date | Description |
+120 -1
View File
@@ -3,7 +3,7 @@
**Document type:** SDD / Software Detailed Design
**Process alignment:** ASPICE SWE.3 Software Detailed Design and Unit Construction
**Version:** 0.9.16
**Version:** 0.9.21
**Status:** Baseline Candidate
**Language:** English
**Product:** Chanora
@@ -1810,6 +1810,100 @@ Notes:
- This is a forward-looking SDD unit. The bench files, the Cargo.toml additions, the two workflow YAML files, the `emit_baseline.rs` / `compare_baseline.rs` binaries, and the initial seed of `crates/chanora_audio/benches/baselines/x86_64-unknown-linux-gnu.json` are NEW implementation work for a builder agent. The seed baseline file is generated by manually dispatching `bench-baseline-update.yml` once after the builder lands the rest of the unit; that first dispatch is the bootstrap that the §6 step 9 short-circuit branch will then no longer take on subsequent PR runs.
- The `tsclientlib::AudioHandler::fill_buffer` decode-side bench was explicitly NOT selected as the canonical `opus_decode_latency` target (see §3 item 4 rationale). A future SDD revision may add `audio_handler_fill_buffer_latency` as a separate composite-call metric without modifying the canonical Opus codec metric.
### Channel Join Pending-State Detailed Design (SDD-121)
**SDD-121**: `chanora_state::channel_join` shall own the single deterministic reducer for user-initiated channel-join pending state, authoritative current-channel membership, and reconnect/snapshot reconciliation. Flutter and `chanora_bridge` shall send join/leave intent and render the reducer output only; neither layer shall create, overwrite, or finalize authoritative current-channel membership.
- Status: Draft
- Type: Software Detailed Design Item
- Stage: P0 / MVP
- Software unit: `chanora_state::channel_join` reducer, `chanora_protocol::adapter` join/move command reporting, `chanora_bridge` command/event DTOs, Flutter channel tree / voice view models
- Source SAD: SAD-092, SAD-093, SAD-094, SAD-095, SAD-096
- Upstream software requirements are traced only through SAD coverage; SDD-121 shall not directly source or enumerate SRS identifiers.
- Cross-trace SDD: SDD-020 (`ChanoraChannelTree` join intent callbacks), SDD-094 (`voice_join` / `voice_leave` lifecycle bridge naming), SDD-097 (`ChanoraVoiceBar` current-channel rendering)
- Verification method: Unit Test (reducer transition table), Integration Test (bridge + protocol adapter seams), Widget Test (pending visual state), Fault-Injection Test (timeout/reconnect/stale outcome)
Implementation requirements:
1. Concrete Rust module/API surface owned by `chanora_state::channel_join`:
- The crate root shall expose `pub mod channel_join;` and shall not hide join-pending state in `chanora_core` or Flutter-local models.
- Type aliases/newtypes shall be declared in the module, or imported from an existing shared state-id module if one exists at implementation time: `ChannelId`, `ConnectionEpoch`, `JoinGeneration`, `JoinRequestId`, and `LeaveRequestId`. If existing repository types already define channel/request identifiers, the module shall use those concrete types rather than invent parallel identifiers; otherwise minimal `#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]` newtypes are authorized for the reducer seam.
- `AuthoritativeMembership` shall contain `current_channel: Option<ChannelId>` and `confirmed_epoch: ConnectionEpoch`. `None` represents no confirmed voice-channel membership.
- `JoinPending` shall contain `target_channel: ChannelId`, `previous_confirmed_channel: Option<ChannelId>`, `request_id: Option<JoinRequestId>`, `generation: JoinGeneration`, `started_at: Instant`, and `connection_epoch: ConnectionEpoch`.
- `ChannelJoinState` shall contain `authoritative: AuthoritativeMembership`, `pending: Option<JoinPending>`, `sync_state: ChannelJoinSyncState`, and `next_generation: JoinGeneration`.
- `ChannelJoinSyncState` shall be `Ready` or `Synchronizing { reason: SyncReason, epoch: ConnectionEpoch }`; `SyncReason` shall include `InitialSnapshot` and `Reconnect`.
- `JoinOutcomeKey` shall contain `connection_epoch: ConnectionEpoch`, `generation: JoinGeneration`, and `request_id: Option<JoinRequestId>`. `request_id: None` is valid only before command-send acceptance; protocol success/failure/timeout outcomes shall carry `Some(request_id)`. `JoinRequestId` shall be unique within a connection epoch. The reducer shall treat `(connection_epoch, generation, request_id)` as the outcome-correlation key. Generation shall increment for every user join request, pending-canceling leave request, reconnect epoch change, and fresh snapshot reconciliation.
- `LeaveOutcomeKey` shall contain `connection_epoch: ConnectionEpoch`, `generation: JoinGeneration`, and `request_id: LeaveRequestId`. `LeaveRequestId` shall be unique within a connection epoch. Leave command success/failure/timeout events shall carry this key; it correlates command lifecycle only and shall not be treated as authoritative membership evidence.
- `ChannelJoinProjection` shall be the only reducer view-model shape consumed by core/bridge mapping. It shall contain `current_channel: Option<ChannelId>`, `pending_target: Option<ChannelId>`, `pending_since: Option<Instant>`, `pending_generation: Option<JoinGeneration>`, `can_join: bool`, `can_leave: bool`, `sync_state: ChannelJoinSyncState`, and `last_join_error: Option<JoinErrorCode>`. Bridge DTOs may convert `pending_since` to elapsed milliseconds or a timestamp but shall preserve the same semantics.
2. Concrete reducer event, action, outcome, and function signatures:
- `ChannelJoinEvent` variants shall be: `UserJoinRequested { target_channel: ChannelId, now: Instant }`; `JoinCommandAccepted { generation: JoinGeneration, request_id: JoinRequestId }`; `JoinCommandRejectedBeforeSend { generation: JoinGeneration, error: JoinFailureKind }`; `ProtocolJoinSucceeded { key: JoinOutcomeKey }`; `ProtocolJoinFailed { key: JoinOutcomeKey, failure: JoinFailureKind }`; `JoinTimeout { key: JoinOutcomeKey, now: Instant }`; `AuthoritativeSelfMove { channel: Option<ChannelId>, epoch: ConnectionEpoch, source: AuthoritativeSource }`; `UserLeaveRequested { now: Instant }`; `LeaveCommandAccepted { generation: JoinGeneration, request_id: LeaveRequestId }`; `ProtocolLeaveSucceeded { key: LeaveOutcomeKey }`; `ProtocolLeaveFailed { key: LeaveOutcomeKey, failure: JoinFailureKind }`; `LeaveTimeout { key: LeaveOutcomeKey, now: Instant }`; `ReconnectStarted { new_epoch: ConnectionEpoch, now: Instant }`; and `SnapshotReady { current_channel: Option<ChannelId>, epoch: ConnectionEpoch, now: Instant }`.
- `AuthoritativeSource` shall include `LiveDelta` and `Snapshot`.
- `ChannelJoinAction` variants shall be: `SendJoinCommand { target_channel: ChannelId, generation: JoinGeneration, epoch: ConnectionEpoch }`; `StartJoinTimeout { key: JoinOutcomeKey, started_at: Instant }`; `CancelJoinTimeout { key: JoinOutcomeKey }`; `SendLeaveCommand { generation: JoinGeneration, epoch: ConnectionEpoch }`; `StartLeaveTimeout { key: LeaveOutcomeKey, started_at: Instant }`; `CancelLeaveTimeout { key: LeaveOutcomeKey }`; `PublishProjection(ChannelJoinProjection)`; and `EmitDiagnostic { key: JoinDiagnosticKey, code: Option<JoinErrorCode> }`. `SendLeaveCommand` deliberately carries no target `ChannelId`; any implementation that requires a dummy or synthetic channel id is non-conformant.
- `JoinReduceStatus` shall be `Accepted`, `CoalescedSameTarget`, `Rejected(JoinIntentRejected)`, `StaleOutcomeIgnored`, `Confirmed`, `Failed(JoinFailureKind)`, `TimedOut`, `SupersededByLeave`, `LeaveCommandAccepted`, `LeaveCommandCompleted`, `LeaveCommandFailed(JoinFailureKind)`, `LeaveTimedOut`, or `ReconciledBySnapshot`.
- `JoinIntentRejected` shall include `JoinAlreadyPendingDifferentTarget` and `CannotJoinWhileSynchronizing`.
- `JoinFailureKind` shall include `Denied`, `Network`, `Protocol`, `Timeout`, and `Unknown`.
- `JoinErrorCode` shall include stable names required by bridge/UI mapping: `DuplicateSameTargetCoalesced`, `JoinAlreadyPendingDifferentTarget`, `JoinDenied`, `JoinProtocolFailure`, `JoinNetworkFailure`, `JoinTimeout`, `JoinSupersededByLeave`, `JoinStaleOutcomeIgnored`, `JoinReconciledDifferentChannel`, `JoinCommandRejectedBeforeSend`, `JoinCannotStartWhileSynchronizing`, `LeaveProtocolFailure`, `LeaveNetworkFailure`, `LeaveTimeout`, `LeaveStaleOutcomeIgnored`, and `LeaveCommandRejectedBeforeSend`.
- Reducer signatures shall be implementation-compatible with: `ChannelJoinState::new(epoch: ConnectionEpoch) -> ChannelJoinState`; `fn reduce(state: &mut ChannelJoinState, event: ChannelJoinEvent) -> ChannelJoinReduction`; `fn project(state: &ChannelJoinState) -> ChannelJoinProjection`; and `struct ChannelJoinReduction { status: JoinReduceStatus, projection: ChannelJoinProjection, actions: Vec<ChannelJoinAction> }`. The reducer shall be deterministic for a given state/event; all wall-clock values enter through event `now` or `started_at` fields, not through hidden calls inside the reducer.
- `StaleJoinOutcome` shall not be accepted from outside the reducer; it is the reducer's diagnostic classification when an incoming success/failure/timeout key does not match the active pending key.
3. User join request behavior:
- If no join is pending, `UserJoinRequested` shall create `JoinPending` with the target channel, the current `authoritative.current_channel` as `previous_confirmed_channel`, a new generation, `started_at` from the event `now`, and no request id until the protocol command is accepted for send. The emitted actions shall include `PublishProjection(project(state))` immediately followed by `SendJoinCommand { target_channel, generation, epoch }`; the owning runtime shall publish the pending projection before starting protocol I/O so Flutter can render the non-authoritative pending target during the send attempt.
- `JoinCommandAccepted` shall attach the protocol request id to the active pending entry only when its generation matches the active pending generation, then emit `StartJoinTimeout { key: JoinOutcomeKey { connection_epoch, generation, request_id: Some(request_id) }, started_at }` and `PublishProjection(project(state))`. If the command-send path fails before a request id exists, `JoinCommandRejectedBeforeSend` shall clear pending, retain `previous_confirmed_channel` as authoritative, and publish the resulting projection.
- Duplicate same-target `UserJoinRequested` while an active pending entry has the same `target_channel` shall coalesce: no new protocol command, generation, timeout, or diagnostic error shall be produced. The reducer shall return the current pending view state and may emit a sanitized `join_duplicate_coalesced` diagnostic.
- Different-target `UserJoinRequested` while pending shall be rejected/serialized for P0: keep the existing pending entry, emit no new protocol command, and return `JoinIntentRejected::JoinAlreadyPendingDifferentTarget`. Flutter shall keep the original target marked pending and shall surface a user-safe "finish or cancel the current join first" message. Automatic switch/cancel-on-new-target is deliberately not selected in this revision.
4. Confirmation behavior:
- Authoritative membership changes only on `AuthoritativeSelfMove` or `SnapshotReady`. `ProtocolJoinSucceeded` is not by itself final membership; it clears neither pending nor `previous_confirmed_channel` unless paired with a matching authoritative self-move/snapshot confirmation. This prevents optimistic finalization when the protocol command returns before the server delta is reduced.
- If `AuthoritativeSelfMove { channel: Some(target) }` or `SnapshotReady { current_channel: Some(target) }` matches the active pending `target_channel` in the same connection epoch, the reducer shall set `authoritative.current_channel = Some(target)`, clear `pending`, and emit `JoinViewState::Confirmed { current_channel: target }` plus a sanitized `join_confirmed` diagnostic.
- If the server authoritatively moves the user to a different channel while a join is pending, the reducer shall accept the authoritative channel as current, clear pending as superseded, and emit `JoinViewState::FailedOrSuperseded { prior_or_server_channel }` with diagnostic key `join_reconciled_different_channel`. The pending target shall never become current unless it is the authoritative value.
5. Failure and timeout behavior:
- `ProtocolJoinFailed` with a matching active key shall clear `pending`, leave `authoritative.current_channel` equal to `previous_confirmed_channel`, and return `JoinFailure::Denied | Network | Protocol | Unknown` mapped to a user-safe localized message. Raw protocol payloads, passwords, server addresses, and permission tokens shall not enter diagnostics.
- `JoinTimeout` with a matching active key shall clear `pending`, preserve `previous_confirmed_channel` as authoritative, and emit `JoinFailure::Timeout`. The timeout duration is configuration-owned by Rust Core; the SDD requires a deterministic timer seam but does not mandate a product-visible duration.
- Success, failure, or timeout with a non-matching `(connection_epoch, generation, request_id)` shall be ignored for state mutation and shall emit only sanitized diagnostic key `join_stale_outcome_ignored` with the stale/current generations and no raw protocol payload.
6. Leave/switch race behavior:
- `UserLeaveRequested` while no join is pending shall emit `SendLeaveCommand { generation, epoch }` when `authoritative.current_channel` is `Some(_)`. The action is a current-client leave/move-out request with no target channel and no channel password. If `authoritative.current_channel` is already `None`, the reducer shall publish the unchanged projection and shall not emit a protocol command.
- `UserLeaveRequested` while a join is pending shall supersede the pending join locally: increment generation, clear pending view state, emit a best-effort cancellation diagnostic `join_pending_superseded_by_leave`, and dispatch `SendLeaveCommand { generation, epoch }` only if the current authoritative channel is `Some(_)`. The reducer shall ignore later matching command success/failure for the superseded join as stale. If a live authoritative self-move to the former pending target arrives before the leave is confirmed, the reducer shall accept that server-authoritative current channel and the in-flight leave shall then be responsible for moving to `None` or the server-provided post-leave state.
- `LeaveCommandAccepted` shall attach a `LeaveOutcomeKey` for timeout and stale-outcome correlation and may emit `StartLeaveTimeout`; it shall not mutate `authoritative.current_channel` or clear `current_channel` in the projection. `ProtocolLeaveSucceeded` with a matching key records command completion only and may cancel the leave timeout and emit a sanitized diagnostic; it shall not change authoritative membership. `ProtocolLeaveFailed` with a matching key and `LeaveTimeout` with a matching key shall leave `authoritative.current_channel` unchanged, set `last_join_error` to the corresponding leave-safe code, and publish the unchanged authoritative projection. Stale leave success/failure/timeout keys shall be ignored for state mutation and may emit `leave_stale_outcome_ignored`.
- Authoritative leave completion is represented only by `AuthoritativeSelfMove { channel: None, source: LiveDelta }`, `AuthoritativeSelfMove { channel: Some(server_channel), source: LiveDelta }`, or `SnapshotReady { current_channel: None | Some(server_channel), ... }`. These events update the projection to `current_channel = None` or to the server-provided replacement channel. Command acknowledgement is never an authoritative membership confirmation.
- Different-target switch while pending is not a leave; it follows item 3 rejection semantics. A future SDD may define explicit "cancel then join new target" behavior, but P0 serializes it.
7. Reconnect and snapshot reconciliation:
- `ReconnectStarted` shall move `sync_state` to `Synchronizing`, increment the connection epoch/generation, and mark any active pending entry as non-authoritative and stale. The UI may continue to display the last confirmed `authoritative.current_channel` as "last known" but shall disable channel actions requiring a ready connection.
- `SnapshotReady` shall replace `authoritative.current_channel` with the snapshot's self-channel value for that epoch. If the snapshot channel equals the former pending target, the result is confirmed by snapshot; otherwise the pending target is cleared and the snapshot value wins. If the snapshot contains no current channel, authoritative current becomes `None`.
- Live deltas from an old epoch shall be ignored for membership mutation after reconnect epoch advancement; live deltas in the new epoch shall be reduced in arrival order after the snapshot-ready boundary.
8. Bridge/core/protocol integration seams:
- On bridge `voice_join(channel_id)` / `join_channel(target_channel)` intent, `chanora_core` shall enqueue `ChannelJoinEvent::UserJoinRequested { target_channel, now }` into the per-connection State Sync reducer task before sending any protocol command. If the reduction returns `SendJoinCommand`, core shall call the existing protocol join/move command seam and then feed either `JoinCommandAccepted { generation, request_id }` or `JoinCommandRejectedBeforeSend { generation, error }` back through the same reducer queue. Duplicate same-target reductions return command-accepted/coalesced semantics to the bridge without sending a second protocol command. Different-target pending reductions map to `JoinIntentRejected::JoinAlreadyPendingDifferentTarget`.
- Phase B request-id allocation rule: the protocol adapter shall pass through a transport request id when the underlying protocol exposes one. If the adapter accepts/sends a join command but exposes no transport request id, `chanora_core` shall allocate a core-local `JoinRequestId` in the protocol side-effect handler after the reducer action has been captured and the session-state lock has been released, immediately before enqueuing `JoinCommandAccepted`. A monotonically incremented unsigned counter scoped to the current `ConnectionEpoch` is an approved substitute for P0; the counter shall reset or be namespaced on epoch change and shall never be reused within one epoch. The allocated id shall be written only through `JoinCommandAccepted { generation, request_id }`; all subsequent protocol success/failure callbacks and timeout tasks created for that accepted command shall carry `JoinOutcomeKey { connection_epoch: epoch, generation, request_id: Some(request_id) }`. Outcomes without a matching accepted id, generation, and epoch shall reduce to stale/no-op behavior.
- On protocol command result, the Protocol Adapter shall report command lifecycle separately from authoritative membership: command accepted/sent, command success, command failure. `ProtocolJoinSucceeded { key }` confirms only that the command completed; it shall not update `authoritative.current_channel`. `ProtocolJoinFailed { key, failure }` and `JoinTimeout { key, now }` may clear pending only when the key matches the active pending entry.
- On authoritative self move or live membership delta, `chanora_core` / State Sync shall enqueue `AuthoritativeSelfMove { channel, epoch, source: LiveDelta }`. On initial or reconnect snapshot completion, it shall enqueue `SnapshotReady { current_channel, epoch, now }`. These are the only events that may finalize or replace `authoritative.current_channel`.
- On reconnect start, `chanora_core` shall enqueue `ReconnectStarted { new_epoch, now }` before accepting live deltas for the new connection. Protocol outcomes and live deltas carrying older epochs shall be reduced as stale/no-op for membership mutation.
- Protocol Adapter shall expose an explicit leave/move-out API seam. Preferred public Rust signature, compatible with the existing `ProtocolClient` async method style, is `pub async fn leave_channel(&self) -> Result<(), ProtocolError>`; `move_out_of_channel(&self)` is an acceptable equivalent if used consistently. The corresponding internal request shape shall have no `channel_id` field, for example `Request::LeaveChannel { reply: oneshot::Sender<Result<(), ProtocolError>> }`. The seam may internally call the protocol library's native leave/current-client-move-out primitive; if the protocol library represents leave with an optional target, that mapping is owned only by `chanora_protocol::adapter` and shall not leak to Rust Core, Bridge, Flutter, or the reducer.
- On bridge `voice_leave()` / `leave_channel()` intent, `chanora_core` shall enqueue `UserLeaveRequested { now }`. If the returned action includes `SendLeaveCommand`, core shall execute that action through the explicit Protocol Adapter leave/move-out seam (`leave_channel` / `move_out_of_channel` or equivalent); suppressing it, treating it as projection-only, or translating it into `move_to_channel(<synthetic id>)` is not permitted. Leave supersedes active pending join locally but shall not clear or finalize authoritative current membership until an authoritative self move, snapshot, or projection produced from such authoritative confirmation establishes `None` or a different server-provided value.
- Leave command result mapping shall mirror the join command lifecycle separation. If the adapter accepts/sends the command and exposes no transport request id, `chanora_core` shall allocate a core-local `LeaveRequestId` scoped to the `ConnectionEpoch` after releasing the session/state lock and before enqueuing `LeaveCommandAccepted`. Adapter success maps to `ProtocolLeaveSucceeded { key }`; adapter denial/protocol/network errors map to `ProtocolLeaveFailed { key, failure }`; timer expiry maps to `LeaveTimeout { key, now }`. All three outcome events shall preserve the previous authoritative `current_channel` unless a live delta or snapshot separately confirms `None` or a server-provided replacement.
- Bridge command handlers shall return only command-acceptance or immediate validation/rejection DTOs derived from `JoinReduceStatus`/`JoinErrorCode`. The bridge shall not mutate `current_channel` and shall not transform a successful command send into confirmed membership.
- The bridge event stream shall expose the reducer projection as a DTO equivalent to `ChannelJoinProjection`: `current_channel`, `pending_target`, `pending_since_ms` or timestamp, `can_join`, `can_leave`, `sync_state`, and optional sanitized `last_join_error`. Existing `BridgeEvent::VoiceState` / server-view events may carry these fields directly or by a nested `ChannelJoinState` DTO, but there shall be exactly one authoritative projection source.
- Flutter shall render `pending_target` as non-authoritative pending UI and shall continue to render `current_channel` from the projection as the last confirmed channel. Flutter may cache the projection for rebuild performance but shall not locally set `current_channel` in response to a tap, protocol success DTO, or timer.
- Flutter and bridge DTOs shall not carry, infer, or synthesize a leave target channel id. A leave/move-out UI action is intent-only; the only post-intent membership values Flutter may render are the reducer projection's unchanged prior `current_channel` or a later reducer projection derived from authoritative live delta/snapshot data.
9. UI/view-model behavior:
- `current_channel` displayed in `ChanoraVoiceBar`, channel tree selection, and voice status shall remain the last confirmed authoritative channel until item 4 or item 7 confirms a new authoritative value.
- The pending target may be rendered in `ChanoraChannelTree` as non-authoritative visual feedback (for example spinner, "joining", disabled row action). This visual state shall be derived only from `pending_target` and shall not move the local client row into that channel.
- While `pending_target` is present or `sync_state == Synchronizing`, Flutter shall disable or serialize unsafe channel actions: joining a different target, repeated leave/join churn, and UI operations that assume the pending target is current. Duplicate same-target taps are allowed but coalesced visually.
- Flutter shall not write `current_channel` in local state except as a cached rendering of the reducer-provided authoritative value.
10. Diagnostics and error codes:
- Reducer-visible error/result codes shall include the stable `JoinErrorCode` values from item 2: `DuplicateSameTargetCoalesced`, `JoinAlreadyPendingDifferentTarget`, `JoinDenied`, `JoinProtocolFailure`, `JoinNetworkFailure`, `JoinTimeout`, `JoinSupersededByLeave`, `JoinStaleOutcomeIgnored`, `JoinReconciledDifferentChannel`, `JoinCommandRejectedBeforeSend`, `JoinCannotStartWhileSynchronizing`, `LeaveProtocolFailure`, `LeaveNetworkFailure`, `LeaveTimeout`, `LeaveStaleOutcomeIgnored`, and `LeaveCommandRejectedBeforeSend`.
- Diagnostic event keys shall include `join_requested`, `join_command_sent`, `join_duplicate_coalesced`, `join_confirmed`, `join_failed`, `join_timeout`, `join_stale_outcome_ignored`, `join_pending_superseded_by_leave`, `join_reconnect_synchronizing`, `join_snapshot_reconciled`, `leave_requested`, `leave_command_sent`, `leave_command_succeeded`, `leave_failed`, `leave_timeout`, `leave_stale_outcome_ignored`, and `leave_confirmed_by_authoritative_state`.
- Diagnostic fields are limited to sanitized channel ids, connection epoch, generation, coarse error kind, elapsed milliseconds, and outcome key. No raw protocol payload, channel password, server password, server address, user nickname, or unredacted external error string may be logged.
11. Concurrency and consistency constraints:
- All join/leave/snapshot/live-delta events for one connection shall be serialized through the State Sync reducer task. No UI isolate, bridge callback, protocol task, or audio engine task may concurrently mutate authoritative membership.
- Per-connection event ordering shall be preserved at the reducer input. Cross-connection events are ordered by `connection_epoch`; older epochs cannot mutate newer-epoch state.
- The reducer shall be pure with respect to state transition decisions: timers, protocol sends, bridge emissions, and diagnostics are returned as side-effect actions for the owning runtime to execute.
- Runtime locking rule for Phase B: reducer state mutation may be protected by the existing session/state mutex, but that mutex shall never be held across protocol I/O, timer awaits, bridge emission awaits, or other await points. The owning task shall acquire the lock, reduce the event, copy the returned status/projection/action list and any correlation fields needed by side effects, release the lock, publish projection/execute protocol actions/timers, and then enqueue outcome events (`JoinCommandAccepted`, `JoinCommandRejectedBeforeSend`, `ProtocolJoinSucceeded`, `ProtocolJoinFailed`, `JoinTimeout`, `LeaveCommandAccepted`, `ProtocolLeaveSucceeded`, `ProtocolLeaveFailed`, `LeaveTimeout`) back through the reducer queue. Each outcome is reduced under the lock using the stored `JoinOutcomeKey` or `LeaveOutcomeKey` plus generation/epoch; no side effect may mutate `ChannelJoinState` directly.
12. Verification implications:
- SWE.4 unit cases shall cover the full reducer transition table: no-current/current initial states; normal join; same-target duplicate coalescing; different-target rejection; denied join; timeout; stale success/failure/timeout; authoritative move to target; authoritative move to different channel; leave while pending; leave command accepted/succeeded/failed/timed out without authoritative clearing; stale leave outcomes; authoritative leave confirmed to `None`; authoritative leave reconciled to a server-provided replacement channel; reconnect followed by target snapshot, different-channel snapshot, and no-current snapshot.
- SWE.5 integration cases shall prove Bridge intent-only behavior and Protocol Adapter separation of command success from authoritative membership deltas. SWE.5 shall include a leave/move-out end-to-end seam test proving `voice_leave()` / `leave_channel()` produces exactly one adapter `leave_channel` / `move_out_of_channel` call, produces no `move_to_channel` call with a synthetic target, leaves `current_channel` unchanged after command success/failure/timeout, and updates `current_channel` only after a live self-move/client-leave delta or reconciled snapshot.
- SWE.6 software qualification cases shall prove UI-visible behavior: prior current channel remains visible while joining, pending target is visually marked but not authoritative, unsafe actions are disabled/serialized, failure/timeout preserves prior current channel, and reconnect/snapshot reconciliation clears stale pending state.
13. Authorized implementation phasing:
- Phase A — reducer scaffolding and unit tests: add `chanora_state::channel_join` module, concrete data structures/enums, `reduce`/`project` API, deterministic timer/request-key seams, and SWE.4 reducer tests for same-target coalescing, different-target rejection, stale outcomes, leave supersession, leave outcome non-authoritativeness, and snapshot reconciliation. Phase A may have no externally visible behavior change except compile-time availability of the reducer seam.
- Phase B — core/bridge/Flutter wiring: route existing core join/leave intent and protocol result paths through the reducer queue; add the explicit Protocol Adapter leave/move-out seam; publish `ChannelJoinProjection` through bridge DTO/event mapping; update Flutter view models to render pending target from the projection while preserving authoritative current channel until reducer confirmation.
- Phase C — verification hardening: add SWE.5 integration coverage for bridge intent-only behavior, protocol-success-not-authoritative behavior, explicit leave/move-out seam use with no synthetic channel id, reconnect/snapshot clearing, and timeout/stale-outcome races; add widget coverage for pending target rendering and disabled/serialized unsafe actions.
## 11. Updated SAD-to-SDD Coverage Matrix
| SAD Range | SDD Coverage |
@@ -1823,6 +1917,7 @@ Notes:
| SAD-063 | Covered by `SDD-118` (Android `chanora_bridge` cdylib build automation — Gradle + `cargo-ndk` + per-ABI jniLibs staging). Cross-trace: SDD-073, SDD-105, SDD-109. |
| SAD-061, SAD-062, SAD-087 | Covered by `SDD-119` (iOS / macOS `chanora_bridge` cdylib build automation — CocoaPods podspec + `cargo` + `lipo` + framework-layout shell, back-fill of existing code). Source SAD allocation: iOS half anchored to SAD-061 (iOS runtime) and SAD-062 (App Store / packaging); macOS half anchored to SAD-087 (macOS runtime baseline — deployment target isolation, universal-binary `lipo` packaging, `.framework` `Versions/A` layout, hand-rolled CocoaPods podspec automation), authored in SAD v0.9.7 to close the macOS-runtime anchor gap that this SDD unit had previously bridged by parallelism from SAD-061 / SAD-062. SDD-117 remains reserved-but-unauthored for the deferred `ios_voice_unit` trait back-fill noted by SDD-111. |
| SAD-088, SAD-089, SAD-090, SAD-091 | Covered by `SDD-120` (realtime-audio benchmark harness and advisory CI infrastructure — criterion-based bench harness, dhat-backed heap-allocation-count metric, SAD-089 baseline JSON post-processor, SAD-090 advisory PR-comment workflow with merge-base baseline read, SAD-091 `workflow_dispatch`-only baseline-update workflow that opens a PR rather than direct-pushing; simpler-form yellow-marker realization treats the SAD-089 baseline as the comparator for both red/green tolerance evaluation and yellow trending detection). Cross-trace: SDD-094 (the bench seam exercises the same `chanora_audio::engine` capture path whose lifecycle is specified by SDD-094). Suggested SWE.4 forward allocation: SWE4-UV-058 through SWE4-UV-062 (verification-engineer follow-up; not authored by this SDD unit). |
| SAD-092, SAD-093, SAD-094, SAD-095, SAD-096 | Covered by `SDD-121` (channel join pending-state reducer — Rust Core / State Sync authoritative current-channel ownership, Flutter pending-target-only rendering, failure/timeout preservation of previous authoritative channel, explicit Protocol Adapter leave/move-out seam with no synthetic channel id, leave command success/failure/timeout non-authoritative outcome handling, deterministic duplicate/different-target/leave-race/stale-outcome/reconnect-snapshot reconciliation). Upstream software requirements remain traced through SAD coverage only. Suggested SWE.4/SWE.5/SWE.6 derivation surfaces are listed in SDD-121 item 12. |
## Baseline Candidate 0.9.1 Update
@@ -1926,6 +2021,30 @@ Notes:
| --- | --- | --- |
| 0.9.16 | 2026-05-18 | SDD-120 amendment: clarify the post-processor binary placement at `crates/chanora_audio/examples/` rather than `benches/` or `src/bin/`. Rationale: Cargo's dependency resolver only routes `[dev-dependencies]` to `[[test]]`, `[[bench]]`, and `[[example]]` targets; `src/bin/` placement would have forced `serde_json` and other dev-only crates into production builds. Reflects commit 3a7750a discovery. No semantic change to SDD-120 — same harness, same metrics, same workflows, same out-of-scope deferrals; only the path conventions and invocation flags (`--example` not `--bin`) corrected. Edits scoped to SDD-120: (1) "Allocated to" line gains `crates/chanora_audio/examples/` alongside `benches/`; (2) "Software units" list relocates `emit_baseline.rs` and `compare_baseline.rs` from `benches/` to `examples/`; (3) §1 item 4 reworded from `[[bin]]` entries pointing into `benches/` to `[[example]]` entries (auto-discovered under `examples/`) with `cargo run --example` invocation pattern; (4) §2 gains a new item 5 stating the `examples/` rationale and the dev-only isolation property; (5) §5 item 1 path updated and invocation-flag note added; (6) §5 item 4 rationale clause updated to reference the `[dev-dependencies]`-to-examples routing; (7) §6 step 6 and step 8 invocation flags changed `--bin` → `--example`; (8) §7 step 5 invocation flag changed `--bin` → `--example`; (9) §10 item 1 release-artifact-isolation bullet extended with an explicit note that the `examples/` placement is the Cargo-design mechanism enforcing the exclusion of `[dev-dependencies]` (including `serde_json`, `criterion`, `dhat`) from `cargo build --release` and from `flutter build apk/aab/ipa --release`. §11 verification matrix unchanged. Coverage matrix row for SAD-088..SAD-091 unchanged. The SDD-120 spec is otherwise byte-identical to v0.9.15. |
## Baseline Candidate 0.9.20 Update
| Version | Date | Description |
| --- | --- | --- |
| 0.9.20 | 2026-05-19 | SDD-121 documentation-only final-review amendment. Removed direct SRS identifier enumeration from the SDD-121 source/context field and from the SAD-to-SDD coverage row; upstream software requirements are now described only as traced via SAD coverage. Clarified Phase B join request correlation: use protocol transport ids when available, otherwise allocate a core-local monotonically incremented `JoinRequestId` scoped to the connection epoch after releasing the session lock and before enqueuing `JoinCommandAccepted`; all success/failure/timeout outcomes carry the resulting `JoinOutcomeKey`. Clarified that pending projection publishes immediately after accepted `UserJoinRequested` and before protocol send, session/state mutexes are not held across protocol/timer/bridge awaits, `SendLeaveCommand` actions must be executed, and authoritative current-channel finalization remains limited to authoritative self-move/snapshot confirmation and projections derived from that confirmation. |
## Baseline Candidate 0.9.21 Update
| Version | Date | Description |
| --- | --- | --- |
| 0.9.21 | 2026-05-19 | SDD-121 documentation-only amendment for SAD-096. Added SAD-096 to SDD-121 Source SAD and the updated SAD-to-SDD coverage matrix. Specified the explicit Protocol Adapter leave/move-out seam (`ProtocolClient::leave_channel(&self) -> Result<(), ProtocolError>` preferred, `move_out_of_channel` acceptable) with no channel-id argument and no upper-layer synthetic channel id. Updated reducer events/actions with `LeaveRequestId`, `LeaveOutcomeKey`, `LeaveCommandAccepted`, `ProtocolLeaveSucceeded`, `ProtocolLeaveFailed`, `LeaveTimeout`, `StartLeaveTimeout`, and `CancelLeaveTimeout`; mapped leave success/failure/timeout to command-lifecycle diagnostics and non-authoritative projection behavior. Reaffirmed that only live deltas or snapshots may clear or replace authoritative `current_channel`. Added SWE.4/SWE.5 verification notes for leave/move-out behavior and no synthetic `move_to_channel` fallback. |
## Baseline Candidate 0.9.19 Update
| Version | Date | Description |
| --- | --- | --- |
| 0.9.19 | 2026-05-18 | Refined SDD-121 to resolve the reducer-surface implementation blocker without code changes. Added concrete `chanora_state::channel_join` module/API design (`ChannelJoinState`, `AuthoritativeMembership`, `JoinPending`, `ChannelJoinEvent`, `ChannelJoinAction`, `ChannelJoinProjection`, `JoinReduceStatus`, `JoinIntentRejected`, `JoinFailureKind`, `JoinErrorCode`, `reduce`, and `project`), explicit core/protocol/bridge/Flutter integration seams, and authorized Phase A/B/C implementation sequencing. Existing SDD-121 behavior decisions are preserved: duplicate same-target coalesces; different target while pending is rejected/serialized for P0; leave supersedes pending; stale outcomes are ignored by epoch/generation/request mismatch; reconnect/snapshot reconciliation clears or confirms pending; protocol success alone is not authoritative finalization. Source SAD remains SAD-092 through SAD-095. |
## Baseline Candidate 0.9.18 Update
| Version | Date | Description |
| --- | --- | --- |
| 0.9.18 | 2026-05-18 | Added channel join pending-state detailed design SDD-121 sourced from SAD-092 through SAD-095. The new unit defines the Rust Core / State Sync reducer state (`AuthoritativeMembership`, `JoinPending`, connection epoch/generation, synchronizing state), events, success/failure/timeout/reconnect transitions, bridge/core/protocol boundaries, UI pending-target rendering constraints, sanitized diagnostics/error codes, concurrency assumptions, and SWE.4/SWE.5/SWE.6 verification implications. Behavior selections: duplicate same-target joins coalesce; different-target joins while pending are rejected/serialized for P0; leave while pending supersedes and stales the pending join; stale outcomes are ignored by generation/request/epoch key; reconnect advances epoch and resolves membership from the fresh snapshot. Coverage matrix updated for SAD-092..SAD-095 with upstream software requirements retained only through SAD coverage. |
## Baseline Candidate 0.9.17 Update
| Version | Date | Description |