Story 2098: Memory Engine ME-4 — form_id → matter_id re-key at promotion

ME-1 (Story-2095) made agents.chat_session.matter_id nullable and added form_id so a pre-matter intake/settings conversation can exist before any matter does. ME-4 closes the loop the RFD opened (docs/notes/rfd-memory-engine.md §4.2, the capture's decided open question): when a form becomes a matter, the form's chat sessions are re-keyed onto that matter in place, inside the matter-creation transaction. The intake conversation is exactly the context a fresh matter's first session needs — orphaning it is designed amnesia. No new table, no new migration; ME-1's shape is consumed as-is.

The re-key (RFD §4.2)

UPDATE agents.chat_session
SET matter_id = :new_matter_id            -- form_id is RETAINED as origin provenance
WHERE firm_id = :firm_id AND form_id = :form_id AND matter_id IS NULL;

What changed

SurfaceChange
src/shared/chat_session_promotion.py (new)rekey_form_sessions_to_matter(session, *, firm_id, form_id, matter_id) — the re-key + collision + audit logic, returning batch counts (rekeyed, renamed_to_intake). Reuses audit.emit with the ME-2 chat_session subject; no new audit vocabulary.
src/domains/matter/dtos/matter.pyMatterCreateRequest.form_id (optional, ≤128 chars) — the intake-promotion origin. Additive and backward-compatible: the manual Add-Matter wizard never sets it.
src/domains/matter/service.pycreate_matter calls the re-key inside its own transaction when req.form_id is set — durable at commit, gone on rollback with the matter it belongs to.
openapi/app-api.jsonRegenerated (make openapi) — the new optional formId field on the create-matter request.
tests/integration/test_chat_session_rekey.py (new)Real-Postgres proof under the RLS pramaan_app role — the three acceptance criteria.

Doctrine held

Verification (acceptance criteria)

make test-db-reset                                     # Postgres 16 @ :55432, fresh volume, migrate to head (0153)
bash scripts/run-with-test-db-env.sh uv run pytest tests/integration/test_chat_session_rekey.py -q   # 2 passed

# promotion fixture: form sessions re-keyed, form_id retained (no collision)
#   test_rekey_promotes_form_sessions_and_reruns_are_no_ops   (also asserts rerun -> (0,0))
# collision: matter already owns a default -> form default becomes named "Intake"
#   test_rekey_default_collision_becomes_named_intake

bash scripts/run-with-test-db-env.sh uv run alembic check                 # No new upgrade operations detected
make openapi && git diff --exit-code -- openapi/app-api.json    # producer snapshot committed

# unaffected by the DTO + wiring edits
bash scripts/run-with-test-db-env.sh uv run pytest \
  tests/unit/test_matter_dtos.py tests/contract/test_app_matters_openapi.py \
  tests/integration/test_app_matters.py -q            # green

The tests start from the fixture the acceptance criteria name — a target matter plus the form's pre-matter sessions (matter_id IS NULL, form_id set) — and exercise the real UPDATE + audit path through the RLS-enforced app role: the promotion re-keys with form_id retained, a rerun is a no-op, and a default that collides with the matter's own default steps aside as the named "Intake" session while the matter's default keeps its slot.