Documents Register saved views
A litigation matter accumulates 300–500+ documents. A register you have to re-filter on every visit is a demo, not a workflow. This Story gives counsel a place to save a named filter·group·sort·fields combination — "Financial evidence", "Unfiled" — so it is one click next time.
The mental model: a saved view is a bookmark for the register's view-state. It is pure Mode-1 deterministic UI state — no document content, no AI, no custody weight — scoped to one matter and isolated to one firm by Postgres RLS.
What changed
Table
New documents.document_saved_views — firm- and matter-scoped, RLS-isolated, with an updated-at trigger.
API
List / create / update / delete nested under a matter at /app/matters/{matter_id}/document-saved-views.
Isolation
Firm isolation is enforced in the data layer (RLS policy), not in handlers. A real cross-firm caller test proves it.
Soft delete
DELETE flips availability to deleted; the row is retained, never session.delete()'d.
The shape of a view
One row captures the whole view-state engine the register reads: slice · group · sort · fields · field-sum · pinned/starred, plus an opaque filter predicate and an owner.
| Column | Type | Purpose |
|---|---|---|
firm_id, matter_id | uuid | Tenancy + matter scope. RLS keys on firm_id. |
name | text | Folder-pill tab label. |
owner_user_id | uuid? | Who owns the view; null = firm-shared. |
fields, filters, sort | jsonb | Ordered columns, opaque predicate, ordered sort specs. JSONB keeps the client view-state vocabulary free to evolve without a migration. |
group_by, slice_by, field_sum | text? | Grouping, slicing, and the numeric column summed in the footer. |
pinned, starred | bool | Drive the tab bar's pinned front and starred overflow. |
availability, deleted_at, deleted_by_user_id | text / ts? / uuid? | Soft-delete axis (see below). |
Why soft delete, not hard delete
The issue's acceptance criteria said "delete API". The repo's lifecycle danger-zone says firm-owned entities are never session.delete()'d — use a lifecycle axis, mirroring the matter-type catalog. Doctrine wins. DELETE here is a soft delete: it sets availability = 'deleted', stamps deleted_at / deleted_by_user_id, and every read path excludes deleted rows. The app role is granted SELECT, INSERT, UPDATE — never row DELETE.
AC amendment: "delete API" is delivered as a soft delete (lifecycle transition), not a physical row removal, per the AGENTS.md lifecycle danger-zone. No user-facing archive state is exposed — deleted views simply vanish from the list.
Mode discipline
Per EPIC-007, this is Mode 1 only: deterministic custody. No column here is AI-derived. Mode 2/3 (EPIC-009) classification never writes to this table. A saved view persists what the lawyer chose, nothing the model guessed.
How to verify
- Bring up the isolated test DB:
make test-db. - Round-trip the migration:
make test-db-alembic-roundtrip— upgrade, downgrade to base, upgrade,alembic checkreports no drift. - Run the integration tests: a freshly provisioned empty firm returns
[](the first-run door is not welded shut), then full CRUD + soft-delete + a404on a deleted view. - Run the RLS test: Firm B cannot list, read, mutate, or delete Firm A's view — it is invisible, and Firm A's view survives the probes.
make openapiregeneratesopenapi/app-api.jsonwith the two new paths;admin-api.jsonis untouched.
Product outcome: the register stops being a per-visit demo. "Financial evidence" and "Unfiled" become saved, one-click tabs — the persistence that makes a 300–500 document register usable by counsel.