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
| Surface | Before | After |
|---|---|---|
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:
| Test | Asserts |
|---|---|
test_webhook_internal_domain_email_rejected | @pramaan.io membership → 200, outcome rejected_identity_hat, zero rows. (D2) |
test_webhook_platform_staff_user_rejected | Active platform_staff on a non-internal email → rejected, zero rows. (D1) |
test_webhook_support_role_exempt_even_on_internal_domain | org: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_role | Non-staff active member resolves their firm role. (positive D3 path) |
test_platform_staff_denied_firm_scope_despite_stale_membership | Stale 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.