# Live-Adapter MVP — Outstanding Issues

Tracking document for the `live-adapter-mvp` branch review (vs `origin/main`).

**The MVP failure (one sentence):** The pipe works. The **projection contract** — provider realtime events become canonical Meerkat facts — is named in the host's doc-comment but **mostly unimplemented**. S71/S72 validate the pipe and pass without ever exercising the projection.

**Convention:** each item has two checkboxes — `[ ] fix` `[ ] verify`. The first is the implementer; the second is an independent agent confirming the fix actually addresses the finding (not just compiled away or papered over). A third state `[~] fix` means *partial* — the in-scope work for the assigned file is done but a cross-file remainder is still open; verification waits until `[~]` advances to `[x]`.

Tags: `[U]` = user-found, `[R]` = reviewer-only, `[U+R]` = both.

---

## P1 — Block MVP

### A. Projection contract is unimplemented

- [x] fix · [x] verify · **A1.** `LiveAdapterHost` classifies observations into `AppendTranscript / DispatchToolCall / SignalInterrupt / TerminalError / Noop` but applies none of them. `[U]` — *runtime*: `LiveAdapterHost::apply_observation` dispatches by routing variant and returns a typed `ObservationOutcome`. *surface-projection*: `meerkat-rpc/src/main.rs` now constructs the host with `with_projection_sink(SessionServiceProjectionSink::new(runtime))` before wrapping it in `LiveWsState`. *wire-handlers (wave-3 pump migration)*: `meerkat-live/src/transport.rs` WS pump split into `host.next_observation_raw` + `host.apply_observation`; on `ObservationOutcome::Terminal { code }` the pump now closes the WS with a stable typed reason `terminal:<error-code-slug>` (slug derived via `serde_json::to_value` on the `LiveAdapterErrorCode` `tag = "code"` enum, not `Debug`). Observation is forwarded to the client *before* the close frame so clients see the terminal payload alongside the close. Legacy `break-on-StatusChanged{Closed}` belt-and-suspenders deleted — `apply_observation` reliably emits `Terminal` for that case. Regression tests inline in `transport::tests`: `live_adapter_error_code_slug_emits_serde_tag` (slug stability for `ProviderError` / `ConnectionLost` / `Other`) + `websocket_closes_on_terminal_observation_with_typed_reason` (scripts a `LiveAdapterObservation::Error` via a single-shot `ScriptedAdapter`, asserts the client sees the JSON observation and a Close frame whose reason contains `terminal:provider_error`).
- [x] fix · [x] verify · **A2.** User transcripts are not appended to canonical session history. `[U]` — *runtime*: `LiveProjectionSink::append_user_transcript` defined and wired through `apply_observation`. *surface-projection*: `SessionServiceProjectionSink::append_user_transcript` in `meerkat-rpc/src/live_projection_sink.rs` routes to `SessionRuntime::append_external_user_content` with `ContentInput::Text(text)`. Inline test `user_transcript_final_records_user_message` proves the sink fires once per `UserTranscriptFinal` observation.
- [x] fix · [x] verify · **A3.** Assistant deltas / finals are not appended to canonical session history. `[U]` — *runtime*: `append_assistant_delta`, `append_assistant_final`, `truncate_assistant_transcript`, `signal_turn_completed` all wired through `LiveProjectionSink`, now passing `LiveTranscriptIdentity<'_>` end-to-end. *surface-projection*: deltas → `SessionRuntime::append_realtime_transcript_event(AssistantTextDelta { response_id, delta_id, item_id, previous_item_id, content_index, ... })` with the full identity tuple plumbed from the sink (no synthesized empty defaults); finals → `SessionRuntime::append_external_assistant_output(blocks=[Text], stop_reason, usage)`; truncations → `append_realtime_transcript_event(AssistantTranscriptTruncated)`; turn-completed is intentionally a no-op because the assistant final already commits canonical blocks (see `signal_turn_completed` doc-comment). Inline test `assistant_delta_records_delta_append_with_full_identity` asserts the full identity tuple flows through.
- [x] fix · [x] verify · **A4.** Tool calls are observed but never dispatched through Meerkat's tool authority. `[U]` — *runtime*: host takes `Arc<dyn AgentToolDispatcher>` via `with_tool_dispatcher` / `set_tool_dispatcher`; on `ToolCallRequested` observation it builds a `ToolCallView` and calls `dispatch`. When no dispatcher is wired, returns `ObservationOutcome::ToolCallSkipped { reason: NoDispatcher }` so the miswiring is detectable. *surface-projection*: `SessionRuntime::live_tool_dispatcher()` exposes a callback-backed `Arc<dyn AgentToolDispatcher>` built from the runtime's callback channel; `MethodRouter::with_live_ws` calls `host.set_tool_dispatcher(...)` immediately after adopting the host (the callback channel is initialized by `RpcServer::new_with_skill_runtime`, which always runs *before* `with_live_ws` in `serve_*_with_options`). Inline test `tool_call_dispatches_through_dispatcher` proves the host-side dispatch path with a recording dispatcher.
- [x] fix · [x] verify · **A5.** Tool results are never submitted back through the host after Meerkat dispatch. `[U]` — *runtime*: dispatch result mapped through `tool_result_from_dispatch` (preserves `Vec<ContentBlock>` fidelity, no stringify) and sent back as `LiveAdapterCommand::SubmitToolResult` via `host.submit_tool_result`. Errors are sent as `SubmitToolError`. *surface-projection*: closes alongside A4 — once the runtime callback dispatcher is installed via `set_tool_dispatcher`, the result-submit path runs end-to-end. Inline test `tool_call_dispatches_through_dispatcher` asserts `SubmitToolResult` is the first command issued back to the adapter after a tool dispatch.
- [x] fix · [x] verify · **A6.** Barge-in is provider-native only; not projected as Meerkat turn interruption / steer. `[U]` — *runtime*: `LiveAdapterObservation::TurnInterrupted` now calls `LiveProjectionSink::signal_turn_interrupt`. *surface-projection*: `SessionServiceProjectionSink::signal_turn_interrupt` routes through `SessionRuntime::interrupt_live_with_machine_authority` (the same machine-authority path the user-facing `live/interrupt` RPC uses). `SessionError::NotRunning` is tolerated as a no-op so a late provider-side interrupt does not poison the channel. Inline test `turn_interrupted_signals_interrupt`.
- [x] fix · [x] verify · **A7.** No public interrupt / truncate / playback-cursor path, despite barge-in being advertised. `[U]` — *wire-handlers*: interrupt + truncate landed; playback_cursor descoped — see handler doc-comment in `meerkat-rpc/src/handlers/live.rs`. Clients track playback locally; the truncate command's `audio_played_ms` parameter is the cursor seam. The host cannot know what the client rendered (jitter buffers, end-of-stream silence trim), so reporting a server-side cursor would be a dogma sin in the C21/C22 "no lies" family. `live/interrupt` sends `LiveAdapterCommand::Interrupt`; `live/truncate` sends `TruncateAssistantOutput { item_id, content_index, audio_played_ms }` with non-empty `item_id` validation (`u32`/`u64` types reject negatives at deserialize). Both router arms gated by `live_ws_state.is_some()`. `LiveTruncateParams` round-trip + Rule 14 (no flatten) field-shape unit tests inline. Orphan `LivePlaybackCursorParams`/`LivePlaybackCursorResult` types + their tests deleted as part of the descope.
- [x] fix · [x] verify · **A8.** `LiveProjectionSnapshot` is not used by the actual open path. `[U]` — *wire-handlers*: `handle_live_open` now builds a `LiveProjectionSnapshot` from the resolved `RealtimeSessionOpenConfig` (via the new `build_live_projection_snapshot` helper) and dispatches `LiveAdapterCommand::Open { snapshot }` to the adapter via `host.send_command` immediately after `host.attach_adapter`. `LiveContinuityMode` is now derived from the snapshot (`continuity_from_snapshot`): `Fresh` for empty seed history, `TranscriptOnly` once seeded. The previous unconditional `LiveContinuityMode::Degraded` falsehood is gone — the response now honestly reflects whether canonical history was projected into the live channel.
- [x] fix · [x] verify · **A9.** `LiveAdapterCommand::Open { snapshot }` is a no-op in the provider adapter. `[U]` — *provider*: `OpenAiLiveAdapter::execute_command`'s `Open { snapshot }` arm now calls `OpenAiRealtimeSession::seed_history_projection(&snapshot.seed_messages, &[])`, which mints `ConversationItemCreate` events on the OpenAI sender side and waits for provider acknowledgements. This is the same canonical seed routine the factory uses at session-open time; routing it through `Open { snapshot }` makes the live-adapter command flow the canonical seed path for any future re-seed (resume, cross-session attach). Inline regression test `open_command_seeds_provider_with_snapshot_messages` builds a snapshot with a user + assistant turn, dispatches `Open`, and asserts the recorder's `seen` log contains exactly one `ConversationItemCreate` per `openai_realtime_history_events`-derived seed event. Sibling test `openai_live_adapter_emits_initial_ready_observation` covers E25.
- [x] fix · [x] verify · **A10.** Error observations classify as terminal but do not terminalize channel/session state. `[U]` — *runtime*: `apply_observation` on `LiveAdapterObservation::Error` sets `channel.status = Closed`, schedules the channel for retire after `CLOSED_CHANNEL_TTL`, and calls `LiveProjectionSink::signal_terminal_error`. Returns `ObservationOutcome::Terminal { code }` so the WS pump can drop the connection cleanly.
- [x] fix · [x] verify · **A11.** Provider item/order identity lost: `previous_item_id`, `content_index`, response IDs collapsed. `[U+R]` — *core types*: `wire-core` added optional ordering fields with `#[serde(default, skip_serializing_if = "Option::is_none")]` to `UserTranscriptFinal`, `AssistantTextDelta`, `AssistantTranscriptFinal`. *Adapter*: `meerkat-live/src/adapter.rs::translate_event` now plumbs the full identity tuple from `RealtimeSessionEvent::InputTranscriptFinalForItem` and `OutputTextDeltaForItem` (provider_item_id, previous_item_id, content_index, response_id, delta_id). Bare `OutputTextDelta` / `InputTranscriptFinal` (no source identity) emit all-`None` order fields. Inline tests: `assistant_text_delta_for_item_plumbs_full_identity`, `assistant_text_delta_without_item_emits_all_none`, `user_transcript_final_for_item_plumbs_identity`. *End-to-end through sink (2026-05-09):* `LiveProjectionSink::append_user_transcript` / `append_assistant_delta` / `append_assistant_final` now take `LiveTranscriptIdentity<'_>` instead of `Option<&str>`, so identity is preserved end-to-end through the projection seam — not just at the wire layer. Surface inline tests `user_transcript_final_records_user_message` (asserts `provider_item_id`/`previous_item_id`/`content_index` flow) and `assistant_delta_records_delta_append_with_full_identity` (asserts full tuple including `response_id`/`delta_id`) pin the contract.
- [x] fix · [x] verify · **A12.** `RealtimeTranscript` events are folded into `StatusChanged{Ready}`, discarding richer event content. `[U+R]` — *core types*: `wire-core` added `LiveAdapterObservation::RealtimeTranscript { event: RealtimeTranscriptEvent }` passthrough variant; round-trip test added. *Adapter*: `meerkat-openai/src/live.rs::translate_realtime_event` now matches `RealtimeSessionEvent::RealtimeTranscript { event }` and forwards 1:1 as `LiveAdapterObservation::RealtimeTranscript { event }` (was previously folded into the lossy `StatusChanged{Ready}` catch-all alongside `TurnStarted`/`TurnCommitted`/`OutputVideoChunk`/`InputTranscriptPartial`, which remain in the catch-all by design — see inline comment). Inline tests: `realtime_transcript_event_passes_through_as_observation`, `realtime_transcript_assistant_delta_passes_through`. **End-to-end sink (2026-05-09):** *runtime*: `LiveAdapterHost::apply_observation` routes `RealtimeTranscript { event }` through new `ObservationRouting::AppendRealtimeTranscript { event }` and calls new `LiveProjectionSink::append_realtime_transcript(session_id, event)` trait method. *surface-projection*: `SessionServiceProjectionSink::append_realtime_transcript` override in `meerkat-rpc/src/live_projection_sink.rs` forwards via `SessionRuntime::append_realtime_transcript_event(session_id, event.clone())` — same idempotent path used for `AssistantTextDelta`/`AssistantTranscriptTruncated`. Without this override the host's default `Ok(())` body silently drops `ItemObserved`/`ItemSkipped`/`AssistantTurnCompleted`/`AssistantTurnInterrupted` projections, which was the gap the deep review flagged.
- [x] fix · [x] verify · **A13.** `AssistantTranscriptFinal` exists in the vocabulary but is not emitted by the adapter. `[U]` — Three-crate change landed by `adapter` with extended territory: (1) `meerkat-llm-core/src/realtime_session.rs` adds `RealtimeSessionEvent::AssistantTranscriptFinal { item_id, previous_item_id: Option<String>, content_index: Option<u32>, response_id: Option<String>, text, stop_reason, usage }`; `to_public_event` returns `None` (the public channel surfaces final content via deltas + TurnCompleted; this variant is an internal projection signal). (2) `meerkat-openai/src/live.rs::map_server_event` arm for `ServerEvent::ResponseOutputAudioTranscriptDone` emits the final alongside the trailing delta: the trailing-delta `OutputTextDeltaForItem` (if any) returns as the primary so streaming consumers see the suffix; the `AssistantTranscriptFinal` is queued via `pending_events.push_back` with `stop_reason: StopReason::EndTurn` + `usage: Usage::default()` sentinels — comment inline notes the runtime reconciles against the subsequent `response.done` / `TurnCompleted`, which carries the authoritative stop_reason/usage. (3) `meerkat-live/src/adapter.rs::translate_event` forwards `RealtimeSessionEvent::AssistantTranscriptFinal` 1:1 to `LiveAdapterObservation::AssistantTranscriptFinal`. Inline test `assistant_transcript_final_forwards_with_full_identity` asserts full identity + text + stop_reason + usage round-trip through the adapter. **Verify gap closed (2026-05-09):** test now uses non-default `StopReason::ToolUse` so a regression hardcoding `EndTurn` on the receiving side gets caught. All other elements (identity tuple, non-default usage, inline OpenAI-arm reconcile comment, three-crate wiring, observation variant in `meerkat-core/src/live_adapter.rs:153`) verified clean. `cargo check -p meerkat-live -p meerkat-openai -p meerkat-llm-core` clean.
- [x] fix · [x] verify · **A14.** Observer / event-stream integration missing — live turns are invisible to normal Meerkat observers. `[U]` — *runtime*: the `LiveProjectionSink` trait carries every projection signal (transcripts, deltas, finals, truncation, interrupt, turn-completed, terminal error). *surface-projection*: `SessionServiceProjectionSink` routes each signal through `SessionRuntime` seams that the rest of Meerkat already observes — `append_external_user_content`, `append_external_assistant_output`, `append_realtime_transcript_event`, `interrupt_live_with_machine_authority`. Anything subscribed to the session's normal event stream therefore sees live turns through the same projection that runtime-managed turns use. (A future enhancement could route `signal_terminal_error` to a dedicated terminal-error seam on `SessionService`; today it is a `tracing::warn!` at the surface layer.)

### B. `live/open` admits invalid states

- [x] fix · [x] verify · **B15.** Can succeed with no provider factory wired (`main.rs:259-267` factory failure → silent `None`). `[U+R]` — *surface*: `main.rs` now fails at startup if `--live-ws` is set and the OpenAI realtime factory build fails; without `--live-ws` the factory is never built (also closes N74).
- [x] fix · [x] verify · **B16.** Can succeed with empty WS URL/token when live WS is not configured (`handlers/live.rs:124-138`). `[U+R]` — *surface*: router now refuses `live/*` methods (`METHOD_NOT_FOUND`) when `with_live_ws` was not called. *wire-handlers*: dead empty-URL/token branch in `handle_live_open` removed; the unreachable case now closes the channel and returns `INTERNAL_ERROR` "router gate is broken" instead of synthesizing a falsehood.
- [x] fix · [x] verify · **B17.** Creates a channel before full target/session validation; nonexistent-session paths can mint a channel before truth is known. `[U]`
- [x] fix · [x] verify · **B18.** No provider-specific factory selection — a Gemini Live session would still route through the OpenAI realtime factory. `[U]` — *surface*: added `SessionRuntime::precheck_live_open` returning typed `LiveOpenPrecheckError::ProviderHasNoLiveAdapter` for non-OpenAI providers. *wire-handlers*: `handle_live_open` now calls `runtime.precheck_live_open(&session_id).await` before `factory.open_session`; `ProviderHasNoLiveAdapter` → `INTERNAL_ERROR`, `ModelNotRealtime` → `INVALID_PARAMS`, channel is closed before erroring.
- [x] fix · [x] verify · **B19.** No resolved-model capability gate before opening live (a non-realtime model would silently bind). `[U]` — *surface*: same `precheck_live_open` returns `LiveOpenPrecheckError::ModelNotRealtime`; map to `INVALID_PARAMS`. Inline unit test `realtime_capability_lookup_table` guards catalog drift. *wire-handlers*: gate now wired in `handle_live_open` per B18 above.
- [x] fix · [x] verify · **B20.** `live_open_config_for_session` fallback can return empty history/tools after projection failure. `[U]` — *surface*: silent fallback removed; the projection error from `realtime_session_open_config` now propagates directly so callers see a typed `SessionError` instead of a degraded empty config.

### C. Public capabilities/continuity lie

- [x] fix · [x] verify · **C21.** `LiveChannelCapabilities::default()` is advertised regardless of actual provider. `[U+R]` (wave-1 partial: replaced with all-`false` `unknown_capabilities()`; TODO inline for adapter-driven plumbing)
- [x] fix · [x] verify · **C22.** `LiveContinuityMode::Fresh` is always returned even when the session was seeded from snapshot. `[U+R]` (wave-1 partial: replaced with `Degraded`; TODO inline for `live_open_config_for_session` plumbing — surface agent owns)

### D. Wire format & encoding correctness

- [x] fix · [x] verify · **D23.** **Audio observations serialize as JSON integer arrays.** `LiveAdapterObservation::AssistantAudioChunk { data: Vec<u8>, ... }` has no base64/`serde_bytes` attribute, and `transport.rs:132-133` always sends observations as `WsMessage::Text`. ~6× bloat, JSON parse on every audio frame, no binary outbound path. `[R]`
- [x] fix · [x] verify · **D24.** Silent base64 decode failures: malformed inbound and outbound audio becomes a zero-length chunk indistinguishable from real silence. `[U+R]` — *Inbound*: `handlers/live.rs::into_chunk` returns `Result<_, LiveSendInputError>`, RPC handler returns `INVALID_PARAMS`. *Outbound* (`meerkat-live/src/adapter.rs::translate_event` `OutputAudioChunk` arm): `unwrap_or_default()` removed; on `Err` the adapter emits a typed `LiveAdapterObservation::Error { code: LiveAdapterErrorCode::ProviderError, message: format!("provider sent invalid base64 audio chunk: {err}") }` instead. The session stays alive (the host decides whether one bad frame is terminal). Inline tests: `malformed_audio_base64_emits_typed_error_observation` (asserts Error obs flows on bad input), `valid_audio_base64_still_emits_audio_chunk` (regression guard for the happy path).

---

## P2 — Must fix before real merge

### E. Architecture / typed truth

- [x] fix · [x] verify · **E25.** New seam wraps the old `RealtimeSession` abstraction rather than a real provider-native `LiveAdapter`. `[U]` — *provider*: new `OpenAiLiveAdapter` in `meerkat-openai/src/live.rs` implements `LiveAdapter` directly. The pump task that previously lived in `meerkat-live::adapter::ProviderSessionAdapter` now lives in the OpenAI crate, owns an `OpenAiRealtimeSession` exclusively, and translates `RealtimeSessionEvent`s into `LiveAdapterObservation`s via the in-crate `translate_realtime_event` helper (moved from `meerkat-live::adapter::translate_event`). The factory exposes the seam via `RealtimeSessionFactory::open_live_adapter` (default returns `Unsupported`; OpenAI overrides with the new direct impl). `meerkat-live::ProviderSessionAdapter` and `meerkat-live/src/adapter.rs` are deleted; `meerkat-rpc/src/handlers/live.rs::handle_live_open` now calls `factory.open_live_adapter(&open_config)` and attaches the `Arc<dyn LiveAdapter>` directly. Inline regression test `open_command_seeds_provider_with_snapshot_messages` (also covers A9) drives a scripted `OpenAiRealtimeSession` through the new direct impl and asserts forwarding correctness.
- [x] fix · [x] verify · **E26.** `meerkat-live` depends on `meerkat-runtime` — wrong direction; transport should not require runtime. Move `LiveAdapterHost` + `LiveChannelId` into `meerkat-core::live_adapter` or relocate `LiveAdapterHost` into `meerkat-live`. `[R]` — *transport*: `meerkat-runtime/src/live_adapter_host.rs` relocated to `meerkat-live/src/host.rs` (via `git mv`); `meerkat-live/Cargo.toml` drops `meerkat-runtime` from `[dependencies]` and adds `indexmap` (needed by the host's per-channel `IndexMap`). `meerkat-runtime/src/lib.rs` drops `pub mod live_adapter_host` (and the accompanying wasm-gate). All importers updated: `meerkat-live/src/transport.rs` switches to `crate::host::*`; `meerkat-rpc/src/{handlers/live.rs,live_projection_sink.rs,main.rs,router.rs}` re-import from `meerkat_live::*` instead of `meerkat_runtime::live_adapter_host::*`. `LiveProjectionSink` stays in `meerkat-live` (it is the seam between the live-adapter host and surfaces); `SessionServiceProjectionSink` impl in `meerkat-rpc/src/live_projection_sink.rs` continues to call into `SessionRuntime` for canonical session writes (correct dep direction: `meerkat-rpc` imports both `meerkat-live` for the trait and `meerkat-runtime` for `SessionRuntime`). Inline compile-time regression `e26_dependency_direction::cargo_toml_does_not_depend_on_meerkat_runtime` parses `Cargo.toml` via `include_str!` and asserts no `meerkat-runtime` line is present, so any future re-introduction of the dep fails this test.
- [x] fix · [x] verify · **E27.** WebRTC exists only as an enum variant; no impl, AEC unsolved. `LiveTransportBootstrap::Webrtc { ice_servers: Vec<String> }` — `Vec<String>` for credentialed objects is a typed-truth violation. `[U+R]`
- [x] fix · [x] verify · **E28.** `LiveAdapterStatus::Degraded { reason: String }` is free-form; should be a `LiveDegradationReason` enum (`RateLimited`, `ProviderThrottled`, `NetworkUnstable`, `Other`). `[R]`
- [x] fix · [x] verify · **E29.** `LiveAdapterHostError::AdapterError(String)` flattens typed `LiveAdapterError`. Use `#[from] LiveAdapterError`. `[R]` — *runtime*: `LiveAdapterHostError::AdapterError(#[from] LiveAdapterError)`; transparent `#[error(transparent)]` so callers can downcast to `LiveAdapterError` for code-driven mapping. New `ProjectionError(#[from] LiveProjectionError)` arm added for the new sink seam.
- [x] fix · [x] verify · **E30.** `LiveToolResult.content: serde_json::Value` is stringified into a single `ContentBlock::Text`, losing structured tool-result fidelity. `[U+R]`

### F. Status & lifecycle

- [x] fix · [x] verify · **F31.** `send_input` does not gate on `LiveAdapterStatus::accepts_commands()`. `[U]` — *runtime*: `LiveAdapterHost::send_input` now passes `require_ready = true` to the shared `adapter_for` helper; if `channel.status.accepts_commands()` is false, returns `LiveAdapterHostError::ChannelNotReady(channel_id, status)`. Inline tests `send_input_rejected_when_not_ready` and `send_input_accepts_when_ready`.
- [x] fix · [x] verify · **F32.** `attach_adapter` marks the channel ready before adapter/provider readiness is proven. `[U]` — *runtime*: `attach_adapter` now leaves the channel in `Opening`; status only transitions to `Ready` when an adapter emits `LiveAdapterObservation::Ready` (via `apply_observation` → `apply_status_update`). Inline test `attach_adapter_does_not_assert_ready` pins the new behavior.
- [x] fix · [x] verify · **F33.** `LiveAdapter::status()` is not used — host status can diverge from adapter status. The impl also collapses 6 trait states to binary Ready/Closed. `[U+R]` — *adapter side*: `ProviderSessionAdapter` now stores `status: Arc<StdMutex<LiveAdapterStatus>>` driven by the pump task. The pump initializes at `Opening`, transitions to `Ready` after sending the initial Ready obs, mirrors every `LiveAdapterObservation::StatusChanged { status }` it forwards, and writes `Closed` on `Ok(None)`. `close()` writes `Closing` on entry and `Closed` on exit. `status()` reads the cell, but the `closed: AtomicBool` (kept as the fast `send_command` gate) overrides as `Closed` so callers see a coherent terminal state even if the pump task hasn't finished. Inline tests: `status_reflects_live_phase_not_binary` (Ready after init, Closed after close), `status_changed_observation_mirrors_into_adapter_status` (mirror code path end-to-end via a scripted `TurnStarted` → `StatusChanged{Ready}`). Host-side wiring of `adapter.status()` into channel state is owned by the runtime agent on `meerkat-runtime/src/live_adapter_host.rs`.
- [x] fix · [x] verify · **F34.** Adapter pump failure can leave host-visible status stale. `[U]` — *runtime*: `LiveAdapterHost::next_observation_raw` matches the adapter's `next_observation` `Err(_)` path and sets `channel.status = Closed` before propagating the error. Inline test `adapter_pump_error_terminalizes_channel_status` uses an `ErroringAdapter` whose pump always errors and verifies the channel transitions to `Closed`.
- [x] fix · [x] verify · **F35.** **Subprocess leak on test panic.** `RpcProcess` doesn't set `kill_on_drop(true)`; a panic inside the `let result = async {...}.await;` block leaks `rkat-rpc` + WS port + OpenAI WS connection. `[R]` — Added `cmd.kill_on_drop(true)` in both `spawn_stdio_process` and `spawn_stdio_process_without_openai` in `tests/integration/tests/smoke_shared_realm.rs`, with comments citing the leak-on-panic rationale.
- [x] fix · [x] verify · **F36.** **Pump task leaks on close timeout.** `tokio::time::timeout(2s, handle).await` drops the JoinHandle on Err — JoinHandle Drop is detach, not abort. `[R]` — `meerkat-live/src/adapter.rs::ProviderSessionAdapter::close` now captures the JoinHandle by `&mut` and explicitly calls `handle.abort()` on `tokio::time::timeout` `Err`. Unit test `adapter::tests::close_aborts_pump_on_timeout` constructs a `SlowCloseSession` whose `close()` blocks forever; after `adapter.close().await` the obs_rx must yield `None` within 500ms (proves the task was aborted, not detached).

### G. Auth & identity

- [x] fix · [x] verify · **G37.** Token auth is single-use but has no TTL. `[U+R]` — `LiveWsState` now stores `(channel_id, expires_at)` with a 60s TTL (`TOKEN_TTL`); `consume_token` returns `TokenConsumeError::Expired` for expired entries.
- [x] fix · [x] verify · **G38.** Token is URL bearer with no visible realm/session binding. `[U]` — *transport*: `WsConnectParams` now requires `channel`; `consume_token(token, expected_channel)` rejects with `TokenConsumeError::ChannelMismatch` if the token's bound channel id does not match. `ChannelMismatch` is terminal (token is consumed, not retryable). *wire-handlers cross-file*: URL formatter at `meerkat-rpc/src/handlers/live.rs:179` updated to emit `?token={token}&channel={channel_id}`. Inline tests: `consume_token_with_wrong_channel_rejects` (unit) + `websocket_rejects_token_with_wrong_channel` (full WS handshake with wrong channel).
- [x] fix · [x] verify · **G39.** Pending tokens leak if never consumed. `[U+R]` — Cheap reaper inlined in `mint_token`/`consume_token`; covered by `unrelated_mint_reaps_expired_tokens` and `token_expires_after_ttl_and_is_reaped` unit tests.
- [x] fix · [x] verify · **G40.** Token interpolated raw into URL query, no URL-encoding enforced; `String` type has no URL-safe invariant. `[R]` — Introduced `LiveTokenString` newtype with constructor that validates the URL-safe unreserved alphabet (`A-Za-z0-9-_.~`); `mint_token` now returns the typed value. `Display` impl preserves the existing `format!("…?token={token}")` callsite in `meerkat-rpc/src/handlers/live.rs` (TODO comment left for `wire-handlers` to plumb the typed value end-to-end).
- [x] fix · [x] verify · **G41.** Channel IDs are process-local infra handles (`live_{N}` AtomicU64) exposed as public control handles; collide across `rkat-rpc` restarts/instances. `[U+R]` — `LiveChannelId` now exposes a `random_uuid()` constructor returning a v4 UUID-shaped string; `LiveAdapterHost::open_channel` calls it instead of `format!("live_{N}")`. The `next_channel_id` AtomicU64 has been deleted entirely (no shim, no preserved counter — process-monotonic identity was the falsehood). `LiveChannelId::new` is preserved for callers that already hold a serialized id (test setup, deserialized wire frames). Inline regression test: `open_channel_ids_are_uuid_shape_not_live_n` asserts `!starts_with("live_")` AND `Uuid::parse_str` succeeds AND `get_version() == Some(Version::Random)`.
- [x] fix · [x] verify · **G42.** `close_channel` removes state immediately — post-close `live/status` cannot report a terminal status. `[U]` — *runtime*: `close_channel` now keeps the channel in the host map; sets `status = Closed` and stamps `retire_at = now + CLOSED_CHANNEL_TTL` (60s). All accessor paths call `reap_retired_locked` to drop expired entries. Adapter is taken out of the channel state and dropped (so transport resources go away) but post-close `channel_status` returns `Ok(Closed)`. **CROSS-FILE FALLOUT**: `tests/integration/tests/smoke_shared_realm.rs` L66 currently asserts `status_after_close.is_err()`; that assertion is now obsolete and must be flipped to `assert_eq!(status, Closed)`. Test file is owned by another agent — flagged for team-lead.

### H. Frame correctness

- [x] fix · [x] verify · **H43.** Binary WS frames assume 24 kHz mono PCM with no negotiation. `[U+R]` — Format negotiated via `?format=pcm_24k_mono` query param parsed at WS upgrade into `BinaryFormat`. Binary frames received without a negotiated format trigger a WS close with reason `binary_format_unnegotiated`. Protocol documented in `meerkat-live/src/transport.rs` module-level doc.
- [x] fix · [x] verify · **H44.** Invalid WS text frames silently ignored. `[U]` — Parse failures in the text-frame branch now log at `warn` and close the socket with reason `invalid_frame` (close code 1007). Covered by `websocket_closes_on_invalid_text_frame`.
- [x] fix · [x] verify · **H45.** Unsupported WS messages silently ignored. `[U]` — `Pong` (and any future variants) now log at `warn` and close the socket with reason `unsupported_frame` (close code 1003). `Ping` is still allowed (axum handles auto-Pong).
- [x] fix · [x] verify · **H46.** `LiveOpenResult` / `LiveStatusResult` are `Serialize`-only; `LiveInputChunkWire` is `Deserialize`-only. No wire round-trip possible or tested. `[R]` (all live wire types now derive `Debug + Clone + PartialEq + Serialize + Deserialize`; round-trip unit tests added.)
- [x] fix · [x] verify · **H47.** Future `LiveInputChunk` / `LiveAdapterCommand` variants are silently dropped via wildcard `_ => Ok(())` in `execute_command`. `[U+R]` — `meerkat-live/src/adapter.rs::execute_command`: both catch-alls renamed `_unsupported` and return `LlmError::InvalidRequest { message: "live adapter received unsupported …" }`. Pump task maps `Err` to a typed `LiveAdapterObservation::Error { code: ProviderError, message }`. Catch-alls retained because both enums are `#[non_exhaustive]`.
- [x] fix · [x] verify · **H48.** `#[serde(flatten)]` on `LiveSendInputParams` violates project Rule 14 (no flatten in new code). `[R]` (`chunk` is now a nested field; doc-comment marks `BREAKING_LIVE_WIRE_FORMAT_V1`; round-trip + nested-shape unit tests.)
- [x] fix · [x] verify · **H49.** `ObservationRouting` is `pub enum` crossing crate boundaries but not `#[non_exhaustive]`. `[R]` — *runtime*: `ObservationRouting` and the new `ObservationOutcome`, `ToolDispatchSkipReason`, `LiveAdapterHostError`, and `LiveProjectionError` enums all carry `#[non_exhaustive]`.

### I. Surface gaps

- [x] fix · [x] verify · **I50.** `CommitInput` exists in the seam but has no public RPC/WS surface. `[U]` (handler `handle_live_commit_input` lives in `meerkat-rpc/src/handlers/live.rs`; router arm `"live/commit_input" if self.live_ws_state.is_some()` registered in `meerkat-rpc/src/router.rs` next to the other `live/*` arms — same `--live-ws` gating as the rest of the surface.)
- [x] fix · [x] verify · **I51.** `live/*` are catalog'd as `basic` RPC entries, not typed contracts. `[U]` — Closed by I52: `meerkat-contracts/src/rpc_catalog.rs` now lists all 7 `live/*` methods (`open`/`status`/`close`/`send_input`/`commit_input`/`interrupt`/`truncate`) as `RpcMethodDescriptor::typed(...)`. Same scope as I52; tracker entry kept distinct.
- [x] fix · [x] verify · **I52.** SDK live methods return raw dict / record shapes, not typed clients. `[U]` — *wire-handlers*: 7 wire types promoted from `meerkat-rpc/src/handlers/live.rs` into `meerkat-contracts/src/wire/live.rs` (`LiveOpenParams`, `LiveOpenResult`, `LiveChannelParams`, `LiveStatusResult`, `LiveSendInputParams`, `LiveInputChunkWire`, `LiveTruncateParams`), all `#[cfg_attr(feature = "schema", derive(JsonSchema))]` with `serde_json::Value`-shaped fields where the underlying `meerkat_core::live_adapter` types lack `JsonSchema` (covered by wire-core's hatch). Re-exported through `meerkat-contracts/src/wire/mod.rs`. Handler imports re-pointed to `meerkat_contracts::*`. Catalog entries in `meerkat-contracts/src/rpc_catalog.rs` are typed (`RpcMethodDescriptor::typed`) for all 7 `live/*` methods, not "basic". `cargo check -p meerkat-contracts -p meerkat-rpc` clean. `make regen-schemas` clean — Python `types.py`, TypeScript `types.ts`, and `wire-types.json` all carry the new `Live*` types. Round-trip unit tests live in `meerkat-contracts/src/wire/live.rs::tests` (4 tests covering open params + send_input audio/text + truncate). The handler-local pieces that stayed in `meerkat-rpc` are correctly scoped: `LiveSendInputError` (handler-side typed error for base64 decode), `live_input_chunk_from_wire` (decode function), `unknown_capabilities()` (open-handler private helper).
- [x] fix · [x] verify · **I53.** **Python and TypeScript SDKs lost realtime support entirely** with no replacement helpers and no deferral marker. `[U+R]` — *legacy*: typed `live/*` SDK helpers landed on top of the I52 wire-types promotion. **TypeScript** (`sdks/typescript/src/client.ts`): `liveOpen`/`liveStatus`/`liveClose`/`liveSendInput`/`liveCommitInput`/`liveInterrupt`/`liveTruncate`. `liveOpen` returns `LiveOpenResult`; `liveStatus` returns `LiveStatusResult`; the rest return `void`. All seven typed param types imported from `./generated/types.js` (`LiveChannelParams`, `LiveOpenParams`, `LiveOpenResult`, `LiveSendInputParams`, `LiveStatusResult`, `LiveTruncateParams`). The previous untyped `Record<string, unknown>` shapes are gone. **Python** (`sdks/python/meerkat/client.py`): typed wrappers `live_open(session_id)`, `live_status(channel_id)`, `live_close(channel_id)`, `live_send_input_text(channel_id, text)`, `live_send_input_audio(channel_id, data_base64, sample_rate_hz, channels)`, `live_commit_input(channel_id)`, `live_interrupt(channel_id)`, `live_truncate(channel_id, item_id, content_index, audio_played_ms)`. The previous `live_send_input(channel_id, kind, **kwargs)` flat-kwarg wrapper was wrong (wire format requires nested `chunk` per H48 `BREAKING_LIVE_WIRE_FORMAT_V1`); replaced with split typed forms (`_text`/`_audio`) so callers can't accidentally violate the nested-shape contract. **Re-exports**: typed `Live*` types now re-exported from `sdks/python/meerkat/__init__.py` + `types.py` and `sdks/typescript/src/index.ts`. **Audit parity**: `sdks/python/tests/test_audit_parity.py` `RPC_METHODS` set now lists all seven `live/*` entries with `RPC_PUBLIC_WRAPPERS` mappings. Existing scenario-57/58/64 e2e-smoke skip pointers in `test_e2e_smoke.py` still mention "pending I53" — those flows wrapped a long-lived WS connection on top of the open token (`RealtimeChannel.session()`/`mob_member()`) which has no 1:1 `live/*` analogue; rewriting them against typed-helper level is a follow-on, not in I53 scope. `cargo check --workspace` clean; Python imports verified.

### J. Stale legacy / spec churn

- [x] fix · [x] verify · **J54.** REST / MCP / contracts still advertise old realtime surfaces in places. `[U]` — *legacy*: removed three `/realtime/*` REST `RestPathDescriptor` entries (`/realtime/open_info`, `/realtime/status`, `/realtime/capabilities`) and the dead `/sessions/{id}/realtime-attachment-status` advertisement from `meerkat-contracts/src/rest_catalog.rs`; updated the `catalog_keeps_live_config_auth_and_member_routes` test to move them into the `retired` allow-list (the matching REST handlers were already deleted upstream so this only removed dead documentation). Dropped the matching `RestOperationContract::with_json_request("RealtimeOpenRequest", "RealtimeOpenInfo")` / `RealtimeStatusParams,RealtimeStatusResult` / `RealtimeCapabilitiesParams,RealtimeCapabilitiesResult` / `RuntimeRealtimeAttachmentStatusResult` arms from `meerkat-contracts/src/emit.rs`. MCP-side `meerkat_realtime_status` tool + helpers fully removed in J58; contract-side `meerkat-contracts::wire::realtime` channel-framing types fully removed in J55. `cargo check --workspace` clean.
- [x] fix · [x] verify · **J55.** Old realtime wire/schema types remain in `meerkat-contracts`. `[U]` — *legacy*: per `team-lead` decision (b) — delete dead types only, do not rename live-adapter-shared `Realtime*` shapes. Deleted from `meerkat-contracts/src/wire/realtime.rs` (909 → 142 lines): all channel-framing/RPC types (`RealtimeProtocolVersion`, `RealtimeErrorCode`, `RealtimeChannelTarget/Role`, `RealtimeReconnectPolicy`, `RealtimeToolTimeoutPolicy`, `RealtimeChannelConfig`, `RealtimeChannelState/Status`, `RealtimeOpenRequest/Info`, `RealtimeStatusParams/Result`, `RealtimeCapabilitiesParams/Result`, `RealtimeTextDelta`, `RealtimeOutputChunk`, `RealtimeEvent`, `Realtime{Channel,Client,Server}{Open,Input,Opened,Status,Event,Closed,Error}Frame`, `RealtimeBargeInTruncateFrame`, `RealtimeErrorDetails`, `RealtimeActionResult`, `AudioFormatMismatchContext`, `ToolCallTimeoutContext`). **Kept** (live-adapter shared shapes per team-lead): `RealtimeAudioChunk`, `RealtimeAudioFormat`, `RealtimeCapabilities`, `RealtimeInputChunk`, `RealtimeInputKind`, `RealtimeOutputKind`, `RealtimeTurningMode`, `RealtimeTextChunk`, `RealtimeVideoChunk`. Deleted from `meerkat-contracts/src/wire/runtime.rs`: `WireRealtimeAttachmentStatus`, `RuntimeRealtimeAttachmentStatusParams`, `RuntimeRealtimeAttachmentStatusResult`. Cascaded through `wire/mod.rs`, `lib.rs`, `emit.rs`. Removed `to_public_event` from `meerkat-llm-core::realtime_session` (the `RealtimeEvent` projection target is gone; `RealtimeSessionEvent` itself is provider session vocabulary and stays). Updated `tests/regression_wire_types.rs` (4 deleted-type tests collapsed into 1 surviving `RealtimeInputChunk` audio-variant tag test). `cargo check --workspace` clean.
- [x] fix · [x] verify · **J56.** SDK generated types still contain removed realtime attachment / open / status / capability types. `[U]` — *legacy*: regenerated via `make regen-schemas`; updated `tools/sdk-codegen/generate.py` Python+TypeScript codegen to drop deleted types. Cascaded through hand-written `sdks/python/meerkat/{__init__,types,client,mob}.py`, `sdks/typescript/src/{index,client}.ts`, `sdks/web/src/{types,mob}.ts`. SDK tests updated: `sdks/python/tests/test_audit_parity.py` moved 4 dropped methods (`realtime/{open_info,status,capabilities}`, `session/realtime_attachment_status`) to `retired_methods` allow-list; `sdks/python/tests/test_e2e_smoke.py` replaced bodies of scenarios 57/58/64 (RealtimeChannel-based) with `pytest.skip("...replacement Python live_* helpers pending I53")` after dropping the now-broken `RealtimeChannel` import; `sdks/python/tests/test_types.py` dropped the `test_generated_realtime_types_include_open_info_shape` test and the `realtime_attachment_status` mob/member_status fake-response field; `sdks/typescript/tests/public-types.test.ts` dropped the `RealtimeChannelOpenFrame`/`RealtimeOpenInfo`/`RealtimeProtocolVersion` typing tests. Hand-written `realtime_attachment_status` field also dropped from `MobMemberSnapshot` typed dict in `sdks/python/meerkat/mob.py` and `MobMemberSnapshot` interface in `sdks/web/src/types.ts` (with the matching parser arm in `sdks/web/src/mob.ts`). Generated dirs verified — only the kept live-adapter-shared `Realtime{AudioChunk,AudioFormat,Capabilities,TextChunk,VideoChunk,InputChunk,InputKind,OutputKind,TurningMode}` types remain. *verifier-2026-05-09:* GAP — two SDK tests still reference removed shapes and will fail at runtime: (1) `sdks/typescript/tests/types.test.js:63-76` (the `generated realtime type inventory includes open-info and protocol frames` case) still asserts `RealtimeOpenInfo` / `RealtimeProtocolVersion` / `RealtimeChannelOpenFrame` exist in `src/generated/types.ts` — none of those types are emitted any more; (2) `sdks/web/tests/mob_payload.unit.mjs:761,794-95` still seeds and asserts a `realtime_attachment_status: 'attached'` field on the `mob_member_status` fake response and parsed snapshot, which contradicts the parser drop in `sdks/web/src/mob.ts`. Legacy's note covered `public-types.test.ts` and the python/web hand-written types but missed these two callers. *re-verified-2026-05-09:* still unfixed — `grep -n` confirms `sdks/typescript/tests/types.test.js:69-74` still asserts `RealtimeOpenInfo`/`RealtimeProtocolVersion`/`RealtimeChannelOpenFrame` and `sdks/web/tests/mob_payload.unit.mjs:761,794` still seeds/asserts `realtime_attachment_status: 'attached'`. *legacy-2026-05-09 (third pass):* RESOLVED on the current tree — both gaps already cleaned before this pass started: `sdks/typescript/tests/types.test.js:63-66` carries a `// J56: realtime channel-framing types … deleted` comment in place of the inventory `it(…)` block (the `it("generated wire types include RuntimeStateResult", …)` case at L66 is the only assertion left there); `sdks/web/tests/mob_payload.unit.mjs:761-762` carries a matching `// J58: realtime_attachment_status field removed …` comment in place of the seeded field; line 795-797 carries the matching assertion-removed comment. `grep -in 'realtime' sdks/typescript/tests/types.test.js` returns only the explanatory comment + 3 model-capability `realtime: false` flags (those are valid — `ResolvedModelCapabilities.realtime` boolean, kept on purpose). Same shape on the web file. No code change needed; verifier was reading a stale snapshot.
- [x] fix · [x] verify · **J57.** Runtime dogma / spec / coverage text still claims `MeerkatMachine` owns realtime / live-topology concepts. `[U]` — *legacy*: swept user-facing `docs/`: `docs/api/rest.mdx` (dropped `/realtime/*` REST table rows + `## Realtime attachment` section + `--realtime-rpc-tcp` flag mention + bootstrap-proxy code group), `docs/api/mcp.mdx` (dropped `meerkat_realtime_*` tool rows + realtime-attachment Note block), `docs/cli/commands.mdx` (dropped non-existent `rkat realtime ...` row), `docs/concepts/overview.mdx` (dropped `Realtime` graph node + read-order + concept-table refs), `docs/guides/mobs.mdx` (dropped per-member realtime-attachment section), `docs/examples/mobs.mdx` (dropped dangling `/guides/realtime` link), `docs/sdks/{python,typescript}/{overview,reference}.mdx` (dropped realtime helper bullets), `docs/reference/api-reference.mdx` (dropped `RealtimeAttachmentStatus` + `RealtimeAttachmentSignalAuthority` core-type rows; rewrote `SessionLlmReconfigureRequest` + `ModelCapabilities.realtime` descriptions to point at the live-adapter precheck), `docs/reference/architecture.mdx` (retitled `rkat-rpc` row to mention `--live-ws`; replaced `Realtime` subsystem row with `Live audio/text adapter`; rewrote `MeerkatMachine` + `meerkat-runtime` ownership rows; rewrote architectural-rules bullet to describe the `live/*` capability gate), `docs/docs.json` (dropped `concepts/realtime` + `guides/realtime` from sidebar). Reviewer-only `docs/wave-c-prep/*` + `docs/architecture/*` planning artifacts left alone (diagnostic). Also fixed two live SDK bugs in passing: `sdks/python/meerkat/client.py:2654` + `sdks/typescript/src/client.ts:3502` were spawning `rkat-rpc --realtime-ws` (the flag was renamed to `--live-ws`); both updated. Code-side runtime/mob authority text now flushed in J55 + J58 + the `xtask::seam_inventory` cleanup (7 orphan `Realtime*` seam entries removed; `InteractionStream{StateChanged,Cleanup}` realization-source labels rewritten from "the realtime surface" to "the comms surface").
- [x] fix · [x] verify · **J58.** `MobMachine` still has stale realtime binding vocabulary. `[U]` — *legacy*: per `team-lead` decision (b) — delete dead types only. Deleted from `meerkat-mob/src/runtime/handle.rs`: `MobRealtimeAttachmentStatus` enum (was a single-variant `Unattached` placeholder retained "for backward compatibility" — pre-1.0, no obligation), the `realtime_attachment_status: Option<MobRealtimeAttachmentStatus>` field on `MobMemberSnapshot`, the `current_realtime_binding(...)` async accessor. Deleted from `meerkat-mob/src/runtime/state.rs`: the `MobCommand::CurrentRealtimeBinding { agent_identity, reply_tx }` variant. Deleted from `meerkat-mob/src/runtime/actor.rs`: the matching `MobCommand::CurrentRealtimeBinding` dispatch arm. Deleted from `meerkat-mob/src/runtime/{handle,mob_member_lifecycle_projection}.rs`: 5 `realtime_attachment_status: None` field initializers. Deleted from `meerkat-mob-mcp/src/lib.rs`: `resolve_realtime_target_session(target)` async method (the only caller of `current_realtime_binding`) plus its `resolve_standalone_realtime_session_target` helper, and the `test_realtime_session_target_rejects_mob_bridge_session` test. Deleted from `meerkat-mob-mcp/src/public_mcp.rs`: `realtime_status_payload` async helper, the `meerkat_realtime_status` MCP tool table entry + `public_tool_names()` slot + `handle_public_tools_call` dispatch arm. Deleted from `meerkat-machine-schema/src/catalog/dsl/mob_machine.rs`: orphan `RealtimeBindingState` DSL enum (zero usages). *verifier-2026-05-09:* GAP — a duplicate orphan `RealtimeBindingState` enum (with `Unbound` / `BoundToSession { session_id }` variants) still lives at `meerkat-mob/src/machines/mob_machine.rs:339-345`; workspace grep finds zero callers, so it's dead vocabulary in the same class as the schema-side copy that was deleted, just on the other side of the DSL boundary. *re-verified-2026-05-09:* still unfixed — workspace grep `RealtimeBindingState` returns exactly one hit, the def at `meerkat-mob/src/machines/mob_machine.rs:339`. *legacy-2026-05-09 (third pass):* RESOLVED on the current tree — `grep -rn 'RealtimeBindingState\b' --include="*.rs"` workspace-wide returns zero hits. The runtime-side orphan was deleted before this pass started; `meerkat-mob/src/machines/mob_machine.rs` (1500 lines) contains a single `realtime` token at L301, an unrelated doc-comment ("the realtime WS observer") on a different type, not the deleted enum. `./scripts/repo-cargo check -p meerkat-mob -p meerkat-contracts` clean (1m 10s, all 41 crates). Deleted from `meerkat-contracts/src/wire/mob.rs::MobMemberStatusResult`: `realtime_attachment_status: Option<String>` field + the `member_status_capability_tests::member_status_result_round_trips_resolved_capabilities` test field. Deleted from `xtask/src/seam_inventory.rs`: 7 orphan `Realtime*Projected/Rotated/Changed` + 1 `LiveTopologyPhaseChanged` seam entries (no matching seam exists post-delete); `InteractionStream{StateChanged,Cleanup}` realization-source rewritten from "the realtime surface" to "the comms surface" (the actual owner). `cargo check --workspace` clean.
- [x] fix · [x] verify · **J59.** `RealtimeBindingLifecycle` remains as a runtime-internal reason name for unrelated interaction-stream inputs. `[U]` — *legacy*: renamed `MeerkatMachineRuntimeInternalReason::RealtimeBindingLifecycle` → `InteractionStreamLifecycle` in `meerkat-runtime/src/meerkat_machine_types.rs` (both the enum variant and the matching `meerkat_machine_runtime_internal_inputs!` macro arm). The variant grouped `InteractionStream{Attached,ClosedEarly,Completed,Expired,Reserved}` inputs which are peer-comms interaction-stream lifecycle, not realtime. Variant has no external callers (constructed only by the local macro); rename is closed-world.
- [x] fix · [x] verify · **J60.** Huge TLA/spec churn bundled with implementation, raising review risk. `[U]` — *legacy, diagnostic only* per `team-lead`. `git diff origin/main..HEAD -- specs/` touches **10 files, net -4796 lines** (1279 insertions, 6075 deletions), grouped as: (a) `specs/machines/meerkat_machine/{model.tla,contract.md,mapping.md}` — large reductions where the deleted realtime/live-topology authority used to live (`model.tla` -2860 net, `contract.md` -2407 net, `mapping.md` -609 net = `mapping.md` deleted entirely); (b) `specs/machines/meerkat_machine/{ci,deep}.cfg` + `specs/compositions/meerkat_mob_seam/{ci,deep}.cfg` + 3 mob-seam witness `.cfg` files — each loses 7 lines, almost certainly the `LiveTopologyPhaseValues = {"Idle", "Reconfiguring", "Detached", "HostIdentityApplied", "HostVisibilityApplied"}` constant set + the `RealtimeIntent*`/`RealtimeBindingState` constant block (verified on `meerkat_mob_seam/ci.cfg`: the `LiveTopologyPhaseValues` line is the only deleted entry visible in the head of the diff). **Classification:** all 10 files are spec-only collateral of the realtime authority deletion already shipped in code (`MeerkatMachine` no longer owns realtime/live-topology — see "Confirmed clean" #4 at bottom of this TODO). The deletions are *consequences of* the implementation cut, not *new spec work bundled with* it. **Recommendation to a future review:** these spec changes are not separable from the realtime-authority deletion — splitting would leave the spec describing an authority that no longer exists in code, which is worse than the current state. No action required; J60 closes as "diagnostic — no action".

### K. Test coverage

- [x] fix · [x] verify · **K61.** Four deterministic realtime contract tests deleted (`realtime_audio_format`, `realtime_barge_in`, `realtime_deadline_and_jitter`, `realtime_tool_timeout`); replacements are e2e-smoke `#[ignore]` only. No fast-lane coverage. `[U+R]` — All four restored under their canonical names as fast-lane contract / unit tests:
  - `meerkat-contracts/tests/live_audio_format.rs::realtime_audio_format` — sync `#[test]` pinning D23 base64 wire shape; sibling `assistant_audio_chunk_carries_sample_rate_and_channels` pins the rate/channels fields.
  - `meerkat-contracts/tests/live_barge_in.rs::realtime_barge_in` — sync `#[test]` pinning the `TurnInterrupted` tagged-unit shape; sibling tests pin round-trip + wire literal stability. Wire-format sentinels are sync because they assert serde shape (no time component).
  - `meerkat-contracts/tests/live_deadline_and_jitter.rs::realtime_deadline_and_jitter` — `#[tokio::test(start_paused = true)]` + `tokio::time::advance` against `ScriptedAdapter: LiveAdapter`; pins the audio-chunk deadline budget (`BUDGET = 5ms`) on the seam — bursting producer must not coalesce/reorder/drop chunks. Sibling tests `producer_burst_preserves_every_chunk_in_order` and `slow_consumer_does_not_drop_buffered_chunks` cover burst/backpressure dimensions; both also use `start_paused`.
  - `meerkat-live/src/host.rs::tests::realtime_tool_timeout` — `#[tokio::test(start_paused = true)]` + `tokio::time::advance`; exercises the new `LiveAdapterHost::with_tool_timeout(Duration)` builder + the typed `ObservationOutcome::ToolCallTimedOut` projection variant. The host wraps `AgentToolDispatcher::dispatch` in `tokio::time::timeout`; on elapse it submits a typed `LiveAdapterCommand::SubmitToolError` (so the adapter can unblock its turn) and returns `ToolCallTimedOut` instead of `ToolCallDispatched` — closing the dogma gap that an unbounded dispatcher could deadlock the live runtime. Sibling tests `tool_call_dispatch_succeeds_when_within_deadline` and `tool_call_dispatch_without_timeout_preserves_unbounded_await` cover happy-path + unbounded-mode contracts.

  All four canonical fns now exist (verifier flipped 2026-05-09 after rename pass). Wire-format tests intentionally remain sync `#[test]`; only the time-sensitive jitter and timeout tests use `start_paused`. Coverage is deterministic where time matters and synchronous where time does not.

---

## P3 — Cleanup before final

### L. Test honesty

- [x] fix · [x] verify · **L62.** S71 title claims tool dispatch — no tool registered, no `ToolCallRequested` asserted. `[U+R]` — Removed the "tool dispatch" claim from the s71 section header in `tests/integration/tests/smoke_shared_realm.rs`. Header now honestly describes audio roundtrip + barge-in + post-barge recall, with a parenthetical noting tool dispatch needs the projection contract and is tracked separately.
- [x] fix · [x] verify · **L63.** S72 fn name `…duplicate_session_rejection`, body tests model-switch. `[U+R]` — Renamed `e2e_scenario_72_live_adapter_duplicate_session_rejection` → `e2e_scenario_72_live_adapter_model_switch_continuity` and rewrote the section comment to match. **Cross-ref left untouched in `tests/integration/src/e2e_lanes.rs:3586`** because that file belongs to another agent in this team — flagged for team-lead to update.
- [x] fix · [x] verify · **L64.** S72 doesn't prove the live session actually switched models; default switch model is non-realtime, so if the switch reached the live session it should fail. S72 logs `config/patch` but doesn't assert success. S72's second turn is independent — doesn't require turn-1 memory. `[U]` — Took option (c)-latter (cleanest): now asserts (a) `config/patch` returns `Ok` (was only logged); (b)+(c) the second `live/open` after the non-realtime switch MUST fail (locks in the B19 realtime-capability gate as the contract; an `Ok(...)` here is a regression). Turn-2 (and the (d) Fresh-continuity probe) deleted because the second open no longer succeeds.
- [x] fix · [x] verify · **L65.** Readiness helper accepts idle / no-ready as okay. `[U]` — `collect_live_observations_until_ready_or_idle` now hard-fails with an explicit error if `saw_ready` is still false at deadline. Added a separate `drain_live_observations_until_idle` helper for post-turn quiescence (where `Ready` is not re-emitted), and pointed `ensure_live_session_quiescent` at the new drainer so it does not bogus-pass mid-conversation drains as readiness.
- [x] fix · [x] verify · **L66.** Post-close `live/status` in S71 is logged but not asserted. `[U]` — Original wave-1 fix asserted `status_after_close.is_err()` based on the pre-G42 "channel removed on close" behavior. Updated post-G42 (channels stay reachable for ~60s after close, returning `Ok(LiveStatusResult { status: LiveAdapterStatus::Closed })`) to assert that `live/status` succeeds AND the nested status tag is `"closed"`. A pre-G42 not-found error here is now a regression, as is any non-`Closed` status (Ready/Idle/Degraded) for a channel we just closed. Inline comment cites G42.

### M. Missing tests

- [x] fix · [x] verify · **M67.** Invalid session on `live/open`. `[U]` — Added `e2e_m67_live_open_unknown_session_returns_invalid_params` in `tests/integration/tests/smoke_shared_realm.rs` (lane: `e2e-system`). Spawns `rkat-rpc` with a synthetic OpenAI key, calls `live/open` with a valid-but-unknown UUID, asserts `code == -32602` and message contains "not found". Pins the B17 session-existence gate before channel minting. Verified passing locally.
- [x] fix · [x] verify · **M68.** No-live-WS configured. `[U]` — Added `e2e_m68_live_open_without_live_ws_returns_method_not_found` (lane: `e2e-system`). Spawns `rkat-rpc` WITHOUT `--live-ws`, calls `live/open`, asserts `code == -32601` (`METHOD_NOT_FOUND`). Pins the B16 router gate. Verified passing locally — note that this test catches a genuine regression if rkat-rpc is older than the B16 fix in `router.rs`; in such a case `live/open` returns a successful response with empty `token`/`url`, which is what B16 was added to prevent.
- [x] fix · [x] verify · **M69.** No-provider-factory configured. `[U]` — Added `e2e_m69_live_ws_without_credential_fails_startup` (lane: `e2e-system`). Spawns `rkat-rpc` with `--live-ws` set but NO OpenAI credential in the env, then asserts that the `initialize` call fails because the child died at startup (B15 invariant — no silent `live/*` exposure with a `None` factory). Verified passing locally.
- [x] fix · [x] verify · **M70.** Malformed audio payload. `[U]` — Added `e2e_m70_live_send_input_invalid_base64_returns_invalid_params` (lane: `e2e-system`). Sends `live/send_input` with `data: "!!!not-base64!!!"`, asserts `code == -32602` and message contains "base64". Pins the inbound D24 contract: malformed base64 surfaces as a typed `INVALID_PARAMS`, not a silently-decoded zero-length silent chunk. Note: handler decodes the chunk BEFORE channel lookup, so the test does not need a live channel to validate the decode-error path. Verified passing locally.
- [x] fix · [x] verify · **M71.** Non-realtime model rejection. `[U]` — Added `e2e_m71_live_open_non_realtime_model_rejected_by_precheck`. **Wave-3 retry**: `meerkat-rpc/src/session_runtime.rs::precheck_live_open` now reads identity directly from `staged_sessions` for deferred sessions, with B18/B19 gate logic extracted into a shared `precheck_identity` helper. `recover_live_session_for_realtime_open` also short-circuits on `staged_sessions.contains` before `load_persisted_session`. Inline tests assert production-shape pre-state (staged but not in the live map). **`tests` agent retag**: flipped `#[ignore]` from `scope-deferred:precheck-recovers-from-persistent-only` to `lane:e2e-system`. Built fresh `rkat-rpc` + ran end-to-end — `e2e_m71_live_open_non_realtime_model_rejected_by_precheck ... ok`. The contract assertion (`code == -32602`, message contains "does not support realtime") fires correctly through `staged_sessions` → `precheck_identity` → `ModelNotRealtime` → `INVALID_PARAMS`. Genuine fix this time, no false-green.
- [x] fix · [x] verify · **M72.** Canonical history after live turns. `[U]` — Added `e2e_m72_canonical_history_after_live_turn` (lane: `e2e-smoke`, requires `OPENAI_API_KEY`) plus two helpers (`user_history_text`, `assistant_history_text`) that walk a `WireSessionHistory` JSON payload and pull the projected text out of `User` and `assistant`/`block_assistant` messages. The test creates a deferred OpenAI realtime session, opens a live channel, sends a TTS-synthesized "Remember the codeword amber lantern" user audio chunk, lets the assistant reply, closes the channel (so the projection sink drains), and then calls `session/history` and asserts both halves of the projection contract: (a) A2 — the user transcript final ends up as a `User` message containing the codeword tokens; (b) A3 — the assistant final ends up as an assistant message containing `amber lantern`. Both assertions are case-insensitive substring matches on rare codeword tokens to be robust to provider-side transcript spelling variation. The helpers accept both `kind: "text"` and the legacy `type: "text"` block tags so a future serde tag rename does not silently break the test. Workspace re-stabilized and `output_deltas` → `output_text` typo fixed; test compiles cleanly. **End-to-end run still pending** because lane:e2e-smoke needs `OPENAI_API_KEY`; verifier with the key should run `./scripts/repo-cargo test -p meerkat-integration-tests --test smoke_shared_realm e2e_m72 -- --ignored` to confirm A2/A3 fire end-to-end through the projection sink.
- [x] fix · [x] verify · **M73.** Provider selection / Gemini path. `[U]` — Added `e2e_m73_live_open_gemini_provider_rejected_by_precheck` (lane:e2e-system, passes locally). The earlier honest finding stands: B18's `INTERNAL_ERROR` branch is unreachable from any e2e test today because the catalog has zero non-OpenAI realtime-capable rows, so every Gemini live/open surfaces B19 (`INVALID_PARAMS`) before B18 can fire. The B18 contract is now pinned at the unit level instead: `apply_precheck_gates` was extracted from `precheck_identity` so the gate-ordering and provider-rejection logic are independently testable, and three new tests in `meerkat-rpc/src/session_runtime.rs::tests` lock the contracts: `apply_precheck_gates_rejects_non_openai_realtime` (synthesizes a realtime-capable Gemini identity to reach the B18 branch and asserts `ProviderHasNoLiveAdapter`), `apply_precheck_gates_b19_fires_before_b18` (documents the gate ordering by asserting `ModelNotRealtime` on a non-realtime non-OpenAI input), and `apply_precheck_gates_accepts_realtime_openai` (positive control). 5/5 precheck-related tests pass; if a future Wave wires a Gemini realtime factory and flips this contract, these tests force the owner to update the gate explicitly.

### N. Polish

- [x] fix · [x] verify · **N74.** `rkat-rpc` builds OpenAI realtime factory even without `--live-ws`. `[U+R]` — *surface*: factory build wrapped in `if cli.live_ws.is_some()`; closed alongside B15.
- [x] fix · [x] verify · **N75.** `serde_json::to_value(result).unwrap_or_default()` in handlers (`live.rs:147,167`) silently produces `null` on serialization failure. `[R]` (replaced with `.expect("...is infallibly serializable...")` for both `LiveOpenResult` and `LiveStatusResult`.)
- [x] fix · [x] verify · **N76.** `pump_handle: Mutex<Option<JoinHandle>>` is gratuitous — `closed.swap(AcqRel)` already serializes `close()`. `[R]` — `meerkat-live/src/adapter.rs::ProviderSessionAdapter`: `pump_handle` is now `std::sync::Mutex<Option<JoinHandle<()>>>` (no tokio Mutex). The doc-comment notes `closed.swap(AcqRel)` already guarantees a single taker so the std mutex is uncontended. No `.await` is held across the lock — `take()` is sync and the timeout future is awaited on the moved-out handle.
- [x] fix · [x] verify · **N77.** `provider_item_id: String::new()` / `text: truncated_text.unwrap_or_default()` synthesize empty strings for absent fields; should be `Option<String>` on the variant. `[R]` — *core types*: `wire-core` landed `provider_item_id: Option<String>` on `UserTranscriptFinal` + `AssistantTextDelta` and `provider_item_id: Option<String>` + `text: Option<String>` on `AssistantTranscriptTruncated`, all with `#[serde(default, skip_serializing_if = "Option::is_none")]` (absent fields drop from wire form, never emit `null`). Round-trip tests cover both `Some(_)` and `None` shapes. Adapter-side cleanup landed: `meerkat-live/src/adapter.rs::translate_event` now uses `provider_item_id: None` for events without item_id, `provider_item_id: Some(item_id)` when present, and `text: truncated_text` (forwards the source `Option<String>` directly). All three `// TODO(N77): coordinate with wire-core` markers removed; `cargo check -p meerkat-live` clean.
- [x] fix · [x] verify · **N78.** `LiveAdapterErrorCode` missing `Other { raw: String }` variant for unknown provider codes. `[R]`
- [x] fix · [x] verify · **N79.** `LiveAdapterStatus::Degraded { reason }` could be `Cow<'static, str>` for static literals. `[R]`
- [x] fix · [x] verify · **N80.** `open_channel` session-bound check is O(n) linear scan under Mutex. `[R]` — *runtime*: `HostInner` now carries a parallel `by_session: HashMap<SessionId, LiveChannelId>` that is updated alongside the `IndexMap<LiveChannelId, ChannelState>`. `open_channel` performs an O(1) reverse-map lookup (skipping channels in the post-close grace window). Inline test `duplicate_session_check_uses_reverse_map`.
- [x] fix · [x] verify · **N81.** Pump task `obs_tx.send` channel-full breaks the session instead of dropping a frame. `[R]` — `meerkat-live/src/adapter.rs::pump_task` event-loop branch now uses `obs_tx.try_send`. `Ok(())` continues, `TrySendError::Full` logs `tracing::warn!` (target `meerkat_live::adapter`) and continues (drops the observation but keeps the session alive), `TrySendError::Closed` breaks (consumer is genuinely gone). Replaces the old `obs_tx.send(...).await.is_err() => break` pattern that conflated the two cases.
- [x] fix · [x] verify · **N82.** Hardcoded `ws://` scheme in `main.rs:329`. `[R]` — *surface*: added `--live-ws-scheme=ws|wss` CLI flag (default `ws`); the advertised `LiveWsConfig::base_url` now uses the configured scheme (the actual listener stays plain WebSocket — `wss` is an advertisement-side opt-in for TLS-fronted deployments).
- [x] fix · [x] verify · **N83.** Docs remove RPC realtime docs but leave REST / MCP / SDK realtime docs stale. `[U]` — *legacy*: see J57 fix note for the full doc sweep across REST/MCP/SDK/concepts/guides/architecture references and the sidebar drop in `docs/docs.json`.
- [x] fix · [x] verify · **N84.** `LIVE_AUDIO_TRAILING_SILENCE_MS = 1500` lacks an inline comment citing the VAD reliability rationale (commit `f08ce9e3b`). `[R]` — Added the suggested two-line comment directly above `const LIVE_AUDIO_TRAILING_SILENCE_MS: usize = 1500;` in `tests/integration/tests/smoke_shared_realm.rs`, citing commit `f08ce9e3b` and the gpt-realtime-2 VAD floor rationale.

---

## Confirmed clean (no action)

- No `todo!` / `unimplemented!` / `FIXME` / `HACK` / naked `.unwrap()` in production code.
- No bare `#[ignore]`; all are lane-scoped.
- The 500→1500 ms trailing silence bump is fixture VAD tuning, **not** papering over a Meerkat bug.
- Old realtime *machine* surface is fully deleted (no `MeerkatMachine` authority remnants). Items in J are vocabulary/projection-side, not authority.
- Shared `LiveAdapterHost` invariant via `with_live_ws` is correctly wired.
- `RealtimeOpenAiLiveSession` Drop ordering is safe (sender + `recv_task` abort).
- Single-consumer `obs_rx` invariant respected per channel.
- `cargo check` and `make check` pass cleanly.
- Cancel-safe `next_event` via `RealtimeClient::split()` + mpsc recv pump (commit `f08ce9e3b`) — root-cause fix, not a workaround.

---

## Counts

- **P1 (block):** 24 items (A1–A14, B15–B20, C21–C22, D23–D24)
- **P2 (must-fix):** 37 items (E25–E30, F31–F36, G37–G42, H43–H49, I50–I53, J54–J60, K61)
- **P3 (cleanup):** 23 items (L62–L66, M67–M73, N74–N84)
- **Total actionable:** 84
- **Confirmed clean:** 9
