Story-3391 · functions lane · RFD-MATTER-006 F7
A staffed member writes matter content — on their matter, and never a delete
firm_member stops being read-only on matter content. A member
staffed on a matter can now upload documents and edit their metadata, create
filings and the proceeding structure that holds them, add and edit
participants, edit matter descriptive fields, write annotations, and author
claims. On a matter they are not staffed on, every one of those returns 403.
Deletes are untouched.
Two axes, one dependency
The panel's P0-1 finding refused a firm-wide grant: handing members
matters:write would reach every matter in the firm, which is
exactly the lateral reach the lenient model is supposed to contain. So the
gate has two halves that answer different questions, and they ship as a
single dependency so neither can be wired without the other.
MatterContentWriteDep
├─ require_permission("matters:content_write") may this ROLE write content?
└─ require_matter_capability(..., "content:write") on THIS matter?
active same-firm membership + assignment row on the exact matter
(firm_admin bypasses; internal/agent sessions pass through)
matters:content_write is a new literal (migration 0253) granted
to firm_admin, firm_editor and
firm_member. It is not granted to support
(pramaan-docs ADR-098: app-support sees configuration, never tenant
content). matters:write stays editor-and-above and still guards
every destructive route, so a plain staffed member is refused twice over on
a delete.
The capability sub-dependency is bound at module level, not inside a
factory. src/domains/matter/deps.py runs under
from __future__ import annotations, so FastAPI resolves each
sub-dependency by eval'ing a STRING against module globals — a closure
variable is not in that namespace, the eval fails silently, and the
sub-dependency vanishes from the resolved tree. The first cut of this guard
did exactly that: the route kept the firm permission, lost the per-matter
containment, and nothing errored. The route inventory read back
cap= empty on all 30 routes, which is how it was caught.
What a member may now write
| Class (DL21) | Routes |
|---|---|
| Matter descriptive fields | PATCH /app/matters/{matter_id} |
| Documents — upload | POST .../documents/presign-upload, .../documents/imports, .../documents/{doc_id}/finalize |
| Documents — metadata edit | PATCH .../documents/{doc_id}/privilege, .../declared-side, .../home |
| Participants | POST/PATCH .../participants, PUT .../proceedings/{id}/participant-roles/{participant_id} |
| Filings | POST .../proceedings/{id}/filings, plus proceedings, stages, exhibit register, matter-owned proceeding types |
| Annotations | POST/PATCH /app/matters/{matter_id}/annotations; POST/PATCH/keep on /app/documents/{document_id}/annotations |
| Claims | POST/PATCH on claims, block-refs, RBR links, validation checks, validation-check resolution, and matter RBRs |
Thirty-eight routes carry the pair. The margin-note routes under
/app/documents/{document_id}/annotations are the ones that
gained a guard rather than loosened one: create, edit and keep
previously required no permission and no capability at all, so any
authenticated firm user could reach them. They now resolve the owning matter
(from the annotation for edits, from the document for creates) and apply the
same two-axis gate.
What a member still may not write
Agent-produced artifacts keep matters:write: the Matter Frame
and its locks and proposals, adversary reads, canonical document blocks,
document-relationship confirmation, and document re-read. So do the custody
controls (legal hold, restore, document-family wave release), cross-matter
operations (related matters, carry-forward, copy-to-matter), firm-level
entities and affiliations (DL23 — firm-shared identity edges with no
matter_id to scope to), and every firm settings catalog.
Every destructive operation is unchanged. PATCH
/app/matters/{matter_id} is now member-reachable for ordinary edits,
but status=archived is still refused inside
MatterService.update_matter — the field-level guard F6
installed — so opening the route did not open the archive.
Red-first and behavioral proof
| Scenario | Evidence |
|---|---|
| S9 | A staffed member drives all six DL21 classes through the real router against PostgreSQL with RLS: matter title, claim, annotation, participant create and edit, upload presign, document privilege, and a filing inside a proceeding they created. |
| S10 | Staffed editor, member-lead, and an unstaffed firm admin all still write content. The admin case pins the bypass: they are on no roster, so a guard that started demanding an assignment row would fail for them. |
| S11 | The same member, on the matter they are not staffed on, is refused on all six classes with pramaan.matter.capability_forbidden, and the database shows the title unchanged and zero claims, annotations and participants written. |
| S12 | Same actor, same matter, same payload, assignment row deleted — 403. This isolates the per-matter half from the firm half. |
| S13 / S17 | On the matter where their content writes succeed, the member is refused on claim delete, participant delete, document delete, bulk document delete, legal-hold lift, and status=archived; every row survives. |
| Planted negative | The capability sub-dependency was deleted from the guard. Five tests went red — the whole-app permission-without-capability inventory, the member-enabled route inventory, F1's two-matter containment test, S11 and S12 — and returned to green when it was restored. |
| Inventory drift | test_content_write_permission_never_ships_without_its_matter_guard walks every mounted route (not just {matter_id} paths) and fails if any route carrying matters:content_write lacks the content:write capability, with its own planted permission-only route as the negative control. |
Cost
The per-matter guard adds two fixed statements per guarded write: one
app.current_* context read and one staffing
SELECT ... FOR UPDATE. The presign query budget
(pramaan-docs ADR-130) moves 14 → 16 to account for them. The cost is
constant — it does not scale with documents, participants or team size,
which is the regression class that budget exists to catch.