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)
| Table | Shape |
|---|---|
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
| Choice | Why |
|---|---|
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
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)