Story-1498 functions lane FN-SAVEDVIEWS ยท EPIC-007

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.

ColumnTypePurpose
firm_id, matter_iduuidTenancy + matter scope. RLS keys on firm_id.
nametextFolder-pill tab label.
owner_user_iduuid?Who owns the view; null = firm-shared.
fields, filters, sortjsonbOrdered columns, opaque predicate, ordered sort specs. JSONB keeps the client view-state vocabulary free to evolve without a migration.
group_by, slice_by, field_sumtext?Grouping, slicing, and the numeric column summed in the footer.
pinned, starredboolDrive the tab bar's pinned front and starred overflow.
availability, deleted_at, deleted_by_user_idtext / 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

  1. Bring up the isolated test DB: make test-db.
  2. Round-trip the migration: make test-db-alembic-roundtrip — upgrade, downgrade to base, upgrade, alembic check reports no drift.
  3. Run the integration tests: a freshly provisioned empty firm returns [] (the first-run door is not welded shut), then full CRUD + soft-delete + a 404 on a deleted view.
  4. 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.
  5. make openapi regenerates openapi/app-api.json with the two new paths; admin-api.json is 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.