Story 2099: Memory Engine ME-5 — the memory stores

ME-5 lands the three greenfield persistence stores the Memory Engine reads into the Memory Pack (RFD docs/notes/rfd-memory-engine.md §5.2, §5.3, §5.4) and the one code path that bumps memory_version — the cache-key component every accepted memory change invalidates. This is substrate only: the interceptor, summarizer, proposal-confirm flow, and pack assembly land in later ME stories. All three tables live under the agents schema (broker runtime the stateless brain never reads directly), each firm-RLS'd, each self-contained on provenance so an item survives the deletion of the session that produced it.

What changed

SurfaceChange
Migration 0156_memory_stores Creates the three tables off head d4c1b8e5a207 (0155). Hand-written RLS, partial indexes, CHECKs, grants, and updated_at triggers (autogenerate sees none of these).
agents.counsel_preference (§5.2) Cross-matter, per-counsel style items ("lead with exposure"). Item rows, not a JSON array — each is independently proposed/confirmed/edited/deleted with its own provenance. status ∈ proposed/accepted/auto_accepted/rejected/deleted; source ∈ interceptor/explicit/settings. Exact-dup guard: partial unique uq_counsel_preference_accepted_text (firm_id, user_id, text) WHERE status IN ('accepted','auto_accepted'). No matter_id by design (it is about a person).
agents.matter_strategy_item (§5.3) Matter-scoped, team-shared working plan ("goal = settlement"). kind ∈ goal/focus/constraint/note; status adds superseded; proposed_by_user_id is attribution, not scope (the plan belongs to the matter, provenance records who said each item). superseded_by is a self-ref edge — a new focus writes a new accepted row and marks the old one superseded, not an overwrite.
agents.session_memory (§5.4) The rolling per-(matter|form × counsel × agent) fold and the home of memory_version int NOT NULL DEFAULT 1. Scope guard session_memory_scope_present (matter_id IS NOT NULL OR form_id IS NOT NULL) mirrors 0092/0150; two partial uniques (matter-scoped and form-scoped) give one fold row per scope; agent_key carries the same CHECK vocabulary as agent_run/chat_session (§5.4).
Provenance FK stance (§6.2) origin_session_id / origin_turn_id / source_excerpt are denormalized and carry no FK to chat_session/chat_turn — the item must outlive session deletion and turn-content purge. The load-bearing ids (firm_id, matter_id, owning user_id) DO FK with RESTRICT, the 0044 agents-foundation precedent.
src/shared/agents_db_models.py The Core-table mirror (used by the sibling pramaan-agents repo) plus the closed-vocabulary Literals/tuples. The alembic roundtrip's check ("No new upgrade operations detected") proves the mirror matches the DDL.
src/shared/memory/version.py The ONE bump_memory_version() code path — see below.
src/shared/audit/registry.py Two new subjects (§9.3): counsel_preference (area settings) and matter_strategy (area matter). Both exclude text/source_excerpt from any snapshot (memory text is content class, §9.1 R10/R15). Verbs reuse the existing vocabulary — accept→approved, undo→rejected. The chat_session subject already exists (ME-2).

The memory_version bump — one grouped UPDATE, three scopes (§5.4)

Every accepting/undoing write path (fold persist, strategy accept/supersede/delete, preference accept/undo/delete, explicit remember) ends by bumping memory_version so the next turn's prompt sandwich re-reads a fresh memory block instead of a stale cached one. There is exactly one statement, parameterized by scope:

bump_memory_version(session, firm_id, user_id=U)                    # preference change: cross-matter
    -> UPDATE ... WHERE firm_id=:f AND user_id=:u                   #   every fold row of counsel U, ONE grouped UPDATE (DR-2)

bump_memory_version(session, firm_id, matter_id=M)                  # strategy change: matter-shared
    -> UPDATE ... WHERE firm_id=:f AND matter_id=:m                 #   every counsel's fold for matter M

bump_memory_version(session, firm_id, matter_id=M, user_id=U, agent_key=K)   # fold persist / explicit remember
    -> UPDATE ... WHERE firm_id=:f AND matter_id=:m AND user_id=:u AND agent_key=:k   # the single scope row

A bare firm-only call raises ValueError (a firm-wide bump is a bug, never a use case). The helper only bumps rows that already exist — a scope with no fold row has no cached block to invalidate, and its row is born at version 1 when the first fold persists (the ME-9 writer's job), which is the correct starting version. It runs in the caller's transaction, so the bump commits or aborts with the memory change it announces.

Tenancy & retention

RLS is firm isolation + staff-with-grant SELECT (the 0153/0154 clone); the preference service additionally filters user_id = actor (a colleague's packs never carry them, §5.2 — service-layer, not RLS). The app role keeps DELETE: counsel delete/clear is a real app-role write (§9.2), unlike the retention-locked inbox table. Memory items use soft states for audit retention; hard erasure rides the firm-level flows.

Verification (acceptance criteria)

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

# alembic full CI-parity roundtrip + drift check
make test-db-alembic-roundtrip                            # down->base, up->head, "No new upgrade operations detected"

# roundtrip + firm-isolation RLS + the bump code path, through a REAL request as pramaan_app
pytest tests/integration/test_memory_stores_rls.py -q     # 1 passed

# migration shape (RLS/indexes/uniques/CHECKs/grants) + vocab-drift + bump-helper unit shape
pytest tests/unit/test_memory_stores_migration.py tests/unit/test_memory_version_bump.py -q   # 9 passed

# the global firm-scope RLS-completeness gate now accounts for the three new tables
pytest tests/integration/test_rls_isolation.py -q         # 25 passed

The system-boundary proof (test_memory_stores_rls.py) inserts a preference, a strategy item, and four fold rows through the RLS-enforced pramaan_app role against a freshly provisioned (empty) firm, reads them back, then proves firm B's runtime sees zero of firm A's memory rows and that a cross-firm bump touches nothing. It then exercises all four bump scopes on real rows: the user-scoped preference bump moves every one of that counsel's folds while a colleague's stays put; the matter-scoped strategy bump moves every counsel's fold for that matter; the single-scope and form-scope bumps each move exactly one. No HTTP endpoint exists in ME-5 scope; the routes that consume these stores arrive with later ME stories.