Story-3388 · Epic-3384 · RFD-MATTER-006 F4

Removal now means one thing, and leaving is its own action.

The Matter Team roster now follows the DL15 removal ladder all the way from the HTTP guard to the database write. A staffed person can leave without asking a more powerful colleague to remove them. The old demote_editor transition is gone, so role transitions have only the RFD-approved doors: Assign, Transfer Lead, and Leave.

The removal ladder

ActorMay removeMay not removeScenarios
Firm member Another non-lead firm member Editor, admin, or lead S24–S25, S31
Firm editor Non-lead members and editors Admin or lead S26–S27, S31
Lead counsel Every non-lead roster entry, including a roster-only admin Another lead seat S28–S29
Firm admin Any roster entry, including the lead — S30, S32

The lead seat is protected independently of the lead user's firm capability. Changing that person from member to editor or admin does not let a member or editor unseat them. Only a firm admin, the lead leaving themselves, or Transfer Lead can vacate that seat.

The database is the final arbiter

The first red S24 run found a dangerous false success: the application guard admitted member-to-member removal and returned 200, while the RLS SELECT policy hid the peer row from DELETE and PostgreSQL changed zero rows. The fix is one narrow capability function, business.remove_matter_team_assignment. It takes no claimed actor or role, locks the matter row, reads the actor from authenticated session context, evaluates the same ladder through the canonical roster reader, and deletes exactly one assignment in the same call.

HTTP remove / leave
        │
        ├── application guard: _require_can_remove / _require_staffed
        │
        └── database capability: remove_matter_team_assignment
                ├── lock matter FOR UPDATE
                ├── re-evaluate actor + protected lead seat
                └── DELETE one roster row atomically

The runtime role receives EXECUTE on that function, not wider table visibility. Its legacy table-level DELETE grant and DELETE policy are revoked and dropped, and raw application DELETE statements for business.matter_assignments are gone.

Leave Team

DELETE /app/matters/{matter_id}/team/self

200 OK
{
  "matterId": "…",
  "matterNumber": 42,
  "departedUserId": "…",
  "previousTeamRole": "lead_counsel",
  "noLead": true,
  "noMembers": false
}

Admin roster removal tells the truth

Removing a non-lead firm admin removes only their roster row. It does not revoke access: the firm-admin bypass still reaches the matter. That path now emits the distinct action matter.roster_removed_admin_access_retained, with firm_admin_access_retained: true and an “Access effect” summary. It is never presented as an access revocation.

What was deleted

Settings → Team preview behavior is unchanged. Lead claim/transfer and eligibility remain the neighboring F2/F3 work. There is no destructive manifest, webhook, or UI preview change in this Story.

Acceptance evidence

EvidenceResult represented
test_matter_resource_authorization.py S24–S32 and S43–S44 exercise real HTTP sessions and durable database state.
test_matter_team_route_inventory.py The demote symbol and route are absent; raw assignment DELETE is absent from runtime code.
test_matter_team_removal_function_migration.py The capability is narrow, reversible, locked, granted explicitly, contains the full ladder, and retires the direct DELETE privilege/policy.
openapi/app-api.json The generated contract exposes appLeaveMatterTeam and MatterTeamLeaveResponse.

Red-first observations were retained in the implementation history: the missing static self route resolved as a UUID and failed 422; the live demote service failed the inventory; the generic admin-removal event was absent; and S24 returned success without deleting. Each detector was then rerun against the repaired state.