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.
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;
form_id stays on every re-keyed
row — the chat_session_scope_present CHECK is at-least-one, not
exactly-one — so the session's origin remains legible to audit and debug reads.matter_id IS NULL predicate is the
idempotency guard: once a form session carries its matter, a rerun matches nothing and
is a clean no-op. Safe if a promotion retries.uq_chat_session_default_agent_chat is scoped by matter_id, so
a re-keyed default_agent_chat form session could collide with a default the
target matter already owns (same firm/user/agent). Resolution: the colliding form
default steps aside as a named_agent_chat titled "Intake"
— the matter's own default keeps its slot, nothing is deleted, no history is lost. The
rename runs before the re-key (while matter_id is still NULL), so
the UPDATE never trips the index. At most one form default per (user, agent) may claim
the matter's slot, so two form defaults for the same identity cannot collide with each
other either.chat_session
(no single session id — this is a batch), verb updated, matter-scoped,
written in the same transaction as the UPDATE.| Surface | Change |
|---|---|
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.py | MatterCreateRequest.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.py | create_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.json | Regenerated (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. |
WHERE firm_id = :firm_id); the re-key runs under the same
pramaan_app role as the request, proven in the integration test.create_matter route span plus its DB spans;
the durable record of the operation is the in-txn audit row. No hand-rolled plumbing
span (a PR reject).agents.session_memory (RFD §5.4) takes
the same re-key when that store lands (Epic-2094 ME-6); its table does not exist yet, so
there is nothing to re-key today — documented at the function, not silently dropped.
The Atlas broker matter-creation caller (which will pass form_id) lands
with its own story; ME-4 ships the seam and the logic.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.