You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
Split child of #1947 (Phase 7). Filed by Scope per the Ready-for-spec-and-split decision recorded on #1947 (#1947 (comment)). Not Ready.
Parent:#1947, which keeps its own gates and is delivered by this slice (#1947a) plus #1947b.
Scope
#1947a covers #1947 item 1, item 3, the entity half of item 4 and (optional) item 5 registration:
Root: the canonical public.legal_entities table keyed (tenant_id, id), with same-Tenant composite FKs, restrictive deletes, forced RLS, and writes only through tenant_guard guarded functions. User-facing owner writes are held for the Phase 12 gate.
Setup: idempotent, concurrency-safe ordinary-Tenant setup that creates or selects exactly one default, without guessing organization facts. The default is never a persistence or runtime fallback.
Check: an exact Tenant + Legal Entity check with typed results (available / unavailable / refused), where a foreign id and an unknown id give the same answer.
supabase/migrations/20261025124400_legal_entities.sql plus rollback_20261025124400_legal_entities.sql. Version is reserved; Samson may renumber at handback to keep merge order.
Spec r1 (Scope, Oct 26, 2026, 8:19 AM PT), pending Samson approval
#2403 = #1947a, split from #1947 per the Ready-for-spec-and-split decision (#1947 (comment)). Status: spec only. Not Ready. Building starts only after Blake marks it Ready, and the OPEN gates in §14 stay open until their owners close them.
§0 What I checked
develop 88eefbd9 (Oct 26, 2026, 12:27 AM PT). Newest migration: 20261025120000_donor_feed_preferences_own_rows.sql. No legal_entities object exists on develop, on any remote branch, or in the one open PR (Modernize Sentry, content, email and browser payment SDKs #2214, which touches no migration).
Authority read:[Phase 7] Canonical Legal Entity and immutable issuer-profile runtime foundation (Backlog draft) #1947's body (items 1–5, the acceptance list, prerequisites and non-goals); ADR-0044 lines 25–60; the Phase 7 PRD Legal Entity amendment (lines 13–23: "introduces the canonical legal_entities root and seeds exactly one default for the ordinary tenant... Single-entity users see no selector"); and CONTEXT.md "Legal Entity" (line 6780) and "Legal Entity scope" (line 2898).
Substrate read:supabase/migrations/20261024150000_tenant_guard.sql, docs/security/tenant-guard.md ("Adding a guarded function" steps 1–6 and "Adding a tenant table"), packages/api/src/identity/tenant-guard.ts (GUARDED_RPCS, withTenant), and packages/api/src/projection/types.ts:50,119 and validation.ts:98–134 (they already model legalEntities scope and read row.legal_entity_id; untouched here).
One canonical root. Each Legal Entity is one row of public.legal_entities, owned by exactly one Tenant, with a stable identity (tenant_id, id). Downstream financial roots (first AL-1998: [Phase 9] Manage one stable Ministry Assignment and its separate participant memberships (P27-S01) #1998) store the exact entity through a composite FK (tenant_id, legal_entity_id) → legal_entities (tenant_id, id) ON DELETE RESTRICT. A reference can't point at another Tenant's entity, and an entity in use can't be deleted.
Identity never changes.tenant_id and id can't be updated, by anyone.
One quiet default per Tenant. Ordinary-Tenant setup creates the Tenant's default, or returns it if it already exists. Running it twice, or concurrently, gives exactly one default. It records no organization facts (no name or label is guessed). Single-entity Tenants need no selector, and nothing in this slice shows one.
The default is not a fallback. Nothing in this slice resolves a missing entity to the default. The default exists only for future setup to prefill. Changing the default is Out (§3), and reassigning historical rows is prohibited (ADR-0044).
Exact scope check. A server caller can ask "is Legal Entity E in Tenant T?" and gets a typed answer:
available, with the exact tenantId and legalEntityId;
unavailable (no detail) when E doesn't exist or belongs to another Tenant, so the two cases look identical;
refused with reason missing_scope when T or E is absent or malformed. In that case the database isn't called.
Guarded access only. Reads and writes go through tenant_guard guarded functions called via withTenant. anon, authenticated and service_role hold no table privilege. User-facing owner writes are held for the Phase 12 gate (§14 G5).
§2 In
Migration supabase/migrations/20261025124400_legal_entities.sql (BEGIN … COMMIT, SET LOCAL lock_timeout = '5s', idempotent, -- Down: header). In order:
tenant_id uuid NOT NULL REFERENCES public.tenants(id) ON DELETE RESTRICT
id uuid NOT NULL DEFAULT gen_random_uuid()
is_tenant_default boolean NOT NULL DEFAULT false
created_at timestamptz NOT NULL DEFAULT now()
PRIMARY KEY (tenant_id, id)
UNIQUE (id), so an id never repeats across Tenants. The composite PK stays the FK target.
A partial unique index legal_entities_one_default on (tenant_id) WHERE is_tenant_default.
A BEFORE UPDATE trigger (function public.legal_entities_identity_immutable(), SECURITY INVOKER, SET search_path = '', REVOKE ALL … FROM PUBLIC, anon, authenticated, service_role). It raises if tenant_id or id changes.
Backfill: one default row for every existing Tenant (INSERT … SELECT id, true FROM public.tenants ON CONFLICT DO NOTHING), placed before RLS is forced.
ENABLE and FORCE ROW LEVEL SECURITY, then policy tenant_guard_isolationFOR ALL TO tenant_guard USING (tenant_id = tenant_guard.current_tenant_id()) WITH CHECK (same).
Grants: REVOKE ALL ON public.legal_entities FROM PUBLIC, anon, authenticated, service_role, then GRANT SELECT, INSERT ON public.legal_entities TO tenant_guard. No UPDATE, DELETE or TRUNCATE for tenant_guard.
Guarded function tenant_guard.ensure_default_legal_entity(p_tenant_id uuid) RETURNS jsonb. It is SECURITY DEFINER with SET search_path = '' and starts with PERFORM tenant_guard.enter(p_tenant_id). It runs INSERT … ON CONFLICT (tenant_id) WHERE is_tenant_default DO NOTHING, then selects the default, and returns {tenant_id, legal_entity_id, created}. An unknown Tenant fails on the FK (23503) and writes nothing. It doesn't read public.tenants, because tenant_guard has no SELECT there.
Guarded function tenant_guard.check_legal_entity(p_tenant_id uuid, p_legal_entity_id uuid) RETURNS jsonb, with the same DEFINER hygiene. It returns {tenant_id, status: 'available', legal_entity_id} or {tenant_id, status: 'unavailable'}.
Public SECURITY INVOKER wrappers public.ensure_default_legal_entity(uuid) and public.check_legal_entity(uuid, uuid), owned by postgres, with REVOKE ALL … FROM PUBLIC, anon, authenticated, service_role and GRANT EXECUTE … TO service_role.
SELECT pg_notify('pgrst', 'reload schema').
Rollback supabase/migrations/rollback_20261025124400_legal_entities.sql. Guarded and idempotent. It RAISEs, and changes nothing, if any FK constraint outside this migration references public.legal_entities. Otherwise it drops the wrappers, the guarded functions, the trigger and its function, and the table.
packages/api/src/identity/tenant-guard.ts: add ensure_default_legal_entity and check_legal_entity to GUARDED_RPCS.
Both run through withTenant. Missing or malformed input returns refused before any client call.
Any other error is thrown, never mapped to available.
supabase/seed.sql:
add DELETE FROM public.legal_entities; immediately before DELETE FROM public.tenants;;
after the demo tenant insert, add SELECT public.ensure_default_legal_entity('00000000-0000-0000-0000-000000000001');, so the demo tenant has exactly one default.
.github/workflows/ci-integration.yml, job migrate, step "Verify tenant isolation (real stack)": append tests/unit/acceptance/issue-2403/legal-entities.realstack.acceptance.test.ts to the run: path list. This is a one-line edit with no new step, and env is unchanged.
Pins on that string:tests/unit/helpers/workflow-yaml.ts:455PINNED_STEP_BODIES (Builder), plus the cross-slice re-pins in §6 (Acceptance).
docs/security/legal-entities.md (new, short): the root, the default rule (prefill only, never a fallback), the check contract and its three results, and "how a new financial root references a Legal Entity" (composite FK, RESTRICT, tenant_id NOT NULL).
A trigger on public.tenants that creates defaults automatically (OQ7).
§4 Done criteria
[A] = Acceptance test. [R] = real stack, in "Verify tenant isolation (real stack)". [B] = Builder unit test.
[A][R] Shape: the table has the four columns with the stated types, nullability and defaults; PK (tenant_id, id); UNIQUE (id); FK tenant_id → tenants(id) with ON DELETE RESTRICT; the partial unique index on (tenant_id) WHERE is_tenant_default.
[A][R] Backfill: on the migrated CI database, every row of public.tenants that existed at migration time has exactly one is_tenant_default row. After "Apply demo seed", tenant …0001 has exactly one default.
[A][R] Ensure, idempotent: calling ensure_default_legal_entity(T) twice on a fresh Tenant T returns the same legal_entity_id, with created true then false, and leaves exactly one row for T.
[A][R] Ensure, concurrent: 8 parallel sessions call ensure for a fresh Tenant. All return one legal_entity_id, exactly one reports created: true, and T has exactly one row.
[A][R] Ensure, unknown Tenant: it raises 23503 and inserts no row. No row exists for a Tenant id that isn't in tenants.
[A][R] One default: a direct admin insert of a second default for T fails on legal_entities_one_default.
[A][R] Identity: an admin UPDATE changing tenant_id or id raises. Updating is_tenant_default alone is not blocked by the trigger, but no role besides the owner can do it (see 9).
[A][R] RLS posture: relrowsecurity and relforcerowsecurity are both true. The only policy is tenant_guard_isolationFOR ALL TO {tenant_guard}, keyed on tenant_guard.current_tenant_id(). Under SET ROLE tenant_guard with Tenant A entered, B's rows are invisible, and an insert for B is rejected by WITH CHECK.
[A][R] Grants: anon, authenticated and service_role have no SELECT, INSERT, UPDATE, DELETE or TRUNCATE on the table. tenant_guard has exactly SELECT and INSERT.
[A][R] Same-Tenant references: in a rolled-back transaction, a scratch table with (tenant_id, legal_entity_id) → legal_entities (tenant_id, id) ON DELETE RESTRICT:
accepts A's entity with tenant A;
rejects B's entity with tenant A (23503);
blocks deleting a referenced entity (23503).
Deleting a Tenant that owns an entity is blocked (23503).
[A][R] Check: for an entity E of Tenant A, check_legal_entity(A, E) returns available. check_legal_entity(A, E_of_B) and check_legal_entity(A, random) return byte-identical {tenant_id: A, status: 'unavailable'} payloads.
[A][R] Functions: both guarded functions are owned by tenant_guard, are SECURITY DEFINER and pin search_path = '', and grant EXECUTE only to service_role. Both public wrappers are INVOKER, owned by postgres, with EXECUTE for service_role only. anon and authenticated calls fail with 42501.
[B] TS contract: checkLegalEntityScope returns refused/missing_scope with zero client calls for a missing or malformed tenantId or legalEntityId, and maps available and unavailable exactly. Any other RPC error is thrown. ensureDefaultLegalEntity goes through withTenant, and a foreign echoed tenant_id throws.
[B] GUARDED_RPCS contains exactly the four names: the two existing ones and the two new ones.
[A] Seed: the DELETE FROM public.legal_entities; line comes before DELETE FROM public.tenants;, and the seed applies twice in a row without error.
CI: "Verify tenant isolation (real stack)" runs the new real-stack file, and the PINNED_STEP_BODIES, STEP_RUN and fingerprint pins match the appended string.
owned functions 2 → 4; the ACL inventory adds public.legal_entities INSERT, public.legal_entities SELECT; DEFINER list 2 → 4; the FN function list adds the two new guarded functions and two new wrappers
tests/unit/acceptance/issue-2155/postgres-ecr-gates.acceptance.test.ts (~302) and tests/unit/acceptance/issue-2201/merge-group.acceptance.test.ts (~193)
add the reviewed fingerprint row for the Builder commit that appends the path
One path is appended to the existing "Verify tenant isolation (real stack)" step. Env (TENANT_GUARD_IT_ADMIN_DATABASE_URL) is unchanged, with no new step, job or secret. The migration runs in "Apply migrations" and the seed in "Apply demo seed", both already present.
§9 Data, grants, person_id and donor spine
Data: the migration inserts one default row per existing Tenant. It writes no other table.
Grants: as in §2.1.7 and §2.1.10–11. No authenticated or anon path.
Test budget, sized from Acceptance's real-stack needs:
Acceptance, ~520:
real-stack file ~380. It needs two-Tenant fixture setup and teardown (~60), concurrency harness with 8 sessions (~50), criteria 1–13 at ~20 each (~260) and helpers (~10);
OQ2, backfill existing Tenants:yes, in the migration, per the Phase 7 PRD ("seeds exactly one default for the ordinary tenant"). If no, only the ensure path creates defaults.
OQ9, "ambiguous" scope:treated as missing_scope. It covers a Tenant without an entity, or an entity without a Tenant. No default is substituted.
§13 Deploy
Production lags develop. Assume production lacks many develop versions, including 20261024150000_tenant_guard and everything after it. This migration has a pre-assert and fails cleanly, in one transaction, if tenant_guard isn't there. It must apply after the whole develop chain, in version order.
What the hosted apply does (one time):
creates public.legal_entities, its index, trigger, policy and grants, plus 2 guarded functions and 2 wrappers;
inserts one default row per production Tenant;
user-visible behavior doesn't change (no UI, no authenticated path).
G6 OPEN: Blake's scope, order and Ready decision for this child.
G7: refresh overlap and prerequisites at Ready. As of §0 there is no overlapping branch or PR.
Other flags:
Chad sign-off: not required. legal_entities is not donor spine (no FK to or from donors) and not release tooling, so the "Merge needs a recorded sign-off from Chad for Blake." pin does not apply.
No labels or Ready state were changed by this spec.
Activity
II-ricky-bobby-II commentedon Oct 26, 2026
Spec r1 (Scope, Oct 26, 2026, 8:19 AM PT), pending Samson approval
#2403 = #1947a, split from #1947 per the Ready-for-spec-and-split decision (#1947 (comment)). Status: spec only. Not Ready. Building starts only after Blake marks it Ready, and the OPEN gates in §14 stay open until their owners close them.
§0 What I checked
88eefbd9(Oct 26, 2026, 12:27 AM PT). Newest migration:20261025120000_donor_feed_preferences_own_rows.sql. Nolegal_entitiesobject exists on develop, on any remote branch, or in the one open PR (Modernize Sentry, content, email and browser payment SDKs #2214, which touches no migration).legal_entitiesroot and seeds exactly one default for the ordinary tenant... Single-entity users see no selector"); and CONTEXT.md "Legal Entity" (line 6780) and "Legal Entity scope" (line 2898).supabase/migrations/20261024150000_tenant_guard.sql,docs/security/tenant-guard.md("Adding a guarded function" steps 1–6 and "Adding a tenant table"),packages/api/src/identity/tenant-guard.ts(GUARDED_RPCS,withTenant), andpackages/api/src/projection/types.ts:50,119andvalidation.ts:98–134(they already modellegalEntitiesscope and readrow.legal_entity_id; untouched here).legal_entities (tenant_id, id)as its FK target withON DELETE RESTRICT.tests/**,scripts/**,docs/**,.github/**andsupabase/seed.sql, for tenant_guard inventories, the real-stack step string, the [Phase 4] #505 slice 4/4: CI gates: RLS catalog assertion + bare service-role client gate #2253/[Phase 4] #505 slice 2/4: ENABLE + FORCE RLS and at least one policy on every public tenant table #2251 catalog counts, the seed reset order, and migration uniqueness. Findings are in §7.§1 Intended behavior
public.legal_entities, owned by exactly one Tenant, with a stable identity(tenant_id, id). Downstream financial roots (first AL-1998: [Phase 9] Manage one stable Ministry Assignment and its separate participant memberships (P27-S01) #1998) store the exact entity through a composite FK(tenant_id, legal_entity_id) → legal_entities (tenant_id, id) ON DELETE RESTRICT. A reference can't point at another Tenant's entity, and an entity in use can't be deleted.tenant_idandidcan't be updated, by anyone.available, with the exacttenantIdandlegalEntityId;unavailable(no detail) when E doesn't exist or belongs to another Tenant, so the two cases look identical;refusedwith reasonmissing_scopewhen T or E is absent or malformed. In that case the database isn't called.tenant_guardguarded functions called viawithTenant.anon,authenticatedandservice_rolehold no table privilege. User-facing owner writes are held for the Phase 12 gate (§14 G5).§2 In
supabase/migrations/20261025124400_legal_entities.sql(BEGIN … COMMIT,SET LOCAL lock_timeout = '5s', idempotent,-- Down:header). In order:tenant_guardor functiontenant_guard.enter(uuid)is missing (applies only after [Phase 4] #505 slice 1/4: withTenant tenant guard (non-owner, non-BYPASSRLS role, fail-closed) #2250's migration).CREATE TABLE public.legal_entities:tenant_id uuid NOT NULL REFERENCES public.tenants(id) ON DELETE RESTRICTid uuid NOT NULL DEFAULT gen_random_uuid()is_tenant_default boolean NOT NULL DEFAULT falsecreated_at timestamptz NOT NULL DEFAULT now()PRIMARY KEY (tenant_id, id)UNIQUE (id), so an id never repeats across Tenants. The composite PK stays the FK target.legal_entities_one_defaulton(tenant_id) WHERE is_tenant_default.BEFORE UPDATEtrigger (functionpublic.legal_entities_identity_immutable(), SECURITY INVOKER,SET search_path = '',REVOKE ALL … FROM PUBLIC, anon, authenticated, service_role). It raises iftenant_idoridchanges.INSERT … SELECT id, true FROM public.tenants ON CONFLICT DO NOTHING), placed before RLS is forced.ENABLEandFORCE ROW LEVEL SECURITY, then policytenant_guard_isolationFOR ALL TO tenant_guard USING (tenant_id = tenant_guard.current_tenant_id()) WITH CHECK (same).REVOKE ALL ON public.legal_entities FROM PUBLIC, anon, authenticated, service_role, thenGRANT SELECT, INSERT ON public.legal_entities TO tenant_guard. No UPDATE, DELETE or TRUNCATE for tenant_guard.tenant_guard.ensure_default_legal_entity(p_tenant_id uuid) RETURNS jsonb. It is SECURITY DEFINER withSET search_path = ''and starts withPERFORM tenant_guard.enter(p_tenant_id). It runsINSERT … ON CONFLICT (tenant_id) WHERE is_tenant_default DO NOTHING, then selects the default, and returns{tenant_id, legal_entity_id, created}. An unknown Tenant fails on the FK (23503) and writes nothing. It doesn't readpublic.tenants, because tenant_guard has no SELECT there.tenant_guard.check_legal_entity(p_tenant_id uuid, p_legal_entity_id uuid) RETURNS jsonb, with the same DEFINER hygiene. It returns{tenant_id, status: 'available', legal_entity_id}or{tenant_id, status: 'unavailable'}.REVOKE ALL … FROM PUBLIC, anon, authenticated, service_roleandGRANT EXECUTE … TO service_role. Ownership moves to tenant_guard by the [Phase 4] #505 slice 1/4: withTenant tenant guard (non-owner, non-BYPASSRLS role, fail-closed) #2250 sequence (GRANT CREATE/ALTER FUNCTION … OWNER TO tenant_guard/REVOKE CREATE).public.ensure_default_legal_entity(uuid)andpublic.check_legal_entity(uuid, uuid), owned by postgres, withREVOKE ALL … FROM PUBLIC, anon, authenticated, service_roleandGRANT EXECUTE … TO service_role.SELECT pg_notify('pgrst', 'reload schema').supabase/migrations/rollback_20261025124400_legal_entities.sql. Guarded and idempotent. It RAISEs, and changes nothing, if any FK constraint outside this migration referencespublic.legal_entities. Otherwise it drops the wrappers, the guarded functions, the trigger and its function, and the table.packages/api/src/identity/tenant-guard.ts: addensure_default_legal_entityandcheck_legal_entitytoGUARDED_RPCS.packages/api/src/legal-entities/index.ts(new, small):ensureDefaultLegalEntity(tenantId)returns{ legalEntityId, created }.checkLegalEntityScope({ tenantId, legalEntityId })returns the typed union{ status: "available"; tenantId; legalEntityId } | { status: "unavailable" } | { status: "refused"; reason: "missing_scope" }.withTenant. Missing or malformed input returnsrefusedbefore any client call.available.supabase/seed.sql:DELETE FROM public.legal_entities;immediately beforeDELETE FROM public.tenants;;SELECT public.ensure_default_legal_entity('00000000-0000-0000-0000-000000000001');, so the demo tenant has exactly one default..github/workflows/ci-integration.yml, jobmigrate, step "Verify tenant isolation (real stack)": appendtests/unit/acceptance/issue-2403/legal-entities.realstack.acceptance.test.tsto therun:path list. This is a one-line edit with no new step, and env is unchanged.tests/unit/helpers/workflow-yaml.ts:455PINNED_STEP_BODIES(Builder), plus the cross-slice re-pins in §6 (Acceptance).docs/security/legal-entities.md(new, short): the root, the default rule (prefill only, never a fallback), the check contract and its three results, and "how a new financial root references a Legal Entity" (composite FK, RESTRICT,tenant_id NOT NULL).§3 Out
authenticatedread (Phase 12 gate, §14 G5). Any new permission, grant system or "finance admin" override.docs/security/tenant-guard.md(pinned by [Phase 4] #505 slice 1/4: withTenant tenant guard (non-owner, non-BYPASSRLS role, fail-closed) #2250's doc rules),packages/database/types/database.ts, the projection types, and every other workflow step.public.tenantsthat creates defaults automatically (OQ7).§4 Done criteria
[A] = Acceptance test. [R] = real stack, in "Verify tenant isolation (real stack)". [B] = Builder unit test.
(tenant_id, id);UNIQUE (id); FKtenant_id → tenants(id)withON DELETE RESTRICT; the partial unique index on(tenant_id) WHERE is_tenant_default.public.tenantsthat existed at migration time has exactly oneis_tenant_defaultrow. After "Apply demo seed", tenant…0001has exactly one default.ensure_default_legal_entity(T)twice on a fresh Tenant T returns the samelegal_entity_id, withcreatedtrue then false, and leaves exactly one row for T.legal_entity_id, exactly one reportscreated: true, and T has exactly one row.tenants.legal_entities_one_default.UPDATEchangingtenant_idoridraises. Updatingis_tenant_defaultalone is not blocked by the trigger, but no role besides the owner can do it (see 9).relrowsecurityandrelforcerowsecurityare both true. The only policy istenant_guard_isolationFOR ALL TO {tenant_guard}, keyed ontenant_guard.current_tenant_id(). UnderSET ROLE tenant_guardwith Tenant A entered, B's rows are invisible, and an insert for B is rejected by WITH CHECK.anon,authenticatedandservice_rolehave no SELECT, INSERT, UPDATE, DELETE or TRUNCATE on the table. tenant_guard has exactly SELECT and INSERT.(tenant_id, legal_entity_id)→legal_entities (tenant_id, id) ON DELETE RESTRICT:Deleting a Tenant that owns an entity is blocked (23503).
check_legal_entity(A, E)returnsavailable.check_legal_entity(A, E_of_B)andcheck_legal_entity(A, random)return byte-identical{tenant_id: A, status: 'unavailable'}payloads.search_path = '', and grant EXECUTE only to service_role. Both public wrappers are INVOKER, owned by postgres, with EXECUTE for service_role only.anonandauthenticatedcalls fail with 42501.legal_entitiescounted as a tenant table that has a policy and no exception entry.-- Down:header and the pre-assert from §2.1.1. The rollback refuses when an outside FK references the table. The migration version is unique, and it sorts after develop's newest and before AL-1998: [Phase 9] Manage one stable Ministry Assignment and its separate participant memberships (P27-S01) #1998's reserved version (§11).checkLegalEntityScopereturnsrefused/missing_scopewith zero client calls for a missing or malformedtenantIdorlegalEntityId, and mapsavailableandunavailableexactly. Any other RPC error is thrown.ensureDefaultLegalEntitygoes throughwithTenant, and a foreign echoedtenant_idthrows.GUARDED_RPCScontains exactly the four names: the two existing ones and the two new ones.DELETE FROM public.legal_entities;line comes beforeDELETE FROM public.tenants;, and the seed applies twice in a row without error.public.legal_entities INSERTandSELECT; there are 4 tenant_guard DEFINER functions, each withsearch_pathpinned.PINNED_STEP_BODIES,STEP_RUNand fingerprint pins match the appended string.tenants.§5 Mapping
authenticatedpath)§6 Declared edits and owners
supabase/migrations/20261025124400_legal_entities.sqlsupabase/migrations/rollback_20261025124400_legal_entities.sqlpackages/api/src/identity/tenant-guard.tsGUARDED_RPCSpackages/api/src/legal-entities/index.tssupabase/seed.sqldocs/security/legal-entities.md.github/workflows/ci-integration.ymlmigrate, "Verify tenant isolation (real stack)": append the issue-2403 real-stack path torun:(one line)tests/unit/helpers/workflow-yaml.ts:455PINNED_STEP_BODIESrunfor that step matches the appended stringtests/unit/packages/api/legal-entities.test.tsscripts/verify/field-policy-census.mjsand its capturetests/unit/acceptance/issue-2403/legal-entities.realstack.acceptance.test.tstests/unit/acceptance/issue-2403/legal-entities.static.acceptance.test.tstests/unit/acceptance/issue-2250/tenant-guard.realstack.acceptance.test.ts(CATALOG, lines ~43–90)public.legal_entities INSERT, public.legal_entities SELECT; DEFINER list 2 → 4; theFNfunction list adds the two new guarded functions and two new wrapperstests/unit/acceptance/issue-2250/static.acceptance.test.ts:33–39STEP_RUNmatches the appendedrun:tests/unit/acceptance/issue-2155/postgres-ecr-gates.acceptance.test.ts(~302) andtests/unit/acceptance/issue-2201/merge-group.acceptance.test.ts(~193)Shared surfaces:
run:string and inventory lines. The path list stays append-only, ordered by merge order.Nothing else changes.
§7 Pins this change touches
issue-2250/tenant-guard.realstack…:43–45:61–68FNlist:27–28, 81–89public.tenants:70–73run:of "Verify tenant isolation (real stack)"workflow-yaml.ts:455(Builder);issue-2250/static…:33–39STEP_RUN; 2155/2201 fingerprintsdocs/security/tenant-guard.mdregex rulesissue-2250/static…DOC_RULESissue-2253/rls-catalog-gate.realstack…;scripts/verify/tenant-rls-catalog.mjsissue-2251/rls-posture.realstack…tests/unit/supabase/crm-table-preferences-migration.test.ts:20–26supabase/seed.sql:36–40. No test pins the delete list; seed readers (location-schema, missionary-donors-model, issue-2254, issue-2256) don'tauthzDEFINER grant listtests/unit/security/authz-definer-execute-grants.test.tsauthz.*legalEntitiesscope typespackages/api/src/projection/types.ts,validation.ts§8 CI
One path is appended to the existing "Verify tenant isolation (real stack)" step. Env (
TENANT_GUARD_IT_ADMIN_DATABASE_URL) is unchanged, with no new step, job or secret. The migration runs in "Apply migrations" and the seed in "Apply demo seed", both already present.§9 Data, grants, person_id and donor spine
authenticatedoranonpath.legal_entitiesdoesn't referencedonors, and nothing references it from the donor spine.§10 Size
legal-entities/index.ts+GUARDED_RPCSseed.sql, CI yaml,PINNED_STEP_BODIESdocs/security/legal-entities.mdTest budget, sized from Acceptance's real-stack needs:
legal-entities.test.ts~90 (criteria 15 and 16), and thePINNED_STEP_BODIESline ~1.Total ~615 tests.
§11 Dependencies and migration
20261025124400_legal_entities.sql. It is valid (12:40:00) and above develop's newest (20261025120000) and the reserved [Security] Five RLS policies allow cross-tenant reads by anon (USING (true) on missionaries, funds, follows, assets, campaigns) #2312 / [Phase 12] #669 slice (a): Principal union + membership-backed active-assignment resolver (preparation only) #2258 versions (20261025120500,20261025120700). It sorts before AL-1998: [Phase 9] Manage one stable Ministry Assignment and its separate participant memberships (P27-S01) #1998's renumbered20261025124500, which consumeslegal_entities (tenant_id, id). It is unique against develop, all remote branches, the open PR, and every reservation in issue and PR text since Oct 15, including the 20261025 slots 121000–123000, 130000, 131000, 140000, 150000 and later, and all 20261026 reservations.Version is reserved; Samson may renumber at handback to keep merge order.
§12 Open questions (proposed defaults in bold)
is_tenant_defaultplus a partial unique index vs a separate one-row-per-Tenant defaults table. The flag is smaller and keeps AL-1998: [Phase 9] Manage one stable Ministry Assignment and its separate participant memberships (P27-S01) #1998's FK target simple.parties.unauthorizedrefusal now: no. It is added when the Phase 12 PDP lands. Until then, the check is server-only and Tenant-exact.tenantsis rejected because it would make deleting Tenants in existing real-stack suites fail on RESTRICT.STEP_RUNand fingerprint re-pins: the brief for this spec listed the issue-2250STEP_RUNre-pin as Builder. But Donor feed-preferences route fails: user-JWT client on donor_feed_preferences (no policy, no grant) #2260's actual re-pin commit (b44b66f0) was made by Acceptance, and [Phase 7] Party spine: parties supertype + persons anchor + households + household_members + org subtype + org_contacts + donors.party_id #570's OQ17 ruling says edits undertests/unit/acceptance/**are Acceptance's. Default: Acceptance, as in §6. Samson may flip it to Builder.missing_scope. It covers a Tenant without an entity, or an entity without a Tenant. No default is substituted.§13 Deploy
20261024150000_tenant_guardand everything after it. This migration has a pre-assert and fails cleanly, in one transaction, iftenant_guardisn't there. It must apply after the whole develop chain, in version order.public.legal_entities, its index, trigger, policy and grants, plus 2 guarded functions and 2 wrappers;authenticatedpath).§14 Flags and OPEN gates
OPEN gates from #1947. These are not settled here. Each needs its owner before Ready.
Other flags:
legal_entitiesis not donor spine (no FK to or fromdonors) and not release tooling, so the "Merge needs a recorded sign-off from Chad for Blake." pin does not apply.