Story 1347: Identity-hat invariant — platform_staff ⊥ firm_membership

Implements the four decisions of pramaan-docs ADR-123: a single Clerk identity may wear the PRAMAAN-employee hat (platform_staff) or a firm-member hat (firm_membership), but never both at once. Staff reach a firm's data only through the read-only, audited support grant of pramaan-docs ADR-019.

Why (the catastrophe avoided)

On the shared dev/staging database, PRAMAAN platform admins (ankit.k@pramaan.io, ayush.s@pramaan.io, nirbhaya@pramaan.io) held active firm_admin/firm_member rows in the firm “Lexford Chambers”. Because authorization flows through Clerk org membership (pramaan-docs ADR-019), those staffers surfaced inside the firm workspace as selectable Lead Counsel with full read-write scope — bypassing the time-boxed, revocable, read-only support-grant governance entirely. The governance layer was never wrong; it was simply never reached, because the resolver found a real firm_membership hat first.

This Story makes that class of bug unrepresentable: a staff identity cannot be given a firm membership (D1/D2), and even a stale row confers no firm scope (D3).

What changed

SurfaceBeforeAfter
src/shared/settings.py No internal-domain config. New INTERNAL_EMAIL_DOMAINS setting (default pramaan.io), parsed into a normalized internal_email_domains frozenset via get_settings(). (D2/D4)
src/shared/identity_hats.py (new) — Single enforcement point: is_internal_email(), is_active_platform_staff(), and assert_firm_membership_allowed() — the guard every firm-membership write path calls.
src/shared/errors/exceptions.py — New typed PramaanIdentityHatError (409) with codes pramaan.identity.internal_domain_forbidden and pramaan.identity.platform_staff_exclusion.
src/domains/firm_membership/service.py (apply_webhook_upsert) Inserted a membership row for any Clerk org-membership event. Calls the guard for CLIENT-firm roles before inserting (D1/D2). The org:support pathway (FirmRole.SUPPORT) is exempt. Data-layer choke-point — covers every create path.
src/shared/webhooks/clerk_dispatcher.py organizationMembership.created always mirrored the row. Backstop (D2): catches PramaanIdentityHatError, logs the rejection, and records terminal ledger outcome rejected_identity_hat — no row, no Svix retry storm.
src/shared/db/rls/_verify.py + app.py An active firm_membership row opened read-write firm scope. Resolver precedence (D3): an active platform_staff identity is denied read-write firm scope on both firm-scoped paths, even with a stale membership row. Staff fall through to the read-only support session.
alembic/.../0103_demote_staff_firm_memberships.py (new) — Widens the webhook outcome CHECK constraint, and deactivates (status → removed) existing contaminating staff/internal-domain memberships (the Lexford Chambers rows).

Why a guard at the data layer, not just the webhook

The issue requires the invariant to hold for every write path (webhook upsert today; any future invite-driven creation). Putting assert_firm_membership_allowed() inside FirmMembershipService.apply_webhook_upsert() means no caller can create a contaminating row, regardless of entry point. The webhook handler is the operational wrapper: it turns the typed error into a logged, non-retried terminal outcome.

Why the resolver ships before the cleanup (D3 first)

The dev/staging database is shared, so a one-time row deletion is not durable — a re-seed or mis-invite re-creates the rows. Per ADR-123, the resolver demotion (D3) ships in the same change as the code, so the contamination is non-exploitable even while stale rows still exist. The migration then removes the rows so member lists, pickers, and any non-resolver read path also stay clean.

Why org:support is exempt

The support pathway of pramaan-docs ADR-019 deliberately adds staff into a firm's Clerk org with role org:support (read-only) so authorization uses one code path. The guard keys off client-firm membership, which org:support is not — so a FirmRole.SUPPORT membership skips the guard, even on an internal-domain email. This is verified by test_webhook_support_role_exempt_even_on_internal_domain.

Verification at the system boundary

Per AGENTS.md (“done = system boundary, not component boundary”), the new tests fire real Svix-signed requests through the ASGI app against a real (freshly migrated) Postgres, and exercise the resolver against real RLS policies:

TestAsserts
test_webhook_internal_domain_email_rejected@pramaan.io membership → 200, outcome rejected_identity_hat, zero rows. (D2)
test_webhook_platform_staff_user_rejectedActive platform_staff on a non-internal email → rejected, zero rows. (D1)
test_webhook_support_role_exempt_even_on_internal_domainorg:support on @pramaan.io → allowed, row with role support. (D2 exemption)
test_webhook_create_membership_then_role_change (existing)Empty firm, first member on a non-internal domain → succeeds. (first-run/empty state)
test_active_member_resolves_firm_roleNon-staff active member resolves their firm role. (positive D3 path)
test_platform_staff_denied_firm_scope_despite_stale_membershipStale active membership + staff hat → PramaanForbiddenError pramaan.identity.platform_staff_no_firm_scope. (D3)

Consumer mirror impact (app / admin)

This repo owns the webhook backstop and the resolver precedence. The ADR-123 D2 front-door invite check lives in the consumers, because firm invitations are issued through Clerk on Vercel (pramaan-app / pramaan-admin), not in pramaan-functions. Those surfaces should reject internal-domain invitee emails before issuing the Clerk org invitation and reverse a rejected membership via deleteOrganizationMembership. No OpenAPI contract changed in this repo (no new/changed route or DTO), so no app/admin codegen mirror update is required here.

Demo / dogfood (D4)

No code carve-out: the guard keys off INTERNAL_EMAIL_DOMAINS, so a demo/dogfood “firm” provisioned on a non-internal domain (e.g. demo.pramaan-app.com) passes as genuine firm members. The internal-domain guard therefore has no exceptions.