Story 2839: shared matter-turn session resolver

Story-2839 (RFD-AGT-013 T1d-a) closes the remaining broker gap between agents.chat_session routing and the already-shipped shared memory scope. The memory pack assembler in src/shared/memory/pack.py already keys the fold by (agent_key, matter_id, user_id), but the WebSocket edge still had a Mira-only default-session helper and no shared validation path for explicit chat_session_id values. This story extracts that rule into one importable resolver and makes every matter-chat launch use it.

What changed

SurfaceChange
src/shared/chat_session_resolver.py (new) New shared resolver resolve_matter_turn_session(). If the turn carries an explicit chat_session_id, it verifies the row is owned by the same (firm, matter, user, agent) identity and is not deleted. If the turn carries no session id, it resolves or creates the single default_agent_chat row for that same identity.
src/app_api/router_ws.py Removes the old _resolve_default_mira_session() helper and routes both Mira and Atlas matter-chat launches through the shared resolver before the command envelope is built. The nested app Mira envelope still translates into mira_matter_chat, but now through the common path instead of a one-off helper.
src/clerk_worker/runner.py Updates the persistence error text to name the shared resolver, so the operator breadcrumb matches the live routing seam.
tests/unit/test_router_ws.py WS unit coverage now pins the shared resolver path: Mira envelope translation still resolves a default session, explicit matter-chat launches still route to the clerk rail, and a launch without chatSessionId resolves the default thread for atlas_matter_chat.
tests/integration/test_clerk_converse_turn_persistence.py The integration proof now imports the shared resolver directly for the real matter-session keying step, so the system-boundary coverage points at the new canonical implementation.

Why this extraction exists

The product rule is "one shared memory per (agent, matter, counsel), plus transcript turns routed to the selected thread." Named sessions are allowed, but they do not mint separate memory scopes. Before this story, that rule was split:

Story-2839 makes the routing rule explicit and singular: a matter-chat turn either lands on the chosen owned thread, or it lands on the one default thread for that (agent, matter, counsel) identity.

Before and after

PathBeforeAfter
Mira matter-chat launch with no session id Worked through a dedicated _resolve_default_mira_session() helper in router_ws.py. Works through the shared resolver in src/shared/chat_session_resolver.py.
Atlas matter-chat launch with no session id No shared resolver path existed at the WS edge. Resolves or creates the default thread via the same shared helper as Mira.
Explicit chat_session_id on a matter-chat launch No single shared ownership/scope resolver enforced it at the edge. One fail-closed helper validates the session belongs to the same (firm, matter, user, agent) identity and is not deleted.
Memory scope Already shared per (agent, matter, counsel). Unchanged, but now aligned with the routing seam that selects the turn's thread.

Doctrine held

Verification

uv run pytest tests/unit/test_router_ws.py -q
# 44 passed

uv run ruff check src/shared/chat_session_resolver.py \
                  src/app_api/router_ws.py \
                  src/clerk_worker/runner.py \
                  tests/unit/test_router_ws.py \
                  tests/integration/test_clerk_converse_turn_persistence.py
# all checks passed

uv run pytest tests/integration/test_clerk_converse_turn_persistence.py -q
# could not run locally: DATABASE_URL / PRAMAAN_ADMIN_DATABASE_URL not set
# CI must verify this integration path

The local proof is therefore the unit boundary plus static lint on the touched files. The system-boundary integration test remains the authoritative guard for the real DB-backed thread persistence path, but in this environment it is CI-only because the required database URLs were absent.