Story 2100: Memory Engine ME-6 — memory contracts + pack assembly

ME-5 (Story-2099) built the three memory stores; ME-6 builds the read. Two pieces (RFD docs/notes/rfd-memory-engine.md §7.1, §6.2, §5.5, §10): a pure contracts package (src/shared/memory_contracts/) the stateless brains import as the single source of contract truth, and a pack assembly service that folds the stores plus a People projection into one memory_pack.v1 the broker will stamp onto the invoke envelope. No new migration — the stores already exist. The interceptor, the "remember:" grammar, and the broker stamping are later ME stories; ME-6 is the seam they hang off.

What changed

SurfaceChange
src/shared/memory_contracts/ (§7.1, §6.2) New PURE package, mirror of context_contracts: MemoryPack/MemoryPerson/MemoryItemView (memory_pack.v1) and MemoryProposal/MemoryProposalItem (memory_proposal.v1). extra="forbid" freezes the shape; every carrier pins a schema_version literal. No sqlalchemy/boto3/settings import — a store dependency here would drag the ORM into every brain.
tests/unit/memory_contracts/test_purity.py The no-sqlalchemy CI gate (mirror of the context_contracts one): a fresh-interpreter import proves nothing transitive pulls a store module in, and an AST scan bans the direct import lines so a future edit fails loudly at review.
src/shared/memory/pack.py — assemble_memory_pack() The pack assembly service. A fixed set of grouped queries (§10, DR-1): 5 statements for a matter scope, 3 for a form scope — never per-row. Reads the fold + memory_version, accepted preferences, accepted non-superseded strategy, and the People projection.
src/shared/agent_contracts/invoke.py Additive optional field memory_pack: MemoryPack | None = None on agent_invoke.v1. A missing pack is a valid pack (the brain degrades to today's behaviour — fail-open); it rides the SAME pre-fetch assembly that stamps the Context Pack (RFD R-6 / CE-14 seam). No breaking change — the existing envelope is byte-unchanged when the field is absent.
src/shared/observability/manual_span.py New closed-registry span functions.memory.pack_assemble (ADR-124), INTERNAL, required attr firm_id (root dimension, never baggage). It is where the pack build's db_query_count budget is observed.

The pack is a fixed set of grouped queries (§10, DR-1)

Each Lambda invocation talks to RDS over the network, so latency is round-trip accumulation. DR-1 forbids a query count that scales with rows. The assembly is bounded by the scope shape, not by how many parties / preferences / strategy items exist:

#QueryScope
1agents.session_memory → memory_version + fold_textalways
2agents.counsel_preference WHERE status IN (accepted, auto_accepted), this counselalways
3agents.matter_strategy_item WHERE status='accepted' AND superseded_by IS NULL, ordered goal→focus→constraint→note, cappedmatter only
4identity.users → the requesting counsel's display namealways
5business.matter_participants → the who's-whomatter only

5 statements for a matter scope, 3 for a form scope — both ≤ 6. The O(1)-query test (tests/unit/test_memory_pack_assembly.py) proves it by running the assembly with 1 participant and with 200 and asserting the execute() count is identical.

People is a projection, not a store (§5.5)

There is no people table. People is assembled at pack build from what already exists: the requesting counsel (from identity.users) as "counsel (you)" on side us, plus the matter's matter_participants mapped through their side axis (ours→us, opposing→them, court/others→other) and role_type. When the frame or participants change, People changes with them on the next pack build — a people_memory table would drift from the frame within a week. Pre-matter (form scope) degrades honestly to just the requesting counsel. (Full firm-side co-counsel enumeration + intake-form participants are the ME-14 "completeness" story.)

Two independent version axes

memory_version is the data version — the cache-key component (R-2) that bumps on any accepted memory change; a fresh scope with no session_memory row reads as the born-at-1 default. schema_version ("memory_pack.v1") is the shape version — additive-only within its vN, forks to v2 only on a field-meaning change. They are never conflated.

"Done" = the empty first-run state, against a real DB

A seeded fixture would hide the case that actually breaks first: a brand-new matter with nothing remembered. tests/integration/test_memory_pack_assembly_rls.py exercises both boundary states through the runtime pramaan_app role under RLS — a populated matter (preference + strategy + versioned fold + participant, with a superseded strategy item proven absent), and the empty scope (fresh counsel, fresh matter, nothing seeded) which yields a valid pack: memory_version = 1, session_fold = None, empty lists, only "counsel (you)" in People. A wrong column name or an RLS gap fails here, not in prod.

Verify

uv run ruff check
uv run pytest tests/unit/memory_contracts tests/unit/test_memory_pack_assembly.py
make openapi            # no drift — agent_invoke.v1 is a broker→brain envelope,
                        # not a served route DTO
make test-db && make test   # includes the RLS assembly integration proof

Recall: ME-6 turns the stores into one importable, fail-open, O(1)-query memory_pack.v1 — the seam the interceptor and the broker stamping hang off next.