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
}
tenancy by user.<claim> of <Registry>— asystemmember (peer ofuser {}/auth {}). Names the user-shape field that carries the tenant id and the one aggregate that is the tenant registry. At most one per system; the claim field must exist onuser {}; the registry must exist.with tenantOwned— a built-in prelude capability (src/macros/prelude.ts, next toauditable/softDeletable). Unfold it and you get exactly:aggregate Invoice { number: string amountDue: decimal // — what `with tenantOwned` expands to — tenantId: string internal dataKey: string? internal stamp onCreate { tenantId := currentUser.tenantId dataKey := currentUser.orgPath } filter this.tenantId == currentUser.tenantId }The
tenantIdcolumn lands in the migrations automatically — with a derived non-unique<table>_tenant_id_idx(every tenant read prefixes on that column; derived in the sharedMigrationsIR, so all DB backends emit it);internalkeeps both columns out of create inputs (the client can never pass them); the filter is AND-ed into every generated read on all five backends; the stamp copies the claim at create.The two
tenantIds are different things. The one on the row is the COLUMN this capability provides — always spelledtenantId, not the author's to rename. The one it is compared against is the declared claim, whatevertenancy by user.<claim>named. They coincide in the examples here only because those declare the claim astenantId. Undertenancy by user.orgId, node emitseq(t.tenantId, requireCurrentUser().orgId)and stampstenantId: currentUser.orgId. The capability itself cannot see the declaration (a prelude definition has no system in scope), so enrichment binds the principal side in phase ⑥ — the same pass that derives the registry self-scope, which is why both halves now agree on one claim.dataKey(multi-tenancy Phase 2, plan P2.3) is the materialized DataKey path, stamped unconditionally fromcurrentUser.orgPath— the same claim-copy stamp mechanism astenantId, riding the identicalcontextStamppipeline (no per-backend code: every backend already renderscurrentUser.orgPathfor the P2.1/P2.2 filter use-site; a stamp assignment is the same expression renderer, a different call site). It is stamped on everytenantOwnedaggregate regardless of whether the registry has opted intoimplements tenantRegistry— under flat tenancyorgPathresolves to the tenancy claim itself (P2.1's fallback, a correct root-only path); once the registry opts into hierarchy,orgPathresolves to the real materialized path with nothing here needing to change. UnliketenantId,dataKeyis not justinternal—authorization.md §2calls it a persistence column only, sowireFieldsForAggregate(src/ir/enrich/enrichments.ts) drops it fromwireShapeentirely: it never appears in.loom/wire-spec.json, a DTO, or any read response — a stricter exclusion thantenantId's (which stays inwireShape, just filtered out of API reads). No read-side filtering ridesdataKeyyet — that is thepolicy { data {} }ladder, P2.4.crossTenant— an aggregate-header modifier, for shared reference data (Plan,Country). A stance marker: attaches nothing, generates nothing — it exists so "no tenant filter" is a declared decision, never an accident. It sits in the header region after the aggregate name, alongside the realization axes (persistedAs:/shape:/inheritanceUsing:) and order-independent with them —aggregate Plan crossTenant { … }. It is deliberately not a leading adjective likeabstract:abstractsays what the aggregate is, whilecrossTenantdeclares how it participates in thetenancy byfacility. (M-T5.17 sort-by-meaning amendment.)The registry gets no marker — it is self-keyed (its "tenant" IS its own id), so neither stance fits it. Since Phase 1b it DOES get generated read behavior: the derived self-scope filter (below). Who may read or edit organizations beyond that is authorization's job.
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:
- It does not consult
auth { enforcement: }. The language default isenforcement: opt, which gates nothing at all; and underdenyByDefault,requires truesatisfies the ungated-read check while leaking exactly as hard. The question this gate asks is "does this read drop the tenant filter", not "does this read have some gate". - It is a warning, never an error. The escape hatch keeps working — the model still compiles and the cross-tenant read is still emitted.
- It fires only on a TENANCY bypass.
ignoring softDeletablewidens a read to rows the caller's own tenant already owns and stays silent, as doesignoring *on an aggregate with no tenancy filter in scope.
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
}
parent: Self id?— an immutable, nullable self-FK (Selfresolves to the registry aggregate).immutablekeeps it settable at create (the signup bootstrap passes it) but frozen after — reparent is out of scope (immutable paths are what makedeepa cheap prefix scan). It lands in migrations as a nullable self-referential FK (parent uuid null references organizations(id)) plus anorganizations_parent_idx.dataKey: string?— the managed materialized path (data_key text null, off create/update inputs, present on reads). Its value is computed server-side in the author-writtensignUpcreate factory via the workflow-tierrepo-leton the parent (dataKey := parent.dataKey + "." + <segment>); a capability is a pure mixin, so it carries the field but cannot inject a repo-reading create body. Root orgs get a root-segment path; children extend the parent's.
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):
- NULL-
dataKeyfallback (OR-fallback, not pure fail-closed LIKE). Rows stamped before P2.3 — or by a principal-less save — carry a NULLdata_key, which a bare prefix LIKE silently hides.deeptherefore ORs indataKey IS NULL AND tenantId == currentUser.tenantId: NULL rows degrade to exactly thelocalfloor, 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. - Delimiter-correct prefix. The descendant match is
pathexactly ORpath || '.'+ more (the.segment separator), soorg_anever prefix-matchesorg_ab. Thetext_pattern_opsopclass ondata_keyships as P2.5 and is what the escaped-LIKEprefilter rides (M-T3.17). global= the caller's root-org subtree (P2.5). authorization.md §2 says "all in my tenant, never the whole table." Under a hierarchyglobalis the full root subtree — every org descending from the caller's root org — emitted as the same descendant-or-self prefix scan asdeepbut anchored atcurrentUser.rootOrg(the firstorgPathsegment) instead oforgPath. The accessor is a pure string derivation off the already-resolvedorgPath(no extra DB read); every backend exposes it besideorgPath. Under flat tenancy every org is its own root, sorootOrg == orgPath == the tenant floorandglobal == deep == localall coincide — andglobal/deepstill require atenantRegistryhierarchy (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:
- The
claim/registrycross-reference upgrade (capstone decision 5) — thetenancy bybindings are now real Langium cross-references (claim=[UserField:UserFieldName] 'of' registry=[Aggregate:ID]insrc/language/ddd.langium), so an unknown claim/registry is a linking error (with navigation/rename) rather than a themed validator code. Byte-identical surface. tenant_idindex — the derived non-unique<table>_tenant_id_idxis emitted for everytenantOwnedtable (viaderive("tenant_id")inwithTenantIndex,src/system/migrations-builder.ts), riding the sharedMigrationsIRdirectly like thedataKeyprefix index — a derived, non-authored index that needed noindex:surface. (Consistent with thetenant_id_idxnote underwith tenantOwnedabove.)