Story-3387 · functions lane · RFD-MATTER-006 F3
The lead seat has one lock and no invisible successor
Assign, claim, and transfer now use one compare-and-set command. Every lead-loss path leaves a visible, audited vacancy. No service path silently selects the next editor, and a losing concurrent request receives the winning roster instead of a 500.
lock matter row
→ read canonical lead under the lock
→ compare expectedLeadUserId
→ validate actor and active target
→ demote old lead + install new lead atomically
→ flush
→ write one combined audit event
The HTTP contract makes the race explicit
PUT /app/matters/{matter_id}/lead now requires
expectedLeadUserId. It is the UUID the caller saw, or JSON
null for a vacant seat. A stale command returns
409 lead already claimed with details.winningRoster.
If the requested user is already lead, the command is an audit-free no-op even
when it is an exact replay carrying the old expectation.
Occupied and vacant authority
| Seat | Actor | Decision |
|---|---|---|
| Occupied | Current lead or firm admin | May assign an eligible existing member or add an eligible non-team target directly as lead. |
| Occupied | Any other member/editor | 403; staffing alone cannot unseat the lead. |
| Vacant | Staffed member/editor, or firm admin | May claim or assign an eligible target, including a non-team target. |
| Vacant | Unstaffed non-admin | 403. |
| Either | Any actor choosing an inactive target | 403. |
F2 has not landed in this branch. Target eligibility therefore deliberately remains the existing active-editor check. The lock, authority, lifecycle, and inactive-target contracts are complete here; F2 owns broadening the eligible active roles.
No automatic vacancy repair remains
Lead self-removal, admin removal, demotion, firm-role reconciliation, and membership
deactivation all lock the affected matter before changing the lead seat. They flush
the lead loss, emit the existing removal or role-change audit, and return a no-lead
disclosure. Preview DTOs and OpenAPI expose only unchanged and
no_lead; the former promoted-user response branch is gone.
An executable repository scan covers source, workflows, documentation, and OpenAPI so the deleted repair mechanism and its audit vocabulary cannot reappear unnoticed.
Concurrency proof
The integration suite opens two independent HTTP clients and lets both transactions contend for the same vacant matter. The matter row is the serialization point: one request commits, the other re-reads the winner and returns a deterministic 409. A second race pits a staffed claimant against an admin assigning a non-team target; the losing non-team target is never inserted.
Acceptance proof
| Scenario | Evidence |
|---|---|
| S33 | Lead self-removal leaves an audited no-lead roster. |
| S34–S36 | Lead/admin replacement demotes the old lead; a non-team target is inserted once as lead and produces one combined audit. |
| S37 | A staffed non-lead cannot replace an occupied lead. |
| S38–S40 | Vacant-seat leniency admits staffed actors/admin and refuses an unstaffed non-admin. |
| S41 | Real two-session double-claim and claim-vs-admin races each yield one 200, one deterministic 409, one lead, and one audit. |
| S42 | An inactive target returns 403 without a mutation or audit. |
| Replay | Exact replay and self-to-self assignment preserve the roster without another audit. |
| Red first | The focused acceptance run was observed with 13 failures and 1 pass before implementation, then 19 passes after the protocol landed. |
| Regression | make test: 6,337 passed, 3 skipped against an isolated PostgreSQL database. |