← Back to home

Multi-tenancy — tenancy by, tenantOwned, crossTenant

First-class B2B tenant isolation. One system-level declaration names the tenant claim and the tenant registry; every aggregate then declares its stance explicitly, and the toolchain applies the read scope to every generated query — the "forgot the filter on one query" cross-tenant leak becomes a compile error instead of an incident.

One clause opts a read out of it, and it is the only one: ignoring tenantOwned / ignoring * (the deliberate platform-admin cross-tenant report — see ignoring and the tenant filter below). It is not silent: every read that drops a tenancy filter raises loom.tenancy-filter-bypass, in every auth { enforcement: } mode.

Design record: proposals/multi-tenancy-design-note.md (R1–R5); implementation plan: plans/multi-tenancy-implementation.md.

Surface

system Billder {
  user { id: guid  email: string  tenantId: string }

  tenancy by user.tenantId of Organization      // claim + registry, one line

  subdomain Billing {
    context Catalog {
      aggregate Plan crossTenant { code: string  monthlyPrice: decimal }
    }
    context Invoicing {
      aggregate Invoice with tenantOwned { number: string  amountDue: decimal }
    }
    context Accounts {
      // The registry — named in `of`, no tenancy marker.  `with crudish` is not
      // decoration: the signup bootstrap two sections down turns on `POST
      // /organizations` existing, and an aggregate with no `create` emits a
      // read-only API.  `tenancy-owned.ddd` — the fixture that pins the
      // bootstrap end-to-end — declares it the same way.
      aggregate Organization with crudish { name: string }
    }
  }
  // deployables need `auth: required` — the filter/stamp read the principal
}

The registry: derived self-scope + claim-less bootstrap (Phase 1b)

tenantId ≡ <Registry>.id is a derived type link (capstone decision 4): from the tenancy by declaration alone, every read of the registry is scoped to the caller's own org. You never write this filter — enrichment appends it to the registry's capability filters, so it rides the exact pipeline tenantOwned's predicate uses:

aggregate Organization { name: string }   // you write only this
// derived by the toolchain (never in source):
//   filter this.id == currentUser.tenantId
// node — db/repositories/organization-repository.ts (generated)
async all(): Promise<Organization[]> {
  const rootRows = await this.db.select().from(schema.organizations)
    .where(eq(schema.organizations.id, requireCurrentUser().tenantId));
  ...
}

So GET /organizations/<own id> → 200, GET /organizations/<other id> → 404 (existence hidden), and the list contains exactly your own org.

Claim-less signup bootstrap. Filters never gate creates and the registry carries no stamp, so POST /organizations works for any authenticated principal — including one whose token has no (or a foreign) tenant claim. The created org's id is then a valid tenantId claim value (that's the identity), closing the signup loop: create org → issue token with tenantId = <org id> → read it back. Pinned end-to-end by test/e2e/tenancy-isolation.test.ts (LOOM_TENANCY_E2E=1).

The id-vs-claim type rule. The registry id is usually guid; JWT claims are usually strings. The comparison binds the claim as the id's value type at each backend's accessor site:

Claim type vs ids Result
same-typed (guid/guid, string/string, …) compared directly on every backend
string claim, guid aggregate id parsed to the id's value type at each backend's accessor site, yielding null when it doesn't parse (see below)
anything else loom.tenancy-claim-type-mismatch (error, with the fix spelled out)

A malformed tenant claim (an authenticated token carrying tenantId: "not-a-guid") is an ordinary empty read on every backend — the same 200-with-an-empty-list / 404-by-id a foreign-but-well-formed claim gives, never a 500. That is what the parse-to-null accessor buys: the string claim is coerced in the host language before it reaches the database, so a value the guid id cannot represent binds NULL and matches no row. A missing principal claim takes the same path, for the same reason.

.NET has always done this (Guid.TryParse); the other four parsed unconditionally and answered a bad token with a stack trace until M-T3.7(c). Pinned on every behavioural leg by corpus/tenancy-owned (the harness principal's claim is not a uuid, so the registry reads 404 / empty, never 500), by the remaining .NET and Java tenancy-registry-self-scope generator suites for the guid-claim and Criteria arms, and end-to-end by assertCrossTenantIsolation (the tenancy-e2e matrix, all five backends × {flat, hierarchy}).

Per-backend registry read scope (all five compile-gated, and all five runtime-gated by the isolation e2e):

Backend Registry read scope Malformed claim
node (Hono/Drizzle) eq(schema.organizations.id, requireCurrentUser().tenantId) AND-ed into every root read, behind a UUID-shape ternary falls to the always-false term (and(isNull(id), isNotNull(id)))
.NET (EF Core) HasQueryFilter("IdFilter", x => x.Id == __SelfScopeId_Organization_0), a hoisted OrganizationId? member Guid.TryParse(…) ? new OrganizationId(g) : null → = NULL
elixir (Ecto) fragment("? = ?", record.id, ^…) — cast in Elixir, compared on the uuid column so the PK index still serves it Ecto.UUID.cast/1 → nil → = NULL
python (SQLAlchemy) OrganizationRow.id == user.guid_claim("tenant_id"), a with_loader_criteria the read-scope hook (app/db/read_scope.py) builds from the request principal on every ORM read (false() with no principal) guid_claim → None → IS NULL
java (Spring/JPA) an auto-enabled, by-key @Filter(name = "Organization_tenancy", condition = "id = :__cuTenantIdAsUuid") on the entity, the claim a @ParamDef whose resolver reads the request principal per query — so every query of the entity carries it, @Query finds, Criteria and Spring Data's own findAll/existsById alike <claim>AsUuid() → null → = NULL

The derived filter is provenance-tagged tenancy in contextFilterOrigins. tenancy is not a capability, so a named ignoring tenancy is rejected (loom.filter-bypass-unknown-capability); only an explicit ignoring * read — the same authored escape hatch that drops tenantOwned's filter — bypasses it.

The bundled dev Keycloak's demo tenant

Because the self-scope matches only a uuid claim, the demo user of the bundled dev Keycloak (bundled for any auth { oidc … } block, whatever the provider) is seeded with a uuid tenancy claim, 00000000-0000-4000-8000-00000000de40 (DEMO_TENANT_ID, src/ir/util/demo-tenant.ts) — not demo-<claim>, which matched no registry row ever (eval-closure #26). A matching first-boot registry row ships through the ordinary seed path: enrichment appends a raw seed row (id = that uuid, dataKey = the same uuid when the registry is a tenantRegistry root, a synthetic value for every other required scalar column) in its own dataset, loomDemoTenant, and the generated dev compose sets LOOM_SEED: "loomDemoTenant" on the backend hosting the registry. Like every non-default dataset it runs only when LOOM_SEED names it, so a production boot never inserts a demo tenant. A registry whose required columns have no obvious synthetic value (a value object, an X id reference, a list), or that is inherited, event-sourced, non-relational or not on Postgres, gets no row — the realm claim is still the uuid. The demo user's permissions are deliberately not seeded, so denial stays demonstrable. The allow path is a second seeded user, demo-admin / demo-admin, emitted whenever the system declares a permissions { } block: it carries every declared permission (the <subdomain>.<name> runtime strings) through whichever claim permissions reads — the flat user attribute, or realm roles when it is mapped onto realm_access.roles. Every other id-typed claim (agentId: Agent id?) is seeded with the all-zero uuid the dev stub uses, and the realm's user profile sets unmanagedAttributePolicy: ENABLED, so a claim attribute granted later through the admin console survives into the token.

ignoring and the tenant filter

ignoring tenantOwned (and ignoring * on a tenant-scoped aggregate, which subsumes it and the registry self-scope above) is the ONE way to read across tenants, and it is a real, supported shape: the platform-admin report that has to total every tenant's rows. What it is not is a quiet one — it removes the boundary the whole feature exists to provide, and the emitted query then carries no tenant predicate at all:

projection PlatformRevenue {          // every tenant's revenue, to any caller
  revenue: money
  from WorkOrder as w ignoring tenantOwned
  select revenue = sum(w.amount)
}
// the emitted read — no `where`, where the un-bypassed sibling carries
// `.where(eq(schema.workOrders.tenantId, requireCurrentUser().orgId))`
const [row] = await db.select({ revenue: sum(schema.workOrders.amount) }).from(schema.workOrders);

So every such read raises loom.tenancy-filter-bypass (a warning, naming the read, the aggregate and the claim that no longer applies). Three properties of that gate are deliberate:

If the cross-tenant read is deliberate, gate it behind an explicit platform-admin requires and keep it off tenant-facing APIs; the warning stays, as the standing record that this read crosses the boundary.

The explicit-stance rule (the safety story)

Under a system with tenancy by, every persisted aggregate must declare a stance — with tenantOwned or crossTenant (the registry and abstract bases are exempt; a subtype inherits its base's stance). An unmarked aggregate is a hard error:

Code Fires when Severity
loom.tenancy-stance-unmarked unmarked aggregate under a tenancy by system error
linking error (Could not resolve reference …) user.<claim> not on the user {} shape, or of names a non-existent aggregate — both bindings are real cross-references since 1b.1 (navigation / rename / find-refs work) error
loom.tenancy-duplicate more than one tenancy by error
loom.tenant-owned-without-tenancy with tenantOwned but no tenancy by error
loom.cross-tenant-without-tenancy crossTenant but no tenancy by warning
loom.tenancy-conflicting-stance both markers on one aggregate (or a marker on the registry) error
loom.tenancy-inherited-stance-conflict a subtype declares the OPPOSITE stance from the abstract base it extends error
loom.tenancy-claim-type-mismatch the claim's type can't bind against the registry's ids type (see the id-vs-claim rule above) error
loom.tenant-owned-claim-type a tenantOwned aggregate exists but the claim isn't string (the capability's field is tenantId: string; a guid claim mis-compiles typed backends — declare the claim string, guid values round-trip as text) error
loom.tenancy-filter-bypass a read (find / query-time projection / inline Repo.run) whose ignoring clause drops the tenant floor or the registry self-scope — see ignoring and the tenant filter. Independent of auth { enforcement: } warning

Inheritance: a subtype inherits its base's stance

An abstract base is exempt from the rule because it persists no rows; its stance reaches every subtype (D-INHERITED-CAPABILITIES). abstract aggregate Vehicle with tenantOwned makes each … extends Vehicle tenant-owned — the column, the stamp that fills it and the filter that scopes reads by it — with no marker of its own. Writing crossTenant on such a subtype is loom.tenancy-inherited-stance-conflict: the subtype would be both tenant data and shared data. A crossTenant base makes its subtypes crossTenant the same way.

There is deliberately no severity knob: the escape hatch is writing crossTenant — one keyword, intent declared. This is fail-closed without magic: nothing is ever injected by the distant tenancy by line; the capability the aggregate wrote carries the fields (unfoldable, reviewable).

What each backend emits

The filter and stamp ride the standard capability-filter/stamp pipeline — tenancy adds no bespoke backend code:

The principal-side spellings below assume the common tenancy by user.tenantId declaration; under any other claim name the currentUser.… half follows the declaration while the row column stays tenantId.

Backend Read scope Create stamp (tenantId + dataKey)
node (Hono/Drizzle) .where(and(…, eq(t.tenantId, requireCurrentUser().tenantId))) on every repository read stampInsert copies both claims off the ambient principal — tenantId: currentUser.tenantId, dataKey: currentUser.orgPath (no-op for system/seed saves)
.NET (EF Core) HasQueryFilter(x => x.TenantId == RequestContext.Current!.CurrentUser!.TenantId) SaveChangesInterceptor sets both on Added — x.TenantId off …CurrentUser!.TenantId, x.DataKey off …CurrentUser!.OrgPath
elixir (Ecto) fail-closed ^(current_user && current_user.tenant_id) match in every read put_change(:tenant_id, current_user && current_user.tenant_id) |> put_change(:data_key, current_user && current_user.org_path)
python (FastAPI/SQLAlchemy) one do_orm_execute hook (app/db/read_scope.py) adds with_loader_criteria(<Row>, <Row>.tenant_id == user.tenant_id) to every ORM read; false() with no principal self._tenant_id = current_user.tenant_id + self._data_key = current_user.org_path in the create factory
java (Spring/JPA) an auto-enabled @Filter(condition = "tenant_id = :__cuTenantId") on the entity (a TPH hierarchy's on its root), the claim a per-query @ParamDef resolver; ignoring tenantOwned disables it for that read @PrePersist hook reading CurrentUserAccessor.currentOrNull(), setting this.tenantId = currentUser.tenantId() and this.dataKey = currentUser.orgPath()

Frontends need nothing: enforcement is server-side, and neither tenantId nor dataKey ever appears in create forms. tenantId (like softDeletable's isDeleted) is wire-visible on reads today — internals aren't hidden from read DTOs by default. dataKey is the exception: it is dropped from wireShape entirely (see above), so it never reaches any read DTO either.

Cross-tenant reads of another tenant's row return 404 (existence hidden), falling out of the filter semantics: the row simply isn't found.

Reactors and timers — the system principal's tenant

An event reactor runs as the system principal (D-REACTOR-SYSTEM-PRINCIPAL), and filters and stamps inside it use the triggering event's tenant: the system principal copies the tenancy claim and orgPath from the principal of the request that raised the event, so a reactor reads and writes in exactly the tenant its event came from. A timerSource tick has no originating request and therefore no tenant — a tenant-owned read in a timer-driven reactor would match nothing — so it must be explicitly cross-tenant: declare it as a find (or an inline Repo.run) with ignoring tenantOwned, or loom.timer-tenant-read (error) refuses it.

repository Jobs for Job {
  find anyTenant(c: string): Job where this.code == c ignoring tenantOwned
}
workflow sweepRun {
  sweep: Sweep id
  create(t: SweepTick) by t.sweep {
    let j = Jobs.anyTenant("x")      // `Jobs.byCode("x")` here: loom.timer-tenant-read
    j.close()
  }
}

An event that crosses the transactional outbox or a broker carries its origin — tenant, orgPath, causedBy — on the outbox row and as the envelope's tenantid / loomorgpath / loomcausedby attributes, and the relay / consumer delivers it as the system principal of that tenant, so a relay- or broker-fed reactor reads and writes in the event's tenant too (channels.md § Event origin).

Hierarchy — the registry tree (implements tenantRegistry, Phase 2)

The tenant registry opts into a hierarchy by carrying implements tenantRegistry — a built-in prelude capability (src/macros/prelude.ts, next to tenantOwned). It provides two managed tree fields; the author writes neither (unfold the capability to see them):

tenancy by user.tenantId of Organization

aggregate Organization {
  name: string
  implements tenantRegistry
  // — what `implements tenantRegistry` provides —
  //   parent: Organization id?   immutable   // self-FK; null = root org
  //   dataKey: string?           managed     // the materialized path
}

Structural verification (src/ir/validate/checks/tenancy-checks.ts): only under a tenancy by system (loom.tenant-registry-without-tenancy), exactly one aggregate (loom.tenancy-registry-duplicate), and it must be the of <Registry> target (loom.tenancy-registry-not-target). parent's immutability and self-reference are structural (the access modifier + Self), so neither needs a check.

currentUser.orgPath under hierarchy

orgPath (the P2.1 principal member) is the caller's materialized path. With no tenantRegistry (flat tenancy) it is the tenancy claim itself — the root-segment path, since there is no dataKey column to read. Once the registry implements tenantRegistry, the principal resolves orgPath from the registry's data_key column, memoized per request, keyed by the tenancy claim:

select data_key from organizations where id = <currentUser.tenantId>

Fail-safe: a missing row, a null data_key (pre-tree data), or any lookup error falls back to the claim value — never null, never a crash. Wired on all five backends, each through its own request-scoped principal seam:

Backend Registry read Memoization
node (Hono/Drizzle) boot registers registerOrgPathResolver (a db-backed closure — the auth layer can't reach the injected db); middleware awaits it once in middleware, on the request principal
.NET (EF Core) IOrgPathResolver/EfOrgPathResolver (IgnoreQueryFilters().Where(id).Select(DataKey)), method-injected into UserMiddleware scoped resolver; user.OrgPath slot set once per request
Java (Spring/JPA) OrgPathResolver holder + a boot @Component registering a JdbcTemplate SELECT data_key … WHERE id = ? closure per-request ThreadLocal memo, cleared in UserFilter
Python (FastAPI/SQLAlchemy) Starlette middleware queries text("SELECT data_key … WHERE id = :claim") via the module-level session_factory written once onto the frozen principal (off asdict()/wire)
Elixir (Ecto) put_org_path/1 reads via Repo.one(from o in <Registry>, where: o.id == ^claim, select: o.data_key) (schema-prefix + binary_id cast) the plug runs once per request → memoized on the principal map

The policy {} read ladder (local / deep / global, Phase 2 P2.4)

A policy {} context member selects a per-aggregate read reachability level for tenant-owned aggregates — the directional ladder from proposals/authorization.md §3 (Self / Descendants / All ≈ local / deep / global). It is the read side of the hierarchy: P2.1–P2.3 wrote dataKey; P2.4 reads by it.

context Ledger {
  aggregate Invoice with tenantOwned { amount: Money }
  aggregate Org { name: string  implements tenantRegistry }
  // …
  policy {
    allow deep on Invoice        // caller's org + all descendant orgs
  }
}

Each rule rewrites the aggregate's tenantOwned capability filter (its contextFilters entry), riding the exact per-backend query seams the flat tenant floor already uses (Drizzle .where, EF HasQueryFilter, SQLAlchemy with_loader_criteria hook, JPA @Query/Specification, pinned Ecto where:) — no new backend plumbing. The levels:

Level Emitted scope
local tenantId == currentUser.tenantId — the caller's own org node. The default (an omitted / local aggregate keeps today's flat floor).
deep descendant-or-self on the materialized path: dataKey = orgPath OR dataKey is under `orgPath
global the caller's root-org subtree (P2.5): the same descendant-or-self prefix scan as deep but anchored at currentUser.rootOrg (the first orgPath segment). Under flat tenancy rootOrg == orgPath == the tenant floor, so all three levels coincide (see the decision below).

The descendant test is two terms — an escaped LIKE prefilter and an anchored recheck, in that order (M-T3.17):

data_key LIKE <escaped-anchor> || '.%' ESCAPE '!'   -- sargable: rides <table>_data_key_idx
  AND strpos(data_key, <anchor> || '.') = 1         -- decides the row; no pattern language

The row is admitted by the anchored test, which has no pattern language at all — that is what keeps an org legitimately named acme_corp from reading acmeXcorp.… (#2562's orgXa.leak trap). The LIKE in front exists only so the planner can turn the pattern's fixed prefix into an index range over the data_key text_pattern_ops index instead of sequentially scanning the tenant's whole table; !, % and _ in the anchor are escaped with ESCAPE '!'. The two-term split is deliberate: a mistake in the escaping can only make the prefilter wider, and the recheck still throws the extra rows away, so sargability can never cost isolation. EXPLAIN proof: test/e2e/tenancy-subtree-explain.test.ts.

Generated deep filter, one line per backend (for allow deep on Account):

Backend Emitted predicate
node (Hono/Drizzle) or(and(isNotNull(t.dataKey), or(eq(t.dataKey, p.orgPath), and(sql`${t.dataKey} like ${esc(p.orgPath)} escape '!'`, sql`strpos(${t.dataKey}, ${p.orgPath + "."}) = 1`))), and(isNull(t.dataKey), eq(t.tenantId, p.tenantId)))
node (Hono/MikroORM) (in the entity's default tenantOwned schema filter) raw("((data_key is not null and (data_key = ? or (data_key like ? escape '!' and strpos(data_key, ?) = 1))) or (data_key is null and tenant_id = ?))", […])
.NET (EF Core) (x.DataKey != null && (x.DataKey == u.OrgPath || (EF.Functions.Like(x.DataKey, esc(u.OrgPath), "!") && x.DataKey.StartsWith(u.OrgPath + ".")))) || (x.DataKey == null && x.TenantId == u.TenantId)
Python (SQLAlchemy) or_(and_(R.data_key.isnot(None), or_(R.data_key == u.org_path, and_(R.data_key.like(esc(u.org_path), escape="!"), func.strpos(R.data_key, u.org_path + ".") == 1))), and_(R.data_key.is_(None), R.tenant_id == u.tenant_id))
Java (Hibernate @Filter) ((data_key is not null and (data_key = :__cuOrgPath or (data_key like :__cuSubtreeOrgPath escape '!' and position(concat(:__cuOrgPath, '.') in data_key) = 1))) or (data_key is null and tenant_id = :__cuTenantId)), each :__cu… a @ParamDef resolved from the request principal per query
Elixir (Ecto) fragment("(? IS NOT NULL AND (? = ? OR (? LIKE ? ESCAPE '!' AND strpos(?, ? || '.') = 1))) OR (? IS NULL AND ? = ?)", …) with ^-pinned fail-closed principal

(esc(…) above is each language's inline escape chain — !→!!, %→!%, _→!_, then + ".%". The five spellings live together in src/generator/_expr/subtree-like.ts.)

Three settled semantics decisions (authorization.md §9 leaves them to P2.4):

  1. NULL-dataKey fallback (OR-fallback, not pure fail-closed LIKE). Rows stamped before P2.3 — or by a principal-less save — carry a NULL data_key, which a bare prefix LIKE silently hides. deep therefore ORs in dataKey IS NULL AND tenantId == currentUser.tenantId: NULL rows degrade to exactly the local floor, staying visible to their own tenant and never widening past it (no cross-tenant leak). This preserves flat-tenancy correctness for legacy rows while the tree fills in.
  2. Delimiter-correct prefix. The descendant match is path exactly OR path || '.' + more (the . segment separator), so org_a never prefix-matches org_ab. The text_pattern_ops opclass on data_key ships as P2.5 and is what the escaped-LIKE prefilter rides (M-T3.17).
  3. global = the caller's root-org subtree (P2.5). authorization.md §2 says "all in my tenant, never the whole table." Under a hierarchy global is the full root subtree — every org descending from the caller's root org — emitted as the same descendant-or-self prefix scan as deep but anchored at currentUser.rootOrg (the first orgPath segment) instead of orgPath. The accessor is a pure string derivation off the already-resolved orgPath (no extra DB read); every backend exposes it beside orgPath. Under flat tenancy every org is its own root, so rootOrg == orgPath == the tenant floor and global == deep == local all coincide — and global/deep still require a tenantRegistry hierarchy (loom.policy-level-requires-hierarchy), so a flat model keeps the fail-closed floor. It never leaks past the tenant root.

The ladder is still a compiler-owned sentinel, not startsWith (yet). Both levels lower to an authz-filter { kind: "scope" } node that each backend renders natively — see buildDeepScopeFilter / buildGlobalScopeFilter in src/ir/util/tenant-stance.ts. The reconciled surface (tenancy-authorization-final-surface decision 2) retires that sentinel in favour of an ordinary filter written in the language, and its prefix operator now ships: startsWith is queryable on every backend (stdlib.md → string), lowering to an anchored strpos(col, $p) = 1 rather than a LIKE so a %/_ in the prefix VALUE matches literally. What the swap still needs is two more queryable shapes the sublanguage lacks — string concatenation (currentUser.orgPath + ".") and != null on the internal dataKey column — because the sentinel's NULL-dataKey OR-fallback (decision 1 below) is load-bearing and must survive it. Until then the operator is available to authors and the ladder keeps its sentinel.

Validation (fail closed). deep/global need a tenantRegistry hierarchy (loom.policy-level-requires-hierarchy); the target must be a tenant-owned aggregate in the same context (loom.policy-unknown-aggregate, loom.policy-target-not-tenant-owned); one level per aggregate (loom.policy-duplicate-target). The minimal surface is the read ladder only — operation/view/field gates, deny, and policy function/let helpers stay later proposal work.

organizationContext — the operating scope and its switch gate

currentUser is the principal: who is calling, their permissions, and — through currentUser.orgPath — their own org. organizationContext is the operating scope: the org this request acts in. The two coincide unless the request asks to operate in a descendant org, by sending an x-org-context header carrying that org's materialized path. Design record: organization-context; reconciled surface decisions 3–5 of tenancy-authorization-final-surface.

It has exactly one member, organizationContext.orgPath, and its first use is the tenant write stamp — an admin at org_a creating a row that belongs to org_a.b without a per-write registry read:

system OrgContext {
  user { id: guid  tenantId: string }
  tenancy by user.tenantId of Org

  // The reconciled surface's tenantOwned: the WRITE stamp follows the operating
  // scope, the read filter stays on the principal.  A user-declared capability
  // of the same name replaces the prelude's.
  capability tenantOwned {
    tenantId: string internal
    dataKey: string? internal
    stamp onCreate {
      tenantId := currentUser.tenantId
      dataKey := organizationContext.orgPath
    }
    filter this.tenantId == currentUser.tenantId
  }
  // … Org implements tenantRegistry, Account with tenantOwned,
  //   policy { allow deep on Account }, an `auth: required` deployable
}
// node — auth/middleware.ts (generated): the gate, between resolving the
// principal's orgPath and building the principal
const orgPath = await resolveOrgPath(String(claims.tenantId ?? ""));
const requestedOrgContext = c.req.header("x-org-context");
const orgContextPath = orgContextFor(orgPath, requestedOrgContext);
if (orgContextPath === null) return orgContextForbidden(c, requestedOrgContext ?? "", orgPath);
const user: User = { ...claims, orgPath, rootOrg: rootOrgOf(orgPath), orgContextPath };

// db/audit-stamp.ts (generated): the stamp reads the operating scope
return { ...row, tenantId: currentUser.tenantId, dataKey: currentUser.orgContextPath };

The gate. The accessor re-roots the write stamp from an unforgeable claim onto a value the caller submits, so it exists only together with a fail-closed, per-backend gate. Each backend's auth middleware resolves the operating scope once per request, before any handler runs:

x-org-context Caller's orgPath Result
absent or empty any the principal's own orgPath — never a widening
equal to orgPath, or orgPath + . + more non-empty admitted: organizationContext.orgPath is the header value
anything else (an ancestor, a sibling, the delimiter trap org_ab under org_a, an unrelated root) non-empty 403, org_context_denied logged with reason: "outside_scope"
any value empty (no tenancy claim) 403, reason: "no_principal_scope"

The containment test is the delimiter-correct prefix every deep read uses — equality, or startsWith(orgPath + ".") — so org_a never admits org_ab. A refused request never reaches a handler: nothing is written, and the 403 is an RFC 7807 problem ("title": "Forbidden"). The deny is logged through the neutral catalog (org_context_denied, warn, fields org_context / reason / status) on all five backends.

Backend Where the gate runs Where the operating scope lives
node (Hono) authMiddleware, after resolveOrgPath User.orgContextPath on the request principal
.NET UserMiddleware, after the IOrgPathResolver read User.OrgContextPath (settable, defaults to OrgPath)
Java (Spring) UserFilter, inside the request try (the finally clears it) the OrgContext ThreadLocal; User.orgContextPath() falls back to orgPath()
Python (FastAPI) AuthMiddleware, after _resolve_org_path user.org_context_path (off asdict() / the /auth/me wire)
Elixir (Phoenix) the Auth plug's call/2, via org_context_gate/2 current_user.org_context_path (seeded with org_path on every principal the plug builds)

Reads stay principal-anchored (reconciled decision 4, verified — not rebuilt): every derived tenant filter and the deep/global ladder anchor on currentUser, never on organizationContext. A switched request reads exactly what the caller would read without the header. A read that an author anchors on organizationContext.orgPath can only narrow: the gate guarantees the operating scope lies inside the principal's subtree.

What it does not do (yet). tenantId stays principal-anchored, so a row created under a switch carries the caller's tenantId and the target org's dataKey: it is visible to deep reads from the caller's org and the target's ancestors, and to deep reads from the target org itself, but not to the target org's local floor. There is no organizationContext.orgId (deriving an org id from a submitted path needs a registry read by dataKey). The prelude's own tenantOwned still stamps currentUser.orgPath; re-rooting it (so every tenant-owned write follows the operating scope) is a separate, owner-decided change, because it would put the gate in front of every tenancy system.

Validation (fail closed). loom.org-context-surface — a bare organizationContext, any member but orgPath, or any read inside a ui (a frontend has no switch gate). loom.org-context-gate-unmet — a read with no tenancy by … of <Registry> whose registry implements tenantRegistry (nothing to switch within), or a read in a context hosted by a backend deployable without auth: required. The runtime proof is the org-context leg of tenancy-e2e on all five backends (npm run test:tenancy-org-context*).

Scope and roadmap

Phase 1 is flat tenancy (local reads — tenant-id equality). The registry self-scope + claim-less bootstrap shipped as Phase 1b (above). The registry tree (implements tenantRegistry → parent + managed dataKey) + the orgPath registry read on all five backends shipped as Phase 2 P2.2 (above). Stamping dataKey on every tenantOwned aggregate (P2.3, above) shipped too. The deep/global policy {} read ladder (P2.4, above) shipped. The materialized-path dataKey prefix index (text_pattern_ops) + the currentUser.rootOrg accessor that widens global to the root-org subtree shipped as P2.5 (above) — closing multi-tenancy Phase 2. Also shipped since: