Skip to content

[Phase 7 #1947a] #1947 follow-up: legal_entities root, idempotent one-default setup and exact Tenant+Entity check #2403

Open

Description

@II-ricky-bobby-II

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.
  • Registration (optional): [Phase 3] field_policies table + sensitivity taxonomy + column-census seed (fail-closed) #491 field-policy census rows for the new columns, decided in the spec's Open questions.

Size

~420 code+docs / ~610 tests (sizing in the spec).

Migration

supabase/migrations/20261025124400_legal_entities.sql plus rollback_20261025124400_legal_entities.sql. Version is reserved; Samson may renumber at handback to keep merge order.

Pins

Blocked by

Notes

Spec

A dated spec r1 follows in a comment on this issue.

Deploy: Samson relays to Blake at merge. The spec's deploy section assumes production lags develop.

Activity

  1. II-ricky-bobby-II commented on Oct 26, 2026

    @II-ricky-bobby-II
    ContributorAuthor

    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

    §1 Intended behavior

    1. 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.
    2. Identity never changes. tenant_id and id can't be updated, by anyone.
    3. 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.
    4. 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).
    5. 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.
    6. 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

    1. Migration supabase/migrations/20261025124400_legal_entities.sql (BEGIN … COMMIT, SET LOCAL lock_timeout = '5s', idempotent, -- Down: header). In order:
      1. Pre-assert: RAISE with a clear message if schema tenant_guard or function tenant_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).
      2. CREATE TABLE public.legal_entities:
        • 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.
      3. A partial unique index legal_entities_one_default on (tenant_id) WHERE is_tenant_default.
      4. 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.
      5. Backfill: one default row for every existing Tenant (INSERT … SELECT id, true FROM public.tenants ON CONFLICT DO NOTHING), placed before RLS is forced.
      6. ENABLE and FORCE ROW LEVEL SECURITY, then policy tenant_guard_isolation FOR ALL TO tenant_guard USING (tenant_id = tenant_guard.current_tenant_id()) WITH CHECK (same).
      7. 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.
      8. 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.
      9. 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'}.
      10. Each guarded function gets REVOKE ALL … FROM PUBLIC, anon, authenticated, service_role and GRANT 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).
      11. 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.
      12. SELECT pg_notify('pgrst', 'reload schema').
    2. 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.
    3. packages/api/src/identity/tenant-guard.ts: add ensure_default_legal_entity and check_legal_entity to GUARDED_RPCS.
    4. 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" }.
      • Both run through withTenant. Missing or malformed input returns refused before any client call.
      • Any other error is thrown, never mapped to available.
    5. 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.
    6. .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.
    7. Pins on that string: tests/unit/helpers/workflow-yaml.ts:455 PINNED_STEP_BODIES (Builder), plus the cross-slice re-pins in §6 (Acceptance).
    8. 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).
    9. Optional, per OQ5: [Phase 3] field_policies table + sensitivity taxonomy + column-census seed (fail-closed) #491 field-policy census registration of the four new columns.

    §3 Out

    §4 Done criteria

    [A] = Acceptance test. [R] = real stack, in "Verify tenant isolation (real stack)". [B] = Builder unit test.

    1. [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.
    2. [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.
    3. [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.
    4. [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.
    5. [A][R] Ensure, unknown Tenant: it raises 23503 and inserts no row. No row exists for a Tenant id that isn't in tenants.
    6. [A][R] One default: a direct admin insert of a second default for T fails on legal_entities_one_default.
    7. [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).
    8. [A][R] RLS posture: relrowsecurity and relforcerowsecurity are both true. The only policy is tenant_guard_isolation FOR 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.
    9. [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.
    10. [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).
    11. [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.
    12. [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.
    13. [A][R] The [Phase 4] #505 slice 4/4: CI gates: RLS catalog assertion + bare service-role client gate #2253 RLS catalog gate and the [Phase 4] #505 slice 2/4: ENABLE + FORCE RLS and at least one policy on every public tenant table #2251 posture suite pass, with legal_entities counted as a tenant table that has a policy and no exception entry.
    14. [A] Static: the forward migration is a single transaction with a -- 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).
    15. [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.
    16. [B] GUARDED_RPCS contains exactly the four names: the two existing ones and the two new ones.
    17. [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.
    18. [A][R] [Phase 4] #505 slice 1/4: withTenant tenant guard (non-owner, non-BYPASSRLS role, fail-closed) #2250's catalog inventory, as updated in §6, passes: tenant_guard owns 4 functions and no relation; the exact ACL list includes public.legal_entities INSERT and SELECT; there are 4 tenant_guard DEFINER functions, each with search_path pinned.
    19. 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.
    20. Existing suites stay green, including every real-stack test that creates and deletes its own Tenants ([Phase 4] #505 slice 1/4: withTenant tenant guard (non-owner, non-BYPASSRLS role, fail-closed) #2250, [Phase 4] #506 slice a/3: email normalizer + donors identity columns and keys #2254, [Bug] donations reads embed a nonexistent donations→profiles relationship (donor:profiles!donor_id) #2269), since no trigger is added to tenants.

    §5 Mapping

    #1947 item / In Criteria
    Item 1, root, composite keys, RESTRICT, forced RLS, guarded writes (§2.1.2–7) 1, 6–10, 13
    Item 1, owner writes held for Phase 12 9, 12 (no authenticated path)
    Item 3, idempotent one-default setup (§2.1.8, §2.5) 2–5, 17
    Item 4, entity half, typed refusals (§2.1.9, §2.4) 11, 15
    Guarded wiring (§2.1.10–11, §2.3) 12, 16, 18
    Migration, rollback, version (§2.1, §2.2) 14
    CI append (§2.6–7) 19, 20
    Item 5 registration (optional) OQ5

    §6 Declared edits and owners

    Path Change Owner
    supabase/migrations/20261025124400_legal_entities.sql new (§2.1) Builder
    supabase/migrations/rollback_20261025124400_legal_entities.sql new (§2.2) Builder
    packages/api/src/identity/tenant-guard.ts 2 names added to GUARDED_RPCS Builder
    packages/api/src/legal-entities/index.ts new (§2.4) Builder
    supabase/seed.sql 1 delete line, 1 ensure call (§2.5) Builder
    docs/security/legal-entities.md new (§2.8) Builder
    .github/workflows/ci-integration.yml job migrate, "Verify tenant isolation (real stack)": append the issue-2403 real-stack path to run: (one line) Builder
    tests/unit/helpers/workflow-yaml.ts:455 PINNED_STEP_BODIES run for that step matches the appended string Builder
    tests/unit/packages/api/legal-entities.test.ts new (criteria 15, 16) Builder
    scripts/verify/field-policy-census.mjs and its capture only if OQ5 = include Builder
    tests/unit/acceptance/issue-2403/legal-entities.realstack.acceptance.test.ts new (criteria 1–13, 18) Acceptance
    tests/unit/acceptance/issue-2403/legal-entities.static.acceptance.test.ts new (criteria 14, 17) Acceptance
    tests/unit/acceptance/issue-2250/tenant-guard.realstack.acceptance.test.ts (CATALOG, lines ~43–90) 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 Acceptance (another slice's acceptance file)
    tests/unit/acceptance/issue-2250/static.acceptance.test.ts:33–39 STEP_RUN matches the appended run: Acceptance (OQ8)
    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 Acceptance (OQ8)

    Shared surfaces:

    Nothing else changes.

    §7 Pins this change touches

    Pin Where Scope Criteria
    tenant_guard owns exactly 2 functions, no relation issue-2250/tenant-guard.realstack…:43–45 In (Acceptance) 18
    Exact tenant_guard ACL inventory same file :61–68 In (Acceptance) 18
    Exactly 2 tenant_guard DEFINER functions; FN list same file :27–28, 81–89 In (Acceptance) 18
    No tenant_guard SELECT on public.tenants same file :70–73 Out, must stay green (ensure doesn't read tenants) 5, 18
    run: of "Verify tenant isolation (real stack)" workflow-yaml.ts:455 (Builder); issue-2250/static…:33–39 STEP_RUN; 2155/2201 fingerprints In 19
    docs/security/tenant-guard.md regex rules issue-2250/static… DOC_RULES Out, not edited —
    #2253 catalog gate counts (computed from policies plus listed exceptions) issue-2253/rls-catalog-gate.realstack…; scripts/verify/tenant-rls-catalog.mjs Out, stays green (a new table with a policy) 13
    #2251 posture (every tenant table ENABLE+FORCE with a policy or entry; §3 list is a subset check) issue-2251/rls-posture.realstack… Out, stays green 13
    Unique migration versions tests/unit/supabase/crm-table-preferences-migration.test.ts:20–26 In, stays green 14
    Seed reset order supabase/seed.sql:36–40. No test pins the delete list; seed readers (location-schema, missionary-donors-model, issue-2254, issue-2256) don't In 17
    authz DEFINER grant list tests/unit/security/authz-definer-execute-grants.test.ts Out. Only covers authz.* —
    Projection legalEntities scope types packages/api/src/projection/types.ts, validation.ts Out, untouched —

    §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

    §10 Size

    Part Lines
    Migration (table, index, trigger, backfill, RLS, 2 guarded functions, 2 wrappers, grants) ~230
    Rollback ~45
    legal-entities/index.ts + GUARDED_RPCS ~75
    seed.sql, CI yaml, PINNED_STEP_BODIES ~4
    docs/security/legal-entities.md ~70
    Code + docs ~425 (cap ~1k)

    Test budget, sized from Acceptance's real-stack needs:

    Total ~615 tests.

    §11 Dependencies and migration

    Version is reserved; Samson may renumber at handback to keep merge order.

    §12 Open questions (proposed defaults in bold)

    1. OQ1, default representation: a boolean is_tenant_default plus 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.
    2. 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.
    3. OQ3, name or label column: none in #1947a. [Phase 7] Canonical Legal Entity and immutable issuer-profile runtime foundation (Backlog draft) #1947 says not to guess organization facts. Issuer facts belong to [Phase 7 #1947b] #1947 follow-up: immutable Legal Issuer Profile Versions, version check, #493 parity and #1510 consumer fixture #2404's versions.
    4. OQ4, optional organization Party link (CONTEXT.md line 6780): Out. It becomes a later follow-up after [Phase 7] #570 slice a/2: party spine tables, party fill for existing donors, donors.party_id, census v4 and docs #2271 merges, with a same-Tenant composite FK to parties.
    5. OQ5, [Phase 3] field_policies table + sensitivity taxonomy + column-census seed (fail-closed) #491 census registration ([Phase 7] Canonical Legal Entity and immutable issuer-profile runtime foundation (Backlog draft) #1947 item 5, optional here): not in #1947a. This slice exposes no user read projection, so [Phase 7 #1947b] #1947 follow-up: immutable Legal Issuer Profile Versions, version check, #493 parity and #1510 consumer fixture #2404 registers the root and version fields together with [Phase 3] Projection resolver (fail-closed, subtract-only) + promote portal SELECTs + parity guard #493. If Samson says include, the Builder adds the four rows and re-captures the census, and Acceptance updates any count pin.
    6. OQ6, reserve an unauthorized refusal now: no. It is added when the Phase 12 PDP lands. Until then, the check is server-only and Tenant-exact.
    7. OQ7, who calls ensure: no new caller. No app path creates Tenants on develop. The seed uses it, and AL-1998: [Phase 9] Manage one stable Ministry Assignment and its separate participant memberships (P27-S01) #1998's setup calls it. A trigger on tenants is rejected because it would make deleting Tenants in existing real-stack suites fail on RESTRICT.
    8. OQ8, ownership of the STEP_RUN and fingerprint re-pins: the brief for this spec listed the issue-2250 STEP_RUN re-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 under tests/unit/acceptance/** are Acceptance's. Default: Acceptance, as in §6. Samson may flip it to Builder.
    9. 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

    §14 Flags and OPEN gates

    OPEN gates from #1947. These are not settled here. Each needs its owner before Ready.

    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.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions