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
| Actor | May remove | May not remove | Scenarios |
|---|---|---|---|
| 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
}
- Any actively staffed member, editor, lead, or rostered admin can leave (S43).
- A departing lead leaves a visible No-lead state. No candidate is auto-promoted (S44).
- The last staffed person may leave. The roster becomes empty; the matter remains reachable through firm-admin bypass.
- The response names the departed user and the resulting No-lead / No-members state, so the app does not infer either.
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
MatterService.demote_editorand its audit reason.- Any PUT or PATCH role-change route at
/team/{user_id}. - Every raw runtime assignment-removal writer replaced by the capability function.
- The app role's direct assignment DELETE privilege and its retired RLS policy.
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
| Evidence | Result 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.