Story 2095: Memory Engine ME-1 — session identity + lifecycle

ME-1 is the Memory Engine spine root. It extends the existing agents.chat_session identity table (RFD docs/notes/rfd-memory-engine.md §4.1, §4.3, §5.1) so a session's identity is (matter_id | form_id) × counsel × session_id [× agent_key], and so a session carries the ACP lifecycle state. No parallel table is created — chat_session already owns identity, launch wiring, and RLS, so the ladder says extend it. agents.chat_turn is untouched.

What changed

SurfaceChange
Migration 0149_chat_session_lifecycle The one additive + one-nullability-flip migration. See the DDL rows below.
chat_session.matter_id Flipped to NULLABLE. A pre-matter intake/settings session has no matter yet.
chat_session.form_id (new) varchar(128), the pre-matter anchor and the form→matter re-key origin provenance (retained after promotion, §4.2).
CHECK chat_session_scope_present matter_id IS NOT NULL OR form_id IS NOT NULL — the at-least-one scope guard (0092 agent_run precedent).
CHECK chat_session_kind_valid Widened to admit named_agent_chat beside default_agent_chat/legacy_agent_chat (drop/recreate by hand — autogenerate cannot see CHECK predicates).
Lifecycle columns (new) availability varchar(16) NOT NULL DEFAULT 'live' (CHECK live/archived/deleted), deleted_at timestamptz, cleared_until_turn_index int (CHECK ≥ 0). The DEFAULT 'live' is persistent so every existing INSERT path — which never names availability — keeps writing live sessions unchanged.
Indexes (new) ix_chat_session_dropdown (firm_id, matter_id, user_id, agent_key, last_active_at DESC) — the session-list query — and ix_chat_session_form (firm_id, form_id) WHERE form_id IS NOT NULL — the re-key + pre-matter list.
uq_chat_session_default_agent_chat Left intact. Today's single auto-resolved default thread keeps its exactly-one guarantee while counsel opens any number of named sessions beside it.
src/shared/agents_db_models.py The Core table mirror follows the DB: matter_id nullable, plus the four new columns. The Python ChatSessionKind Literal is intentionally left at two values — no ME-1 code constructs or reads a named_agent_chat session yet; it extends with the session-creation verb in a later ME story. The DB CHECK is already ahead so that future consumer is not blocked-by-construction (the 0126 precedent).

Session identity (RFD §4.1)

identity = (matter_id  OR  form_id)  x  counsel_user_id  x  session_id  [x agent_key]
             ^ the matter, once one exists
             ^ form_id pre-matter (intake / settings)
             ^ session_id MANDATORY (the PK anchor; every turn hangs off it)

Backward compatibility

Every existing default-session path is unchanged: matter_id is only relaxed (never a new required column), availability defaults to 'live' at the DB so inserts that omit it still succeed, the default unique index is untouched, and the Python vocabulary is unchanged. The existing test_chat_session_identity.py suite passes verbatim.

Downgrade

downgrade() re-narrows the shape. Because a form-scoped row (matter_id IS NULL) or a named_agent_chat row cannot survive the pre-ME-1 NOT NULL / two-value CHECK, the downgrade DELETEs those rows first, then re-asserts the tighter shape (the 0060 narrowing-downgrade precedent). Destructive on down-migration by construction; it exists to keep alembic downgrade base runnable.

Verification (acceptance criteria)

make test-db-reset && make test-db                       # fresh volume, migrate to 0150
set -a; . ./.env.test-db; set +a

# alembic up/down/up + check  (a1c9f0d2b3e4 = 0150's parent, main's 0149)
alembic downgrade a1c9f0d2b3e4 && alembic upgrade head && alembic check   # "No new upgrade operations detected"
alembic downgrade base && alembic upgrade head                             # full CI-parity roundtrip

# existing default-session tests green UNCHANGED
pytest tests/unit/test_chat_session_identity.py -q                          # 6 passed

# cross-firm RLS zero-rows + zero-state list [] via a REAL request (RLS runtime role)
pytest tests/integration/test_chat_session_lifecycle_rls.py -q             # 1 passed

# migration shape unit coverage
pytest tests/unit/test_chat_session_lifecycle_migration.py -q              # 2 passed

The zero-state proof runs the dropdown-index query against a freshly provisioned (empty) firm through the RLS-enforced pramaan_app role and asserts [] — the real first-run state, not a seeded fixture. The same test proves firm B's runtime sees zero of firm A's rows, and that an archived named_agent_chat session (a real insert of the widened kind against Postgres) is excluded from the live dropdown while the live one is returned. No HTTP list endpoint exists in ME-1 scope; it arrives with the session-list story that consumes this index.