You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
The Layer-0 tenant wall's strict equality annihilates the driver's platform bucket: #2734's fix is defeated on every walled read, and the org-less RBAC catalog reads ZERO for every principal #10103
Routed here from cloud#1406 (target:v17, release blocker on the Route-1 flagship shape). All readings below were taken against framework 8798cd2a60649f9a54d794a97da314d41be4ab7b — the sha cloud/.objectstack-sha pins — and every file cited is byte-identical at framework main7552e0337508972585b09e65a398a0f115fa5d26 (blob-hash compared 2026-08-20), so a pin bump does not change any of it.
The symptom, measured on a real deployment
cloud#1406 measured this over HTTP on the standalone HotCRM SaaS rig (OS_TENANCY_POSTURE=isolated, three organizations, one SQLite database), as two different real principals signed in through the ordinary auth path:
org owner platform admin rows in DB
GET /api/v1/data/sys_position 0 / 200 0 / 200 18
GET .../sys_permission_set 0 / 200 0 / 200 14
GET .../sys_sharing_rule 0 / 200 0 / 200 10
GET .../sys_user 1 / 200 3 / 200
GET .../sys_member 1 / 200 1 / 200
GET .../crm_account 10 / 200 9 / 200
Every table reading empty carries organization_id = NULL on 100% of its rows; every table reading normally is org-stamped. It is not a permission refusal — a refusal on this deployment is an unambiguous 403 (POST sys_user_position as an org owner), and the platform admin's POST sys_user_position returns 201 with the row correctly stamped.
Product consequence: on this shape no principal can list positions, permission sets or sharing rules, so nothing can be bound through Setup and hierarchy-security can never be armed by an operator however loudly an app declares it in requires.
Cause 1 — two implementations of the tenant wall predicate disagree, and the ungoverned one wins
The governed side.SqlDriver.applyTenantScope (packages/drivers/driver-sql/src/sql-driver.ts) is the single read-side tenant chokepoint, re-derived from the AST by pnpm check:tenant-chokepoint on every run. It composes:
(organization_id = :tenant OR organization_id IS NULL)
and its own comment states the ruling and this exact symptom, already paid for once:
(field = :tenantId OR field IS NULL) — a NULL tenant column marks a GLOBAL/platform row (bootstrap-seeded positions and permission sets, business units, pre-org first-boot seeds). Such a row belongs to no OTHER tenant, so the cross-tenant wall must not hide it: with strict equality every tenant admin saw ZERO RBAC rows on a fresh deployment, because every platform row is org-less (#2734).
suggested-audience-bindings.ts names the same rule as ADR-0120 D3's platform bucket and records it as measured driver behaviour, not an inference.
The ungoverned side.computeTenantLayer0Filter (packages/plugins/plugin-security/src/tenant-layer.ts) composes, for the isolated posture:
// `isolated`: the hard wall. Missing active org -> fail closed.if(!input.organizationId)return{ ...RLS_DENY_FILTER};return{organization_id: input.organizationId};
Strict equality, no NULL arm. The security middleware AND-composes it into the read AST (security-plugin.ts: computeRlsFilter -> andComposeLayers(layer0, layer1) -> extra.push(rlsFilter) -> opCtx.ast.where = { $and: [...] }), and the driver then adds its own predicate on top of that same query:
(organization_id = X OR organization_id IS NULL) AND organization_id = X == organization_id = X
The driver's NULL arm is annihilated by conjunction on every authenticated read. #2734's fix therefore holds only for paths that never traverse plugin-security's Layer 0 — which is no authenticated HTTP read on a walled deployment. The group arm has the same shape (driver whereIn(...).orWhereNull(...) vs Layer 0's bare { $in: [...] }).
This is the #8608 shape again — one question, two implementations, the governed side being overridden by the ungoverned duplicate — except here the disagreement is on the wall's own predicate rather than on the index behind it.
Cause 2 — the platform admin's zero is a SECOND, separate gate (tested separately, as cloud#1406 required)
The Layer-0 cross-tenant exemption cannot fire on these three objects for any principal, whatever their rung:
sys_position, sys_permission_set and sys_sharing_rule all declare managedBy: 'config', no access.default: 'private', and no tenancy opt-out — so all three disjuncts are false, superuserBypass is short-circuited to false, and isPlatformAdmin is false regardless of the caller's actual PLATFORM_ADMIN rung. This is declared behaviour ("a platform admin stays org-scoped on public tenant business objects"), not a defect on its own.
Positive control from the same table, produced by this same branch: sys_user is managedBy: 'better-auth' => posturePermits true => the platform admin reads 3 where the org owner reads 1.
Consequence for fix design: marking these objects access.default: 'private' repairs the platform-admin arm and leaves the org owner at zero. It is not a fix.
Cause 3 — the writers, for completeness (⛔ not the place to fix it)
bootstrapDeclaredPositions, bootstrapBuiltinRoles, bootstrapDeclaredPermissions (plugin-security) and bootstrapDeclaredSharingRules (plugin-sharing) all run once inside runBootstrap on kernel:ready, insert under { isSystem: true } with no tenantId, and never write organization_id. cloud#1406 measured sys_position.min(created_at) 13:25:14.377 against sys_organization.min(created_at) 13:25:15.505 — the first insert lands about a second before the deployment's first organization exists.
⛔ Stamping an organization id onto these rows is ruled out on cloud#1406: there is no correct id to stamp, and stamping one converts a shared platform catalog into the first organization's private property.
⚠️ The fork — this needs a maintainer ruling, not a patch
The framework carries two contradictory doctrines about org-less rows at this same pin:
cloud#1406 sits where the two collide, and every available repair picks one of them. ⛔ None should be implemented before the ruling.
Option A — rebind Layer 0 to the driver's declared rule. Make the isolated arm { $or: [{ organization_id: X }, { organization_id: null }] } and the group arm the matching union, spelled to match applyTenantScope verbatim. One function; restores #2734 at the layer that defeats it; applies the standing meta-rule (#8608) that the governed side wins and the ungoverned duplicate rebinds. ⚠️ Cost: it converts every accidentally org-less row — cloud#1395's class (sys_approval_request, sys_automation_run, written by producers that DO have an organization in hand and drop it) — from invisible-to-everyone into visible-to-everyone. An availability defect becomes a confidentiality one, on the arm cloud#1239 already measured, and it contradicts the direction cloud#1395 was ruled.
Option B — narrow the platform bucket to a DECLARED row class. NULL alone stops meaning global; managed_by = 'platform' (the marking these rows already carry, A4 #2920) becomes the thing both layers read, spelled once and identically at the driver and at Layer 0. Keeps #2734's outcome for the catalog, keeps #1395's accidental NULL rows invisible, and makes "platform-level" something a writer must say rather than something it can forget.
Cost: a new scoping semantic on a public contract, and managed_by becomes security-relevant, so its write gates must be as strict as a wall.
Option C — per-organization materialization (recommended). Move the four seeders from upsert-by-name to upsert-by-(name, organization_id) and run them once per organization under a walled posture, following #8617's landed pattern in this same plugin — including its reap of pre-fix org-less rows and its single-posture carve-out (exactly one organization-less pass, no reap). The wall needs no change at all, at either layer, and no NULL row is load-bearing anywhere.
Cost: N-times the rows; the seeders must also run on organization creation, not only at kernel:ready (today runBootstrap re-runs on sys_user insert only); a backfill/reap decision for existing NULL rows; and the framework-reserved built-ins (platform_admin, org_*, everyone, guest) get a copy per organization — arguably correct, since sys_user_position assignments are already per-organization, but it is itself a semantic call.
Recommendation: Option C, with Option B as fallback if per-organization copies of the reserved built-ins are refused
Real business need — measured, not speculative. Setup's position / permission-set / sharing-rule administration is unreachable on the flagship shape, and a declared hierarchy-security can never be armed. This is the release blocker, and it is a real operator workflow with a real app shipping the declarations.
Making AI-written metadata hard to get wrong — decisive against A. Under A, omitting the organization stamp is a privilege-widening accident that no gate catches, which is precisely the failure mode an AI-authored writer produces (cloud#1395 documents two live instances). Under C an org-less row is invalid state and can be refused or reaped loudly. B is second best: the widening requires a positive managed_by: 'platform' claim.
Startup scope discipline — C is bounded to four seeders plus an organization-create hook plus a backfill, all inside plugin-security / plugin-sharing, with a landed precedent to copy. A is one line but buys an indefinite security liability. B is the most contract surface of the three, which is why it is the fallback rather than the recommendation.
Whichever is ruled, D-global and D-invalid should stop coexisting as unqualified general rules — that coexistence is what let this reach a release-blocking shape without either side looking wrong locally.
Files
packages/plugins/plugin-security/src/tenant-layer.ts — computeTenantLayer0Filter (the strict-equality predicate)
packages/plugins/plugin-security/src/{bootstrap-declared-positions,bootstrap-builtin-positions,bootstrap-declared-permissions}.ts, packages/plugins/plugin-sharing/src/bootstrap-declared-sharing-rules.ts — the writers
Routed here from cloud#1406 (
target:v17, release blocker on the Route-1 flagship shape). All readings below were taken against framework8798cd2a60649f9a54d794a97da314d41be4ab7b— the shacloud/.objectstack-shapins — and every file cited is byte-identical at frameworkmain7552e0337508972585b09e65a398a0f115fa5d26(blob-hash compared 2026-08-20), so a pin bump does not change any of it.The symptom, measured on a real deployment
cloud#1406 measured this over HTTP on the standalone HotCRM SaaS rig (
OS_TENANCY_POSTURE=isolated, three organizations, one SQLite database), as two different real principals signed in through the ordinary auth path:Every table reading empty carries
organization_id = NULLon 100% of its rows; every table reading normally is org-stamped. It is not a permission refusal — a refusal on this deployment is an unambiguous 403 (POST sys_user_positionas an org owner), and the platform admin'sPOST sys_user_positionreturns 201 with the row correctly stamped.Product consequence: on this shape no principal can list positions, permission sets or sharing rules, so nothing can be bound through Setup and
hierarchy-securitycan never be armed by an operator however loudly an app declares it inrequires.Cause 1 — two implementations of the tenant wall predicate disagree, and the ungoverned one wins
The governed side.
SqlDriver.applyTenantScope(packages/drivers/driver-sql/src/sql-driver.ts) is the single read-side tenant chokepoint, re-derived from the AST bypnpm check:tenant-chokepointon every run. It composes:and its own comment states the ruling and this exact symptom, already paid for once:
suggested-audience-bindings.tsnames the same rule as ADR-0120 D3's platform bucket and records it as measured driver behaviour, not an inference.The ungoverned side.
computeTenantLayer0Filter(packages/plugins/plugin-security/src/tenant-layer.ts) composes, for theisolatedposture:Strict equality, no NULL arm. The security middleware AND-composes it into the read AST (
security-plugin.ts:computeRlsFilter->andComposeLayers(layer0, layer1)->extra.push(rlsFilter)->opCtx.ast.where = { $and: [...] }), and the driver then adds its own predicate on top of that same query:The driver's NULL arm is annihilated by conjunction on every authenticated read. #2734's fix therefore holds only for paths that never traverse plugin-security's Layer 0 — which is no authenticated HTTP read on a walled deployment. The
grouparm has the same shape (driverwhereIn(...).orWhereNull(...)vs Layer 0's bare{ $in: [...] }).This is the #8608 shape again — one question, two implementations, the governed side being overridden by the ungoverned duplicate — except here the disagreement is on the wall's own predicate rather than on the index behind it.
Cause 2 — the platform admin's zero is a SECOND, separate gate (tested separately, as cloud#1406 required)
The Layer-0 cross-tenant exemption cannot fire on these three objects for any principal, whatever their rung:
sys_position,sys_permission_setandsys_sharing_ruleall declaremanagedBy: 'config', noaccess.default: 'private', and no tenancy opt-out — so all three disjuncts are false,superuserBypassis short-circuited tofalse, andisPlatformAdminisfalseregardless of the caller's actualPLATFORM_ADMINrung. This is declared behaviour ("a platform admin stays org-scoped on public tenant business objects"), not a defect on its own.Positive control from the same table, produced by this same branch:
sys_userismanagedBy: 'better-auth'=>posturePermitstrue => the platform admin reads 3 where the org owner reads 1.Consequence for fix design: marking these objects
access.default: 'private'repairs the platform-admin arm and leaves the org owner at zero. It is not a fix.Cause 3 — the writers, for completeness (⛔ not the place to fix it)
bootstrapDeclaredPositions,bootstrapBuiltinRoles,bootstrapDeclaredPermissions(plugin-security) andbootstrapDeclaredSharingRules(plugin-sharing) all run once insiderunBootstraponkernel:ready, insert under{ isSystem: true }with notenantId, and never writeorganization_id. cloud#1406 measuredsys_position.min(created_at)13:25:14.377 againstsys_organization.min(created_at)13:25:15.505 — the first insert lands about a second before the deployment's first organization exists.⛔ Stamping an organization id onto these rows is ruled out on cloud#1406: there is no correct id to stamp, and stamping one converts a shared platform catalog into the first organization's private property.
The framework carries two contradictory doctrines about org-less rows at this same pin:
objectstack devboot: tenant admin sees ZERO rows in sys_position / sys_permission_set / sys_business_unit over REST (Setup Access Control renders empty) #2734, ADR-0120 D3): a NULL organization marks a platform row that must stay visible to every tenant. Implemented at the driver chokepoint, gate-protected.sys_audience_binding_suggestion— "ONE PASS PER ORGANIZATION under a walled posture" — inside this same plugin.cloud#1406 sits where the two collide, and every available repair picks one of them. ⛔ None should be implemented before the ruling.
Option A — rebind Layer 0 to the driver's declared rule. Make the
⚠️ Cost: it converts every accidentally org-less row — cloud#1395's class (
isolatedarm{ $or: [{ organization_id: X }, { organization_id: null }] }and thegrouparm the matching union, spelled to matchapplyTenantScopeverbatim. One function; restores #2734 at the layer that defeats it; applies the standing meta-rule (#8608) that the governed side wins and the ungoverned duplicate rebinds.sys_approval_request,sys_automation_run, written by producers that DO have an organization in hand and drop it) — from invisible-to-everyone into visible-to-everyone. An availability defect becomes a confidentiality one, on the arm cloud#1239 already measured, and it contradicts the direction cloud#1395 was ruled.Option B — narrow the platform bucket to a DECLARED row class. NULL alone stops meaning global;
managed_by = 'platform'(the marking these rows already carry, A4 #2920) becomes the thing both layers read, spelled once and identically at the driver and at Layer 0. Keeps #2734's outcome for the catalog, keeps #1395's accidental NULL rows invisible, and makes "platform-level" something a writer must say rather than something it can forget.Cost: a new scoping semantic on a public contract, and
managed_bybecomes security-relevant, so its write gates must be as strict as a wall.Option C — per-organization materialization (recommended). Move the four seeders from upsert-by-
nameto upsert-by-(name, organization_id)and run them once per organization under a walled posture, following #8617's landed pattern in this same plugin — including its reap of pre-fix org-less rows and itssingle-posture carve-out (exactly one organization-less pass, no reap). The wall needs no change at all, at either layer, and no NULL row is load-bearing anywhere.Cost: N-times the rows; the seeders must also run on organization creation, not only at
kernel:ready(todayrunBootstrapre-runs onsys_userinsert only); a backfill/reap decision for existing NULL rows; and the framework-reserved built-ins (platform_admin,org_*,everyone,guest) get a copy per organization — arguably correct, sincesys_user_positionassignments are already per-organization, but it is itself a semantic call.Recommendation: Option C, with Option B as fallback if per-organization copies of the reserved built-ins are refused
hierarchy-securitycan never be armed. This is the release blocker, and it is a real operator workflow with a real app shipping the declarations.managed_by: 'platform'claim.plugin-security/plugin-sharing, with a landed precedent to copy. A is one line but buys an indefinite security liability. B is the most contract surface of the three, which is why it is the fallback rather than the recommendation.Whichever is ruled, D-global and D-invalid should stop coexisting as unqualified general rules — that coexistence is what let this reach a release-blocking shape without either side looking wrong locally.
Files
packages/plugins/plugin-security/src/tenant-layer.ts—computeTenantLayer0Filter(the strict-equality predicate)packages/plugins/plugin-security/src/security-plugin.ts—posturePermits/superuserBypass/isPlatformAdmin;computeRlsFilter; theopCtx.ast.whereAND-composition;runBootstrappackages/drivers/driver-sql/src/sql-driver.ts—applyTenantScope(the governed side, Freshobjectstack devboot: tenant admin sees ZERO rows in sys_position / sys_permission_set / sys_business_unit over REST (Setup Access Control renders empty) #2734)packages/plugins/plugin-security/src/{bootstrap-declared-positions,bootstrap-builtin-positions,bootstrap-declared-permissions}.ts,packages/plugins/plugin-sharing/src/bootstrap-declared-sharing-rules.ts— the writerspackages/plugins/plugin-security/src/suggested-audience-bindings.ts— The audience-binding-suggestion reconciler writes with a tenant-less system context, so one organization-less row serves every tenant — the second half of the #8577 install-path dead end #8617's landed per-organization pattern and its reapRefs: cloud#1406 (this card's source and the HTTP measurements), cloud#1395 (write side, ruled Option A 2026-08-17), cloud#1239 (the visible-to-everyone arm), cloud#1396, cloud#1338, objectstack#2734, objectstack#8617, objectstack#8608, ADR-0095 D1, ADR-0105 D1/D2, ADR-0120 D3.