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

SeatActorDecision
OccupiedCurrent lead or firm adminMay assign an eligible existing member or add an eligible non-team target directly as lead.
OccupiedAny other member/editor403; staffing alone cannot unseat the lead.
VacantStaffed member/editor, or firm adminMay claim or assign an eligible target, including a non-team target.
VacantUnstaffed non-admin403.
EitherAny actor choosing an inactive target403.

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

ScenarioEvidence
S33Lead self-removal leaves an audited no-lead roster.
S34–S36Lead/admin replacement demotes the old lead; a non-team target is inserted once as lead and produces one combined audit.
S37A staffed non-lead cannot replace an occupied lead.
S38–S40Vacant-seat leniency admits staffed actors/admin and refuses an unstaffed non-admin.
S41Real two-session double-claim and claim-vs-admin races each yield one 200, one deterministic 409, one lead, and one audit.
S42An inactive target returns 403 without a mutation or audit.
ReplayExact replay and self-to-self assignment preserve the roster without another audit.
Red firstThe focused acceptance run was observed with 13 failures and 1 pass before implementation, then 19 passes after the protocol landed.
Regressionmake test: 6,337 passed, 3 skipped against an isolated PostgreSQL database.