Story-3389 · functions lane · RFD-MATTER-006 F5 · DL2 / DL27 / DL30

A suspended lead stops holding the seat

Clerk owns the firm-membership lifecycle. Deactivate, suspend and firm-removal all land in the two webhook mirror methods — and until this story those methods flipped identity.firm_memberships and never touched business.matter_assignments. A member Clerk had removed from the firm still appeared on every matter team they were on, still held lead counsel, and nothing said otherwise. The only path that ever freed a seat was the Settings→Team admin action, which is not the path Clerk uses.

Clerk event ─→ apply_webhook_status_change / apply_webhook_role_change
                              │  (same transaction)
                              └─→ reconcile_membership_team_assignments
                                        │
    active member/editor/admin ─────────┴─→ no-op: the lead keeps the seat
    inactive / firm-removed / support ──┴─→ every assignment removed
                                             ├─ was lead   → matter No-lead
                                             └─ was last   → matter No-members

The other half: editor-only was the wrong question

The reconciliation kept a lead only when the resulting membership was an active firm_editor. An active firm_member who led a matter fell into a branch that demoted them to a plain teammate, and an active firm_admin lead was removed outright. F2 had already retired editor-only lead eligibility at create and assign (DL2: any active member, editor or admin may lead), so the cascade was enforcing a rule the rest of the domain no longer held. It is now one predicate, read by both the preview and the realized reconciliation.

Resulting membershipMatter team
Active member, editor or adminUnchanged. A lead keeps the lead — a demotion inside the eligible set unseats nobody.
Deactivated / suspendedEvery assignment removed. A vacated lead leaves the matter No-lead.
Firm-removedSame, with membership_removal as the recorded reason.
Moved to supportSame: the role cannot hold a matter seat.

Vacating REMOVES the assignment rather than demoting it. Demotion was the old behaviour and it leaves an inactive user staffed on a live matter — the exact seat this rule exists to free.

The preview now says what will actually happen

Settings→Team shows a dry run before an admin deactivates someone. It used to offer an outcome claiming a replacement lead would be promoted into the vacated seat — a mechanism F3 retired, so the preview was describing work nobody does. It now returns only outcomes the reconciliation realizes, and it computes them from the same team sizes:

OutcomeMeaning
unchangedThe membership stays lead-eligible; the led matter is untouched.
no_leadThe lead seat is vacated and stays vacant until a human fills it.
no_membersThe matter's last assignee is leaving. Outranks no_lead: an empty team has no lead either, and the emptier fact is the one an admin must act on.

A plain member leaving a team that survives reports no consequence at all — the matter keeps its lead, and a warning there would train admins to click past warnings that mean nothing.

The seat is freed under the lock every other team writer takes

The reconciliation locks each matter row before re-reading the assignment rows, so the role values it acts on were read under serialization rather than carried across the gap. Removal goes through the security-definer door business.remove_matter_team_assignment, the same one the roster routes use. The remaining-team count that decides no_members is read AFTER the removals and under those same locks, so it is the team the matter actually has and not a pre-removal number arithmetic was applied to.

The webhook session carries staff context and no app.current_user_id, so the reconciliation re-scopes itself to the affected firm exactly as the channel offboard sweep already does. It runs inside the webhook transaction: if it raises, the mirror flip rolls back with it and the message retries rather than leaving the membership inactive and the seat occupied.

A distinct verb, because nobody performed this removal

The cascade emits matter.roster_vacated_membership_lifecycle, not matter.removed. Reusing the removal verb would credit a colleague with an action they never took and would hide the lifecycle cause an admin needs to read next to a No-lead matter. The event carries the previous team role, the resulting firm role and status, and which of the four reasons freed the seat.

Proof

ScenarioEvidence
S45A Clerk organizationMembership.deleted event takes the lead off the team, leaves the matter No-lead, and writes one lifecycle-vacancy audit row with a null actor. A replay writes no second row.
S46Suspended and deactivated memberships both free the seat; the last teammate going inactive empties the team.
S58A lead on two matters previews one No-lead consequence per matter.
S59Deactivating the matter's sole assignee previews No-members.
S60Deactivating a plain member previews and realizes no consequence; the lead stays seated.
S61No preview outcome promotes a replacement, asserted against the shipped enum so a future promotion outcome cannot be added silently.
DL2 regressionMT-12 and MT-14 were rewritten: an active editor-lead demoted to member keeps the lead, and the preview reports it unchanged. Both were red against the old branch.
Red-firstWith the webhook wiring reverted, six of the seven new lifecycle proofs fail; the seventh is the control that must pass either way.

One-sentence recall

Because Clerk is where deactivation actually happens, the mirror methods now run the same reconciliation the admin path runs — and because any active firm user may lead, only leaving that set frees the seat, which then stays vacant and says so.