Story 2096: Memory Engine ME-2 — session lifecycle service + routes

ME-1 (Story-2095) shaped the agents.chat_session identity + lifecycle columns and stopped short of any HTTP surface. ME-2 is that surface: the functions-side of the session dropdown (RFD docs/notes/rfd-memory-engine.md §4.3 verb table + §8 route sketch). Nine routes drive the eight ACP verbs — list, create, load-turns, rename, compact, clear, archive, restore, delete — over agents.chat_session / agents.chat_turn, each mutating verb writing a typed chat_session.* audit row in the same transaction (§9.3). No new table, no new migration; ME-1's shape is consumed as-is.

The verbs (RFD §4.3) → the routes (§8)

VerbRouteServer semanticsAudit action
listGET /app/matters/{matter_id}/agent-sessions?agentKey=&availability=The dropdown query — firm × matter × counsel × agent, newest-active first. availability=archived serves the archive-management list.—
createPOST /app/matters/{matter_id}/agent-sessionsINSERT a named_agent_chat row, fresh session_id, empty turns, title optional.chat_session.created
loadGET /app/agent-sessions/{session_id}/turnsList turns oldest-first (paged); stamps last_active_at.—
renamePATCH /app/agent-sessions/{session_id}Update the title (inline rename).chat_session.renamed
compactPOST /app/agent-sessions/{session_id}/compactPersist an in-session fold: summary + summary_until_turn_index watermark. Raw turns retained.chat_session.updated
clearPOST /app/agent-sessions/{session_id}/clearSoft-reset: null the fold, set cleared_until_turn_index = turn_count. Raw turns retained.chat_session.updated
archivePOST /app/agent-sessions/{session_id}/archiveavailability='archived' — out of the default dropdown, restorable.chat_session.archived
restorePOST /app/agent-sessions/{session_id}/restoreavailability='live'.chat_session.restored
deleteDELETE /app/agent-sessions/{session_id}Soft: availability='deleted' + deleted_at. NEVER a hard row delete.chat_session.deleted

What changed

SurfaceChange
src/app_api/agent_session_dtos.py (new)Request/response contracts. *Request inputs (ApiRequestModel, camelCase wire, extra="forbid"); distinct *View/*Response outputs — no class is both a request body and a response_model=.
src/app_api/agent_session_service.py (new)The lifecycle service. Caller-scoped (every read/write filtered to the counsel's own user_id); firm/user resolved once from the RLS context cache (DR-3); each mutating verb emits its typed audit row via audit.emit in-transaction.
src/app_api/router_agent_sessions.py (new)The nine routes. Full contract hygiene: router tags=, response_model=, canonical ErrorResponse responses=, clean camelCase operationIds. matters:read gates every verb.
src/shared/audit/registry.pyNew subject chat_session (area matter) — §9.3. NOT the firm-area agent_session subject (tool-call activity), which stays un-overloaded.
src/shared/audit/coverage.pyThe seven mutating routes registered in EMIT_ROUTES with their chat_session.* actions (the route-coverage gate).
src/shared/agents_db_models.pyThe Python ChatSessionKind Literal widened to admit named_agent_chat — ME-1 left it at two values on purpose ("extends with the session-creation verb in a later ME story"); this is that story. The DB CHECK was already ahead (0150).
src/app_api/app.pyRouter mounted.
openapi/app-api.jsonRegenerated (make openapi) — the app consumes these via src/gen.

Doctrine held

Verification (acceptance criteria)

make test-db                                            # Postgres 16 @ :55432, migrate to head (0150)
bash scripts/run-with-test-db-env.sh uv run pytest tests/integration/test_app_agent_sessions.py -q   # 3 passed

# every verb over HTTP against a real DB, incl. empty first-run
#   test_session_lifecycle_all_verbs_over_http
# delete leaves agent_run FK intact
#   test_delete_leaves_agent_run_fk_intact
# db_query_count O(1) on the list (1 session vs 5 sessions -> identical count)
#   test_list_db_query_count_is_o1

make openapi && git diff --exit-code -- openapi/app-api.json    # producer snapshot committed

# guards unaffected by the registry/coverage/kind edits
uv run pytest tests/unit/test_audit_route_coverage.py tests/unit/test_audit_registry_emit.py \
              tests/integration/test_app_registers.py -q            # green

The lifecycle test starts from the empty first-run state — a brand-new matter's dropdown returns [] before any session exists — then walks every verb through the full ASGI pipeline (middleware → RLS dep → service → audit → real Postgres), proving the archived-list returns the archived-not-deleted row and that a deleted session vanishes from both the live and archived lists while its agent_run reference survives.