From 924e1a0e50520ea7535f7a0396d35dbc2e875e73 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 6 Aug 2026 10:56:32 +0000 Subject: [PATCH] =?UTF-8?q?docs(protocol):=20=E9=87=8D=E5=86=99=20schema.m?= =?UTF-8?q?dx=E3=80=8CMulti-Tenant=20Schemas=E3=80=8D=E4=B8=BA=E5=B9=B3?= =?UTF-8?q?=E5=8F=B0=E5=AE=9E=E9=99=85=E7=A7=9F=E6=88=B7=E5=A7=BF=E5=8A=BF?= =?UTF-8?q?=20(#5746)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The section taught three things the platform does not do: `tenantField: tenant_id` with a hand-declared `tenant_id` lookup, a `reference: tenant` target that does not exist (the organization object is `sys_organization`), and `OS_MULTI_ORG_ENABLED` as the live switch plus a default `tenant_isolation` RLS policy as the enforcement point. Rewritten from the code: `organization_id` is registry-injected on every non-opted-out object regardless of posture (only its index is posture-gated), `tenancy.enabled: false` is the opt-out, `tenantField` carries no default (#5315) and exists only for a genuinely non-`organization_id` column, and the wall is Layer 0 (ADR-0095 D1) with the predicate chosen by `OS_TENANCY_POSTURE` (ADR-0105 D1). --- content/docs/protocol/objectql/schema.mdx | 50 ++++++++++++++++++----- 1 file changed, 40 insertions(+), 10 deletions(-) diff --git a/content/docs/protocol/objectql/schema.mdx b/content/docs/protocol/objectql/schema.mdx index d5e0d711c0..6ba6fbe562 100644 --- a/content/docs/protocol/objectql/schema.mdx +++ b/content/docs/protocol/objectql/schema.mdx @@ -708,24 +708,54 @@ full_name: ### Multi-Tenant Schemas -Isolate data by tenant: +Row-level isolation runs on **one platform column**: `organization_id`, a lookup to +`sys_organization`. The registry injects it into every registered object that has not +opted out (`hidden`, `readonly`, `required: false` — it is server-populated on insert +and stays NULL where no organization context exists), so a tenant-scoped object +declares **nothing** about tenancy: ```yaml name: customer +fields: + name: + type: text + required: true +``` + +The column's existence does not depend on the deployment's tenancy posture — only its +*index* does, since nothing filters by organization on a single-organization stack. An +object opts **out** by declaring `tenancy.enabled: false` +(or `systemFields.tenant: false`), which withholds both the column and the tenant +filter — the posture for a platform-global catalog that must stay readable across +organizations: + +```yaml +name: currency_rate tenancy: - enabled: true # Automatically filter by the tenant field (row-level isolation) - tenantField: tenant_id + enabled: false # platform-global: no organization_id, never tenant-scoped fields: - tenant_id: - type: lookup - reference: tenant + code: + type: text required: true ``` -When the kernel runs in multi-tenant mode (`OS_MULTI_ORG_ENABLED=true`), the registry also -auto-injects an `organization_id` lookup on every user object, and the default -`tenant_isolation` RLS policy scopes reads/writes to the caller's tenant — so a user in -Tenant A cannot see Tenant B's records. +`tenantField` is not part of the normal path and carries **no default** — leave it +undeclared and the driver scopes by `organization_id`, the same column the RLS +predicates and `RLS.tenantPolicy()` assume. Declare it only for an object whose tenant +column genuinely is not `organization_id` (`tenantField: workspace_id`), and only when +the object really carries that field: a declared name for a column that does not exist +is ignored and the `organization_id` fallback applies. + +Enforcement is not a per-object RLS policy. The organization wall is **Layer 0** of the +authorization kernel — its own code path, always first, AND-composed ahead of and +independently of business RLS, so no permissive policy, sharing rule or +`viewAllRecords` / `modifyAllRecords` superuser bit can widen it (ADR-0095 D1). What it +filters is decided by the deployment's tenancy posture, selected with +`OS_TENANCY_POSTURE` (ADR-0105 D1): `single` leaves the layer inert, `group` scopes to +`organization_id IN accessible_org_ids`, `isolated` to +`organization_id = ` — so under either walled posture a user in +organization A cannot see organization B's records. See +[Tenancy Postures & Membership](/docs/deployment/tenancy-modes). ## Schema Versioning