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 fieldsPATCH /app/matters/{matter_id}
Documents — uploadPOST .../documents/presign-upload, .../documents/imports, .../documents/{doc_id}/finalize
Documents — metadata editPATCH .../documents/{doc_id}/privilege, .../declared-side, .../home
ParticipantsPOST/PATCH .../participants, PUT .../proceedings/{id}/participant-roles/{participant_id}
FilingsPOST .../proceedings/{id}/filings, plus proceedings, stages, exhibit register, matter-owned proceeding types
AnnotationsPOST/PATCH /app/matters/{matter_id}/annotations; POST/PATCH/keep on /app/documents/{document_id}/annotations
ClaimsPOST/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

ScenarioEvidence
S9A 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.
S10Staffed 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.
S11The 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.
S12Same actor, same matter, same payload, assignment row deleted — 403. This isolates the per-matter half from the firm half.
S13 / S17On 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 negativeThe 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 drifttest_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.