Story 2670: Firm membership vacancy hooks

This is the historical walkthrough for F4 of RFD-MATTER-001. Story 3387 later replaced its automatic vacancy-repair policy: lead-loss actions now leave a visible, audited no-lead state. The membership reconciliation entry point remains, but it no longer selects a replacement lead.

1. The production wiring

PathWhat changed
PATCH /app/firm/members/{user_uuid}/firm-role After the membership role change is flushed, the route calls reconcile_membership_team_assignments. If the target led matters, the seat is left vacant and the matter is flagged with no lead. The membership mutation still succeeds.
POST /app/firm/members/{user_uuid}/deactivate After the status change is flushed, the same reconcile entry point removes or demotes the target's team rows and leaves a vacated lead seat unfilled. A no-lead result is returned as a warning, not a refusal.
Current behavior is governed by Story 3387: every membership-triggered lead loss is serialized on the matter row, audited, and reported as no-lead. There is no automatic replacement path.

2. Preview and mutation response shape

The app can now ask what will happen before showing the confirmation dialog:

GET /app/firm/members/{user_uuid}/role-change-preview?roleId=<role_uuid>
GET /app/firm/members/{user_uuid}/deactivate-preview

Both return:

{
  "targetUserId": "...",
  "hasWarnings": true,
  "consequences": [
    {
      "matterId": "...",
      "matterNumber": 12,
      "matterRef": "M-012",
      "title": "ABC v XYZ",
      "outcome": "no_lead",
      "warning": "No lead counsel remains assigned to this matter."
    }
  ]
}

outcome is either unchanged or no_lead. The firm-role and deactivate mutation responses embed the same object as teamChange, describing the consequence that actually happened after the flush.

3. Audit

Assignment mutations produced by the cascade emit the same matter-team audit vocabulary as the direct team endpoints:

ChangeAudit action
Member added, demoted, or transferred between team rolesmatter.assigned
Member removed from a matter team because their firm role/status no longer allows staffingmatter.removed
Firm admin opens a matter they are not staffed onmatter.viewed

matter.viewed is wired through the matter detail read path. It records a coarse admin off-team open with authoritative_content_read=false; it is access history, not a claim that counsel read the matter contents.

4. Acceptance coverage

ScenarioProof
MT-14Demoting the only lead previews and returns a no-lead warning, and the roster discloses no lead.
MT-21Deactivating the only lead previews and returns a no-lead warning; the mutation is not refused.
MT-30An admin off-team matter detail read emits matter.viewed.
MT-12A membership change that vacates the lead emits membership and matter-team audit rows.
MT-04The existing admin-add-editor scenario stays green with the new matter-team audit wiring.

5. Verification

make openapi
make gen-permissions
make check-features
bash scripts/run-with-test-db-env.sh uv run pytest tests/steps/test_matter_team.py -q --tb=short
uv run pytest tests/contract/test_app_firm_roles_openapi.py -q
make test
bash scripts/run-with-test-db-env.sh make contract-test PRAMAAN_APP_SCHEMAS_DIR=/Users/am/projects/pramaan-io/pramaan-app/src/gen

Final full-suite result: 3735 passed, 3 skipped. Contract validation generated and validated 6 fixtures against the app schemas.