Story 1991: structured Brief storage — matter_frames + matter_frame_field_locks

pramaan-docs ADR-153 found the gap while landing RFD-EVENT-001: Vega's typed FrameOutput has no structured storage — the only frame persistence is the matters.matter_frame_text blob. The framer rail (Story-1977) already mints run rows and holds a generation-CAS lease, with the completion path pinned as the seam that writes through… into nothing. This Story is the foundation of EPIC-1990: the two tables everything else (ingest #3, Brief read DTO #4, counsel edit/locks #5) lands on. Schema only — no routes, no DTO mirror, no ingest; those are the sibling Stories.

The two tables (migration 0147, expand phase)

TableShape
business.matter_frames Append-only frame versions. One envelope JSONB (MatterFrameEnvelope = frame / resolution / provenance, ADR-153 D1) plus first-class projection columns extracted at insert: version, frame_status (usable / thin / insufficient_input — CONTENT completeness, never pipeline state), missing_core_fields, source (agent / counsel), agent_run_id, created_by_user_id. Current frame = highest version: one indexed ORDER BY version DESC LIMIT 1 read via uq_matter_frames_firm_matter_version (ADR-130 bounded).
business.matter_frame_field_locks Live per-field pin state (ADR-153 D4): unique per (firm, matter, field); locked_value JSONB carries the EXACT counsel-pinned value so the ingest overlay never guesses; who/when on the row. Live state, so full CRUD — re-lock updates, unlock deletes.

Append-only is enforcement, not convention

REVOKE UPDATE, DELETE ON business.matter_frames FROM pramaan_app. A frame version is an immutable record: counsel edits and agent regenerations are NEW rows, never rewrites — the audit posture (pramaan-docs ADR-105) at the DB layer. The integration test proves the denial live: a firm-context UPDATE and DELETE both fail with permission denied against the real database.

Contract choices that will matter later

ChoiceWhy
FK anchor is agents.agent_run_key(run_id), never agents.agent_run agent_run's primary key is composite (run_id, started_at) under partitioning; agent_run_key is the house single-column anchor (ADR-153 D1, same as the effect ledger and Mode-2 claims).
Attribution CHECK pairs: (source='agent') = (agent_run_id IS NOT NULL), (source='counsel') = (created_by_user_id IS NOT NULL) Every version names exactly one author — an agent version rides a broker-minted run, a counsel version rides a user. No orphan provenance, no double attribution, enforced by the DB not the DTO.
Composite firm-leading FK (firm_id, matter_id) → matters The RLS-safe house pattern: a frame row can never point at another firm's matter, even under a buggy write path.
JSONB envelope + projection columns, not column-per-field The frame contract evolves with prompt iterations; 20+ nullable columns would weld agent-contract evolution to Alembic. Product queries get frame_status/missing_core_fields as real columns (human-gate steer); frame content stays in the envelope owned by code review (ADR-117).
field vocabulary NOT a DB CHECK Lock fields validate against the mirrored DTO's field-name set in code (Story #2b) — one vocabulary for lock, critique, and ingest enforcement, evolving without migrations.
No backfill from matter_frame_text Reverse-parsing prose would launder unvalidated content into a validated table. Version 1 of every matter's frame is born from its first real Vega run (ADR-153 migration sketch).

Version allocation: serialized on the matter row

src/domains/matter_frame/service.py :: allocate_next_frame_version()

1. SELECT id FROM business.matters WHERE firm_id=… AND id=… FOR UPDATE
2. SELECT COALESCE(MAX(version), 0) + 1 FROM business.matter_frames WHERE …

Two concurrent writers (two counsel tabs, or counsel vs agent) queue behind the matter-row lock and each sees the other's committed insert, instead of racing max(version) into a unique-constraint explosion. This is the canonical helper the ingest boundary (Story #3) and counsel-edit path (Story #5) both call, inside their own transactions. Two bounded round-trips (DR-1); instrumentation-exempt per ADR-124 — it always runs under the caller's ingest span.

RLS + parity

Both tables clone the business.matters content pattern at head: firm_isolation_policy FOR ALL plus the firm-scoped staff_with_grant_policy SELECT branch (0013 as rewritten by the 0133 security seal), FORCE ROW LEVEL SECURITY, no BYPASSRLS anywhere. SQLModel entities in src/domains/matter_frame/entity.py keep alembic check parity; the agents-schema FK resolves through a metadata-only anchor stub that autogenerate never manages (env.py include_object excludes the agents schema). Both tables are registered in the RLS isolation suite's confidential-table registry, and the factories teardown drops frame/lock rows before agent_run_key and matters (RESTRICT FKs).

The zero state is a state, not an error

A fresh matter has NO frame rows — that is the first-run truth for every matter until its first Vega run lands. The integration test allocates against exactly this empty state on a real database (version 1, not an error), then proves max+1 after a real insert through the RLS session — the system-boundary "done" bar from AGENTS.md, not a fixture-seeded unit pass.

Observability

Schema-only change: no new executable boundary. The one helper is documented instrumentation-exempt (ADR-124) — it is two statements under the future ingest span; the registry-approved functions.matter_frame.ingest span arrives with Story #3.

Verification

ruff check                             clean on all changed files (repo-wide noise = local 0.15 vs CI 0.13, preexisting)
mypy src/domains/matter_frame/         clean (repo-wide mypy noise preexisting; CI does not enforce)
alembic upgrade head                   0146 → 0147 clean on fresh Postgres 16 volume
alembic downgrade/upgrade/check        roundtrip clean; "No new upgrade operations detected"
pytest tests/                          full suite green vs make test-db Postgres 16
make openapi                           no snapshot drift (no route/DTO change)
staging RDS                            Migrate Staging RDS workflow runs 0147 on merge (LI-2)