Story-2880

Thread sync protocol — channel turns land in the open app thread

This story lands the PUSH side of the channel→app thread contract (RFD-AGT-013 TICKET-MAP v2 T2m-a, scenario CC-32: "WhatsApp shows its own slice; the app shows the whole conversation"). A channel turn is already persisted into the shared (agent, matter, counsel) memory — Story-2839's session resolver routes it and the clerk worker's Bug-2211 writer (_persist_converse_turns) appends it to agents.chat_turn. What was missing: today's app hydration is once-per-session, so a turn that landed while the app thread sat open never appeared. This story makes the broker emit a WebSocket refresh signal so the open app thread re-reads the canonical store. Zero new migrations; one new v1 frame type.

The protocol

DecisionContract
One delivery rail The signal rides ADR-110's only WS rail: _FramePusher → ws_pusher Lambda → postToConnection. No second push path, no domain event, no polling contract.
Signal, not content {"v": 1, "type": "thread.updated", "run_id": …, "payload": {chat_session_id, matter_id, agent_key}} — routing keys only, never turn bodies. The app re-reads GET /app/agent-sessions/{id}/turns (the idempotent canonical read) when the frame names its open thread.
Channel-agnostic The emit condition is "the run has NO launch connection" (off-socket origin) — never a provider check. No whatsapp/meta/adapter field appears in the envelope, so every present and future channel adapter rides the same contract.
Fail-safe thread.updated joins _FramePusher._MUST_DELIVER: with no launch connection the pusher re-resolves the (firm, user) newest live agents.ws_connection row (the Story-2896 socket-attach lookup). No open socket → silent no-op; the turn is durable and the app reads it on next open (ADR-110 §4.1).
No double signal App-socket launches are skipped: their own turn.completed stream already settles the thread on that socket — a refresh would be a redundant re-read.

Where it hooks (src/clerk_worker/runner.py)

The converse settle path persists the thread rows BEFORE the browser settles (the Bug-2211 posture: counsel never sees an answer that failed to persist). _emit_thread_refresh(command, pusher) runs immediately after that persistence succeeds and before turn.completed is pushed, so the signal always points at durable truth. A channel-launched converse run rides exactly this code path, so the hook covers channel turns without touching any channel adapter code — the seam the RFD's "refresh contract when channel turns land" assumes.

How to verify (CC-32 push half)

# The thread-sync unit proofs: channel turn → refresh frame with correct
# session/matter/agent routing on the re-resolved live socket; no-socket
# no-op; app launches not double-signalled; sessionless runs emit nothing
uv run pytest tests/unit/clerk_worker/test_runner_thread_sync.py

# Neighbors that must stay green: converse-turn persistence, the whole
# clerk_worker suite
uv run pytest tests/unit/clerk_worker

Red-first evidence

Three planted negatives were observed red against the shipped implementation: (1) dropping thread.updated from _MUST_DELIVER fails test_channel_turn_emits_thread_refresh_with_session_matter_routing (no live-connection re-resolution for an off-socket run); (2) deleting the _emit_thread_refresh call at the converse settle site fails the same test (no frame emitted); (3) dropping the off-socket guard so the emit fires unconditionally fails test_app_socket_launch_is_not_double_signalled.

Scope boundaries (TICKET-MAP v2)