From b55e8cecaed67a3d7111abef0ded1960f8243337 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 8 Oct 2026 14:45:49 +0000 Subject: [PATCH 1/9] wip(spec,objectql,security): a declared platform-global object gets no organization column (ADR-0131 D7) The injected-columns plan takes the deployment's platformGlobalObjects as its input; the engine reads it at start() before the first schema sync and re-plans objects registered earlier; plugin-security's stand-down fold retires. Claude-Session: https://claude.ai/code/session_01LAi5BVvQNiYzepSAcsoFLK Co-authored-by: Claude --- .../deployment-org-scoping-entitlement.ts | 29 ++- packages/core/src/security/index.ts | 10 + packages/objectql/src/engine.ts | 18 +- packages/objectql/src/plugin.ts | 116 +++++++++- packages/objectql/src/registry.ts | 216 +++++++++++++++++- .../organizations/src/organizations-plugin.ts | 10 + .../src/auto-org-admin-grant.ts | 5 +- .../plugin-security/src/security-plugin.ts | 82 +++---- .../spec/src/data/injected-system-columns.ts | 58 ++++- packages/spec/src/security/tenancy-posture.ts | 47 ++-- .../src/security/tenant-layer0-verdict.ts | 13 +- 11 files changed, 512 insertions(+), 92 deletions(-) rename packages/{plugins/plugin-security/src => core/src/security}/deployment-org-scoping-entitlement.ts (80%) diff --git a/packages/plugins/plugin-security/src/deployment-org-scoping-entitlement.ts b/packages/core/src/security/deployment-org-scoping-entitlement.ts similarity index 80% rename from packages/plugins/plugin-security/src/deployment-org-scoping-entitlement.ts rename to packages/core/src/security/deployment-org-scoping-entitlement.ts index d8a9caa4482..fc21c82862a 100644 --- a/packages/plugins/plugin-security/src/deployment-org-scoping-entitlement.ts +++ b/packages/core/src/security/deployment-org-scoping-entitlement.ts @@ -5,6 +5,17 @@ * mounted `org-scoping` service (`OrgScopingEntitlement`, * `@objectstack/spec/security`). * + * ## Why it lives in core + * + * The two keys have two consumers in two packages that cannot import each + * other: the engine's schema registry (`@objectstack/objectql`) plans the + * columns from `platformGlobalObjects` (ADR-0131 D7: a declared object gets no + * organization column on this deployment), and `@objectstack/plugin-security` + * shapes the `organization_admin` auto-grant from + * `suppressUnboundedOrgAdminGrant`. Both depend on this package, so the ONE + * reader — and its fail-closed contract — lives here. Each caller warns for + * the refused key it consumes, and only that one. + * * ## Why a reader, and why it fails closed per key * * The `org-scoping` service is the enterprise runtime's own object — a live @@ -16,8 +27,8 @@ * never coerced onto a permissive branch. Here the non-permissive branch is * "the key was never declared": * - * - `platformGlobalObjects` junk ⇒ NO object is exempted (everything walls - * exactly as its own declaration says); + * - `platformGlobalObjects` junk ⇒ NO object is declared (every object's + * injected-columns plan is exactly what its own declaration says); * - `suppressUnboundedOrgAdminGrant` junk ⇒ the auto-grant keeps today's * posture-keyed behaviour. * @@ -28,8 +39,9 @@ * ⛔ Partial honouring is refusal's other failure mode: one junk ENTRY voids * the whole `platformGlobalObjects` key rather than dropping the entry. The * declarer is first-party runtime code; half-honouring a malformed list hides - * the bug behind mostly-working behaviour, while a whole-key refusal walls - * every named object — loud on the first smoke test, and safe. + * the bug behind mostly-working behaviour, while a whole-key refusal keeps the + * organization column (and the wall) on every named object — loud on the + * first smoke test, and safe. * * ## Read timing * @@ -40,7 +52,9 @@ * registers after the reader's own init). A re-registered service is a new * instance and re-validates; in-place mutation of a declaration is outside the * contract (the interface is readonly, and every declared key is expected to - * be constant for the kernel's life). + * be constant for the kernel's life). The engine reads `platformGlobalObjects` + * once, at its plugin's `start()`, before the first schema sync — see + * `SchemaRegistry.setDeploymentPlatformGlobalObjects` for the ordering. */ import { @@ -60,8 +74,9 @@ export interface RefusedEntitlementKey { /** The validated, fail-closed reading of the deployment's declaration. */ export interface DeploymentOrgScopingEntitlementReading { /** - * Objects this deployment declares platform-global (Layer 0 must not wall - * them here). Empty when the key is absent, junk, or no service is mounted. + * Objects this deployment declares platform-global — each gets no + * organization column on this deployment (ADR-0131 D7). Empty when the key is + * absent, junk, or no service is mounted. */ readonly platformGlobalObjects: ReadonlySet; /** diff --git a/packages/core/src/security/index.ts b/packages/core/src/security/index.ts index 07851fdeec2..ebc327acce3 100644 --- a/packages/core/src/security/index.ts +++ b/packages/core/src/security/index.ts @@ -54,6 +54,16 @@ export { // re-exports both names unchanged. export { HTTP_SIGNATURE_HEADER, signHttpBody } from './http-signature.js'; +// [#12699 / ADR-0131 D7] The ONE fail-closed reader of the mounted `org-scoping` +// service's per-deployment keys — shared by the engine's schema registry +// (`platformGlobalObjects`: no organization column on a declared object) and +// plugin-security (`suppressUnboundedOrgAdminGrant`). +export { + readDeploymentOrgScopingEntitlement, + type DeploymentOrgScopingEntitlementReading, + type RefusedEntitlementKey, +} from './deployment-org-scoping-entitlement.js'; + // `PluginConfigValidator` / `createPluginConfigValidator` were RETIRED here on // 2026-08-27 (#11982, ADR-0049 enforce-or-remove; recorded in ADR-0025 §3.7). // The kernel never received a plugin's config to validate — factories close diff --git a/packages/objectql/src/engine.ts b/packages/objectql/src/engine.ts index 97c150f0e5d..be44b319e1f 100644 --- a/packages/objectql/src/engine.ts +++ b/packages/objectql/src/engine.ts @@ -2883,9 +2883,11 @@ export interface OperationContext { * to stamp `BulkDataEvent.organizationId`. * * The seam ruled on #15706: the wall is computed ONCE, where every input is - * visible (the posture in force, the caller's organization scope, the - * object's tenancy clauses AND the deployment's #12699 carve-out, which no - * schema carries), and its decision travels here as a value. A reader + * visible (the posture in force, the caller's organization scope and the + * object's tenancy clauses — which, since ADR-0131 D7, include the + * deployment's #12699 platform-global declaration, recorded on the + * registered object as `systemFields.tenant: false`), and its decision + * travels here as a value. A reader * answers from this member ALONE and re-derives nothing — a re-derivation * is a mirror of the wall, and a mirror structurally sees only the clauses * it was taught (the #15706 mislabel). @@ -3545,10 +3547,14 @@ function eventOrganizationValue(value: unknown): string | undefined { * re-derived the wall here — from the enforced posture, the execution * context's `tenantId` / `accessible_org_ids` / `posture` rung, and the * object schema's tenancy clauses. It could not see the third clause - * plugin-security folds into `tenancyDisabled` — the deployment-declared - * `platformGlobalObjects` carve-out (#12699), which no schema carries — and + * plugin-security then folded into `tenancyDisabled` — the deployment-declared + * `platformGlobalObjects` carve-out (#12699), which no schema carried — and * stamped the caller's organization onto a batch Layer 0 had never - * constrained: a WRONG key, the #13566 leak shape. The ruling's acceptance + * constrained: a WRONG key, the #13566 leak shape. (ADR-0131 D7 has since + * made that declaration total: a declared object is registered with no + * organization column and declaring `systemFields.tenant: false`, so the fold + * is retired — but the rule below does not lean on that; the ruling's + * acceptance criterion is about the seam, not one clause.) The ruling's acceptance * criterion, verbatim: the verdict recorded must be what the wall decided, * not a re-statement of its inputs; if the recorded value can be derived by * the reader from anything else on the context, the mirror has not been diff --git a/packages/objectql/src/plugin.ts b/packages/objectql/src/plugin.ts index 9a41af3ad87..7c7485a1d4a 100644 --- a/packages/objectql/src/plugin.ts +++ b/packages/objectql/src/plugin.ts @@ -4,7 +4,7 @@ import { ObjectQL } from './engine.js'; import { assembleMetadataProtocol } from '@objectstack/metadata-protocol'; import type { MetadataAuthoringChannel } from '@objectstack/metadata-protocol'; import { Plugin, PluginContext } from '@objectstack/core'; -import { resolveArtifactPackageOrder, artifactPackageId } from '@objectstack/core'; +import { resolveArtifactPackageOrder, artifactPackageId, readDeploymentOrgScopingEntitlement } from '@objectstack/core'; import { applyConversionsToStoredItem } from '@objectstack/spec'; import { StorageNameMapping } from '@objectstack/spec/system'; // [#21777] The ONE "is this schema the remote's?" predicate, shared with `ObjectQL.syncSchemas`. @@ -314,6 +314,110 @@ export class ObjectQLPlugin implements Plugin { this.lifecycleOptions = opts.lifecycle; } + /** + * [ADR-0131 D7] The objects the deployment declares platform-global, as this + * plugin installed them at `start()` — compared at `kernel:ready` by + * {@link assertDeploymentPlatformGlobalObjectsUnchanged}. + */ + private installedPlatformGlobalObjects: ReadonlySet = new Set(); + + /** + * [ADR-0131 D7 — the #12699 declaration made total] Read the deployment's + * `OrgScopingEntitlement.platformGlobalObjects` off the mounted `org-scoping` + * service and install it as the injected-columns plan's input: every object + * it names gets NO organization column on this deployment, so Layer 0 and + * the driver agree by having nothing to scope. + * + * ## Why here, at the top of `start()` (ADR-0116) + * + * Every `init()` has completed by now — the Phase 1/2 split — and the + * provider declares `org-scoping` in `providesServices`, i.e. registers it + * unconditionally in its `init()`. So the declaration read here is final for + * this boot, and no table exists yet: the first schema sync is below. The + * plan is ALSO computed at registration, inside each registrant's `init()`, + * and the provider cannot be ordered ahead of those (it hard-depends on this + * plugin), so the registry re-plans every object already registered + * (`SchemaRegistry.setDeploymentPlatformGlobalObjects`). + * + * Fail closed, through the one reader (`readDeploymentOrgScopingEntitlement`, + * `@objectstack/core`): no service or an absent key installs nothing and + * every plan is byte-identical; a junk key is refused loudly and installs + * nothing, so no object loses its column. + */ + private installDeploymentPlatformGlobalObjects(ctx: PluginContext): void { + if (!this.ql) return; + const reading = this.readDeploymentPlatformGlobalObjects(ctx); + this.installedPlatformGlobalObjects = reading; + if (reading.size === 0) return; + const { replanned, keptAuthoredColumn } = + this.ql.registry.setDeploymentPlatformGlobalObjects(reading); + const objects = [...reading].sort(); + ctx.logger.info( + `[ObjectQLPlugin] deployment declares ${objects.length} platform-global object(s) — no ` + + 'organization column on THIS deployment (ADR-0131 D7); each is governed by object permission, ' + + 'not by the organization wall', + { objects, replannedAtStart: replanned }, + ); + if (keptAuthoredColumn.length > 0) { + ctx.logger.warn( + `[ObjectQLPlugin] deployment declares ${keptAuthoredColumn.join(', ')} platform-global, but the ` + + 'object DECLARES its own organization_id: that column is the author\'s, not the platform\'s, so it ' + + 'stays and the organization wall keeps scoping it. Remove the authored organization_id field to ' + + 'make the declaration take effect, or drop the object from the declaration.', + { objects: keptAuthoredColumn }, + ); + } + } + + /** The validated `platformGlobalObjects` reading; warns for a refused key. */ + private readDeploymentPlatformGlobalObjects(ctx: PluginContext): ReadonlySet { + let service: unknown; + try { + service = ctx.getService('org-scoping'); + } catch { + service = undefined; + } + const reading = readDeploymentOrgScopingEntitlement(service); + for (const refusal of reading.refused) { + if (refusal.key !== 'platformGlobalObjects') continue; + ctx.logger.warn( + `[ObjectQLPlugin] org-scoping entitlement key 'platformGlobalObjects' REFUSED — ${refusal.problem}`, + { key: refusal.key, declared: refusal.value }, + ); + } + return reading.platformGlobalObjects; + } + + /** + * [ADR-0131 D7] At `kernel:ready`, refuse the boot when the `org-scoping` + * service now declares a different platform-global set than the one the + * columns were planned from at `start()`. That only happens when a provider + * registers `org-scoping` outside its `init()` (or swaps it), which ADR-0116 + * gives no ordering for: the objects it names were provisioned WITH the + * column, and serving on would advertise a declaration the deployment does + * not honour. Reads the service without warning — a junk key was warned + * about at `start()` and reads as the same empty set. + */ + private assertDeploymentPlatformGlobalObjectsUnchanged(ctx: PluginContext): void { + let service: unknown; + try { + service = ctx.getService('org-scoping'); + } catch { + service = undefined; + } + const now = readDeploymentOrgScopingEntitlement(service).platformGlobalObjects; + const before = this.installedPlatformGlobalObjects; + const same = now.size === before.size && [...now].every((name) => before.has(name)); + if (same) return; + throw new Error( + '[ObjectQLPlugin] the org-scoping service\'s platformGlobalObjects declaration changed after the engine ' + + `planned the columns at start() (planned: [${[...before].sort().join(', ')}]; now: ` + + `[${[...now].sort().join(', ')}]). The objects it names were provisioned with an organization column. ` + + "Register 'org-scoping' in the provider's init() and declare it in its providesServices (ADR-0116), " + + 'so every plugin start() — this engine\'s schema sync included — reads the final declaration.', + ); + } + /** * Arm the protocol-driven rebinds. Shared by both assembly modes: called with * the in-house shim when `registerProtocol` is on, and lazily from `start()` @@ -560,6 +664,11 @@ export class ObjectQLPlugin implements Plugin { start = async (ctx: PluginContext) => { ctx.logger.info('ObjectQL engine starting...'); + // [ADR-0131 D7] FIRST: the deployment's platform-global declaration is the + // injected-columns plan's input, so it is installed before anything below + // registers another object or creates a table. + this.installDeploymentPlatformGlobalObjects(ctx); + // Delegated-assembly mode (ADR-0076 Step 2): the protocol was registered // by MetadataProtocolPlugin during init — arm the authored hook/action // rebind against it now that all inits ran. Graceful when absent. @@ -602,6 +711,11 @@ export class ObjectQLPlugin implements Plugin { // Idempotent: the bind fully replaces the 'metadata-service' package // set, so edited hooks re-bind and deleted hooks tear down. ctx.hook('kernel:ready', async () => { + // [ADR-0131 D7] The declaration the columns were planned from must still + // be the deployment's: a provider that registered `org-scoping` after + // this plugin's start() was never read, so its objects were provisioned + // WITH the column. Refused here, by name, rather than served. + this.assertDeploymentPlatformGlobalObjectsUnchanged(ctx); // A field naming a picklist no package declares fails the boot, naming // the field and the package — never served as a select with nothing to // choose — and so does an extension of such a list, whose values would diff --git a/packages/objectql/src/registry.ts b/packages/objectql/src/registry.ts index 5b4161ea96b..f05b3eafdb4 100644 --- a/packages/objectql/src/registry.ts +++ b/packages/objectql/src/registry.ts @@ -1,6 +1,6 @@ // Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. -import { ServiceObject, ObjectSchema, ObjectOwnership, provisionPrimary, resolveInjectedSystemColumns, isTenancyDisabled, checkManagedApiMethodAffordances, LEGACY_API_METHODS, AUDIT_PROVENANCE_FIELDS } from '@objectstack/spec/data'; +import { ServiceObject, ObjectSchema, ObjectOwnership, provisionPrimary, resolveInjectedSystemColumns, isInjectedColumnDefinition, isTenancyDisabled, checkManagedApiMethodAffordances, LEGACY_API_METHODS, AUDIT_PROVENANCE_FIELDS } from '@objectstack/spec/data'; // [#4513] The audit-family governance table, and [#6562] the injected-column // DEFINITION tables it governs — see the re-exports below for why both live in a // package `objectql` and `metadata-protocol` both depend on. @@ -498,7 +498,15 @@ const OWNING_BUSINESS_UNIT_FIELD: typeof SystemFieldName.OWNING_BUSINESS_UNIT_ID export function applySystemFields( schema: ServiceObject, - opts: { multiTenant: boolean } + opts: { + multiTenant: boolean; + /** + * [ADR-0131 D7] The objects THIS deployment declares platform-global — the + * plan's one deployment input. A declared object gets no `organization_id` + * here. Omitted or empty ⇒ the plan is byte-identical to the authored one. + */ + platformGlobalObjects?: ReadonlySet; + } ): ServiceObject { // WHICH columns this object carries is the spec's derivation // (`resolveInjectedSystemColumns`, #5378) — one answer shared with every @@ -518,7 +526,16 @@ export function applySystemFields( // spreads — the definitions must stay byte-identical to the shipped tables, // because the #7859 Layer-0 guard and the #4326 round-trip strip both read // them by exact identity. Do not add keys here; consumers ask the API. - const plan = resolveInjectedSystemColumns(schema); + // + // [ADR-0131 D7] …and the deployment's platform-global declaration is the + // plan's one deployment input: a declared object is planned with no tenant + // column, so nothing below injects `organization_id` for it. Passed only when + // a deployment declared something, so every other call is the one-argument + // plan, byte for byte. + const plan = + opts.platformGlobalObjects && opts.platformGlobalObjects.size > 0 + ? resolveInjectedSystemColumns(schema, { platformGlobalObjects: opts.platformGlobalObjects }) + : resolveInjectedSystemColumns(schema); // 1. Hard opt-out at object level (e.g. seed/migration tables). // Folded into the plan (`systemFields: false` ⇒ every flag false), so the @@ -1830,6 +1847,17 @@ function collectBundle( return { bare: canonicalFirst(bare), local: canonicalFirst(local), other: canonicalFirst(other) }; } +/** + * `schema` without the field `name` — `schema` itself, by reference, when it + * does not carry one. Never mutates its input. + */ +function withoutField(schema: ServiceObject, name: string): ServiceObject { + const fields = schema.fields as Record | undefined; + if (!fields || fields[name] === undefined) return schema; + const { [name]: _dropped, ...rest } = fields; + return { ...schema, fields: rest } as ServiceObject; +} + export class SchemaRegistry { // ========================================== // Logging control @@ -1847,6 +1875,16 @@ export class SchemaRegistry { /** Cross-package base-layer collision policy (ADR-0048). */ private readonly collisionPolicy: 'error' | 'warn'; + /** + * [ADR-0131 D7] The objects THIS deployment declares platform-global — the + * #12699 declaration made total: each one gets NO organization column here. + * + * Empty until the engine plugin installs the deployment's validated reading + * ({@link setDeploymentPlatformGlobalObjects}), which it does at `start()`, + * before the first schema sync. See that method for the ordering. + */ + private deploymentPlatformGlobalObjects: ReadonlySet = new Set(); + constructor(options: SchemaRegistryOptions = {}) { if (options.multiTenant !== undefined) { this.multiTenant = options.multiTenant; @@ -2220,7 +2258,10 @@ export class SchemaRegistry { // the registered schema (driver syncSchema, REST projector, hooks) // sees the same canonical shape. Author-declared fields win — see // applySystemFields(). - schema = applySystemFields(schema, { multiTenant: this.multiTenant }); + schema = applySystemFields(schema, { + multiTenant: this.multiTenant, + platformGlobalObjects: this.deploymentPlatformGlobalObjects, + }); // [ADR-0092 / #1591] Reconcile generic-write `apiMethods` against the CRUD // affordances a better-auth-managed object actually grants — strip verbs @@ -2637,6 +2678,14 @@ export class SchemaRegistry { ): ServiceObject { let out = schema; + // [ADR-0131 D7] The deployment's platform-global declaration, recorded on + // the base layer. FIRST, because every later stamp asks its question of the + // object as this deployment provisions it — the tenant index below must + // see a declared object as carrying no tenant column. See + // {@link applyDeploymentTenancy}; its write-side inverse is the LAST strip + // in {@link stripMaterializedStampsFrom}. + out = this.applyDeploymentTenancy(out); + // [ADR-0079] DESIGNATE-ONLY primary-title provisioning — `synthesize: // false` never adds a column, so this is safe against title-less // system/junction tables (see the call in `registerObject` for the full @@ -2888,11 +2937,166 @@ export class SchemaRegistry { */ stripMaterializedStampsFrom(base: T): T { if (base === null || typeof base !== 'object') return base; - return this.stripProvisionedPrimaryFrom( - this.stripProvisionedSearchCompanionFrom(this.stripProvisionedTenantIndexFrom(base)), + return this.stripDeploymentTenancyFrom( + this.stripProvisionedPrimaryFrom( + this.stripProvisionedSearchCompanionFrom(this.stripProvisionedTenantIndexFrom(base)), + ), ); } + // ========================================== + // [ADR-0131 D7] The deployment's platform-global declaration + // ========================================== + + /** + * Install the objects THIS deployment declares platform-global — the + * validated reading of the mounted `org-scoping` service's + * `OrgScopingEntitlement.platformGlobalObjects` (#12699, made total by + * ADR-0131 D7: "an object a deployment declares platform-global gets no + * organization column on that deployment (the injected-columns plan reads + * the declaration), so Layer 0 and the driver agree by having nothing to + * scope"). + * + * ## When, and what orders it + * + * The engine plugin calls this at `start()`, before its first schema sync. + * The plan is computed at {@link registerObject}, which runs inside whichever + * plugin's `init()` registers the object — and the `org-scoping` provider + * cannot be hoisted ahead of every one of those: it hard-depends on the + * engine (its own `init()` registers a manifest), so an engine-side + * `optionalDependencies` edge on it is a cycle, and an edge from every + * object registrant is an open-ended set. What does order them is ADR-0116's + * Phase 1/2 split: every `init()` completes before any `start()`, and the + * provider declares `org-scoping` in `providesServices`, i.e. registers it + * unconditionally in `init()`. So at the engine's `start()` the declaration + * is final, and no table exists yet. + * + * ## What it does to an object registered before it + * + * Re-plans it, so the registry answers exactly what {@link registerObject} + * answers for an object registered after it: on every base layer the + * platform's tenant index entry and the platform's own `organization_id` + * come off and `systemFields.tenant: false` is recorded + * ({@link applyDeploymentTenancy}); an `extend` layer loses the platform's + * own `organization_id`. A column the AUTHOR declared is the author's — it + * stays, the object stays walled on it, and its name is returned in + * `keptAuthoredColumn` for the caller to report. + * + * The declaration is constant for the kernel's life (the `org-scoping` + * service's keys are readonly). Installing again re-plans with the new set; + * an object dropped from it is not re-injected. + */ + setDeploymentPlatformGlobalObjects(names: Iterable): { + readonly replanned: readonly string[]; + readonly keptAuthoredColumn: readonly string[]; + } { + this.deploymentPlatformGlobalObjects = new Set(names); + const replanned = new Set(); + const keptAuthoredColumn = new Set(); + if (this.deploymentPlatformGlobalObjects.size === 0) { + return { replanned: [], keptAuthoredColumn: [] }; + } + for (const contributors of this.objectContributors.values()) { + for (const contributor of contributors) { + const name = (contributor.definition as { name?: unknown })?.name; + if (typeof name !== 'string' || !this.deploymentPlatformGlobalObjects.has(name)) continue; + const before = contributor.definition; + const orgField = (before.fields as Record | undefined)?.organization_id; + if (orgField !== undefined && !isInjectedColumnDefinition(orgField, TENANT_SCOPE_FIELD_DEF)) { + keptAuthoredColumn.add(name); + continue; + } + const after = + contributor.ownership === 'extend' + ? withoutField(before, 'organization_id') + : this.applyDeploymentTenancy(this.stripProvisionedTenantIndexFrom(before)); + if (after !== before) { + contributor.definition = after; + replanned.add(name); + } + } + } + this.invalidateAll(); + return { replanned: [...replanned].sort(), keptAuthoredColumn: [...keptAuthoredColumn].sort() }; + } + + /** [ADR-0131 D7] The objects THIS deployment declares platform-global (read-only view). */ + getDeploymentPlatformGlobalObjects(): ReadonlySet { + return this.deploymentPlatformGlobalObjects; + } + + /** + * [ADR-0131 D7] Record the deployment's platform-global declaration on one + * base-layer body: a declared object carries no `organization_id` here and + * declares `systemFields.tenant: false`. + * + * Why the record and not only the missing column: `systemFields.tenant: + * false` is the vocabulary every reader of a registered object already + * answers "no organization column" from — the security layer's + * `tenancyDisabled` (so Layer 0 composes nothing and a wildcard + * `organization_id` policy is not applicable, with no second reading of the + * declaration), the tenant-index predicate above, the lifecycle's + * provenance (`absent`), and the `/meta` read exits' own injection pass, + * which re-plans a served body WITHOUT the deployment and would otherwise + * add the column back. The object is then exactly what a deployment-level + * object that declares the key itself is (ADR-0131 D7: governed by object + * permission, not by the wall), on this deployment only. + * + * Withheld — `schema` returned by reference — when the object is not + * declared, when its own declaration already plans no tenant column, and + * when it DECLARES its own `organization_id` (not the platform's + * definition, byte for byte): an authored column is the author's, and the + * object stays walled on it. The decision reads only the body, so the read + * exits ({@link materializeServedObjectOnto}) reach the same answer. + */ + private applyDeploymentTenancy(schema: ServiceObject): ServiceObject { + const name = (schema as { name?: unknown })?.name; + if (typeof name !== 'string' || !this.deploymentPlatformGlobalObjects.has(name)) return schema; + const fields = schema.fields as Record | undefined; + const orgField = fields?.organization_id; + if (orgField !== undefined && !isInjectedColumnDefinition(orgField, TENANT_SCOPE_FIELD_DEF)) return schema; + if (orgField === undefined && !resolveInjectedSystemColumns(schema).tenant) return schema; + const sf = (schema as { systemFields?: unknown }).systemFields; + const out = withoutField(schema, 'organization_id') as ServiceObject & { systemFields?: unknown }; + return { + ...out, + systemFields: { + ...(sf && typeof sf === 'object' && !Array.isArray(sf) ? (sf as Record) : {}), + tenant: false, + }, + } as ServiceObject; + } + + /** + * [ADR-0131 D7] The write-side inverse of {@link applyDeploymentTenancy}: + * take the recorded `systemFields.tenant: false` back off a declared + * object's body on its way IN, so a Studio GET → edit → PUT on the declaring + * deployment persists the body the author wrote (#4326), not a deployment + * fact that would outlive the declaration in `sys_metadata`. + * + * Exact in the way its siblings are: only a declared object, only the + * `tenant: false` member, and the `systemFields` key itself only when that + * member was all it held. The trade is theirs too — an author who wrote + * `systemFields: { tenant: false }` on a declared object has it dropped on + * the first save that carries it, and on this deployment the answer is + * re-derived at every load, so nothing it provisions changes. + * + * Returns `base` by reference when nothing was owed. + */ + private stripDeploymentTenancyFrom(base: T): T { + if (base === null || typeof base !== 'object') return base; + const name = (base as { name?: unknown }).name; + if (typeof name !== 'string' || !this.deploymentPlatformGlobalObjects.has(name)) return base; + const sf = (base as { systemFields?: unknown }).systemFields; + if (!sf || typeof sf !== 'object' || Array.isArray(sf)) return base; + if ((sf as { tenant?: unknown }).tenant !== false) return base; + const { tenant: _tenant, ...restSystemFields } = sf as Record; + const out = { ...(base as Record) }; + if (Object.keys(restSystemFields).length === 0) delete out.systemFields; + else out.systemFields = restSystemFields; + return out as unknown as T; + } + /** * [#8375] Remove the tenant-scope index entry {@link materializeBaseLayer} * appended — and only that one. diff --git a/packages/plugins/organizations/src/organizations-plugin.ts b/packages/plugins/organizations/src/organizations-plugin.ts index 597275e19bf..85d3d597242 100644 --- a/packages/plugins/organizations/src/organizations-plugin.ts +++ b/packages/plugins/organizations/src/organizations-plugin.ts @@ -161,6 +161,16 @@ export class OrganizationsPlugin implements Plugin { type = 'standard' as const; version = '1.0.0'; dependencies = ['com.objectstack.engine.objectql']; + /** + * Services `init()` UNCONDITIONALLY registers (ADR-0116 D2). `org-scoping` is + * registered first thing in `init()`, so every plugin's `start()` reads the + * final answer — the engine's schema sync included, which plans each object's + * columns from this service's `platformGlobalObjects` declaration (ADR-0131 + * D7: a declared object gets no organization column on this deployment). + * A subclass that moved the registration out of `init()` would break that + * order; the engine refuses such a boot at `kernel:ready`. + */ + readonly providesServices = ['org-scoping']; /** * [ADR-0105 D12, as amended by ADR-0132] Which tenancy postures THIS runtime diff --git a/packages/plugins/plugin-security/src/auto-org-admin-grant.ts b/packages/plugins/plugin-security/src/auto-org-admin-grant.ts index 646d8fb359f..e38971357a1 100644 --- a/packages/plugins/plugin-security/src/auto-org-admin-grant.ts +++ b/packages/plugins/plugin-security/src/auto-org-admin-grant.ts @@ -72,8 +72,9 @@ const SYSTEM_CTX = { isSystem: true } as const; * * [#12699] `suppressUnbounded` is the deployment's own veto on the walled * branch (`OrgScopingEntitlement.suppressUnboundedOrgAdminGrant`): D4's "Layer - * 0 bounds it" rationale stops holding on a deployment that carves - * platform-global objects OUT of the wall, so such a deployment declares that + * 0 bounds it" rationale stops holding on a deployment that declares objects + * platform-global (ADR-0131 D7: they carry no organization column there, so no + * wall bounds them), so such a deployment declares that * arming a walled posture must NOT auto-grant the unbounded superbits — the * de-VAMA'd variant is granted on walled postures too. Fail closed: `false`/ * absent keeps today's posture-keyed behaviour exactly. diff --git a/packages/plugins/plugin-security/src/security-plugin.ts b/packages/plugins/plugin-security/src/security-plugin.ts index a70099338ef..4adac045f99 100644 --- a/packages/plugins/plugin-security/src/security-plugin.ts +++ b/packages/plugins/plugin-security/src/security-plugin.ts @@ -165,7 +165,7 @@ import { import { readDeploymentOrgScopingEntitlement, type DeploymentOrgScopingEntitlementReading, -} from './deployment-org-scoping-entitlement.js'; +} from '@objectstack/core'; import { SysPositionDetailPage } from '@objectstack/platform-objects/pages'; import { securityObjects, @@ -1181,26 +1181,33 @@ export class SecurityPlugin implements Plugin { /** * [#12699] `problem` strings already warned about, so a junk * `OrgScopingEntitlement` key is explained once per boot rather than on - * every security-meta fill (the `warnedAuthoredTenantPolicies` pattern). + * every read (the `warnedAuthoredTenantPolicies` pattern). */ private readonly warnedEntitlementRefusals = new Set(); /** - * [#12699] The deployment's wall-shaping declaration, read LIVE off the - * mounted `org-scoping` service — the same read pattern as the seam's - * existing consumer (plugin-auth's `probeEntitledPostures`): resolve the - * service per call, fail closed to "absent" on any miss. Live resolution is - * ordering-robust by construction: a deployment where the wall is ARMED had - * `org-scoping` registered before this plugin's `start()` captured the - * posture, so the declaration is present from the first read; a deployment - * where it registered too late resolves `single` and Layer 0 is inert, so - * the exemption decides nothing. Validation is memoized per service - * instance inside the reader; refusals are warned once per boot here. + * [#12699] The deployment's `suppressUnboundedOrgAdminGrant` declaration, + * read LIVE off the mounted `org-scoping` service — the same read pattern as + * the seam's existing consumer (plugin-auth's `probeEntitledPostures`): + * resolve the service per call, fail closed to "absent" on any miss. + * Validation is memoized per service instance inside the reader; refusals + * of the key this plugin consumes are warned once per boot here. + * + * [ADR-0131 D7] `platformGlobalObjects` is NOT consumed here any more, and + * nothing in this plugin stands Layer 0 down for a declared object. The + * declaration is the injected-columns plan's input, read by the engine's + * schema registry (`@objectstack/objectql`): a declared object is + * registered with no `organization_id` and declaring + * `systemFields.tenant: false`, so the object's own two clauses in + * {@link getObjectSecurityMeta} answer for it, and Layer 0 and the driver + * agree by having nothing to scope. The engine warns for a junk + * `platformGlobalObjects`; this plugin warns only for the key it reads. */ private deploymentOrgScopingEntitlement(): DeploymentOrgScopingEntitlementReading { const reading = readDeploymentOrgScopingEntitlement( this.resolveKernelService?.('org-scoping'), ); for (const refusal of reading.refused) { + if (refusal.key !== 'suppressUnboundedOrgAdminGrant') continue; if (this.warnedEntitlementRefusals.has(refusal.problem)) continue; this.warnedEntitlementRefusals.add(refusal.problem); this.logger?.warn?.( @@ -1863,19 +1870,13 @@ export class SecurityPlugin implements Plugin { ? 'organization_id IN accessible_org_ids — union access across the caller\'s memberships' : 'organization_id = active organization'})`, ); - // [#12699] Surface the deployment's wall-shaping declaration at arming - // time: the carve-out list is a security-relevant deployment fact, and - // this is also the boot-time seam where a junk declaration gets its - // warn-once (the accessor validates as a side effect), instead of - // surfacing on the first request. + // [#12699] Surface the deployment's grant-shaping declaration at arming + // time: it is a security-relevant deployment fact, and this is also the + // boot-time seam where a junk declaration gets its warn-once (the + // accessor validates as a side effect), instead of surfacing on the + // first request. [ADR-0131 D7] The platform-global list is announced by + // the engine, which plans the columns from it — not here. const entitlement = this.deploymentOrgScopingEntitlement(); - if (entitlement.platformGlobalObjects.size > 0) { - ctx.logger.info( - `[security] deployment declares ${entitlement.platformGlobalObjects.size} platform-global ` + - `object(s) — Layer 0 does not wall them on THIS deployment`, - { objects: [...entitlement.platformGlobalObjects].sort() }, - ); - } if (entitlement.suppressUnboundedOrgAdminGrant) { ctx.logger.info( '[security] deployment suppresses the unbounded organization_admin auto-grant — ' + @@ -9441,25 +9442,26 @@ export class SecurityPlugin implements Plugin { } const meta = { isPrivate: (obj as any)?.access?.default === 'private', - // [#12699] The deployment's `platformGlobalObjects` declaration folds in - // HERE, and only here — this meta is the single source every Layer 0 - // consumer reads (the read wall and the Layer 1 wildcard-`organization_id` - // drop via the 'tenancyDisabled' merge in computeLayeredRlsFilter; the - // ADR-0123 D2 write refusal and the forge guard via - // computeWriteTenantCheckFilter, which IS that same layer0; the + // The object's own two clauses, and nothing else. This meta is the single + // source every Layer 0 consumer reads (the read wall and the Layer 1 + // wildcard-`organization_id` drop via the 'tenancyDisabled' merge in + // computeLayeredRlsFilter; the ADR-0123 D2 write refusal and the forge + // guard via computeWriteTenantCheckFilter, which IS that same layer0; the // platform-admin `posturePermits` gate and the write-check bypass via - // `meta.tenancyDisabled` directly) — so a deployment-exempted object - // behaves exactly as if it had declared `tenancy: { enabled: false }` - // itself, on every one of those paths at once, on THIS deployment only. - // Gated on the armed wall so the `single` posture stays byte-identical - // (there the exemption could only perturb Layer 1 bypasses on a wall - // that does not exist). Fail closed: absent/junk declaration ⇒ the - // object's own two clauses decide, exactly as today. + // `meta.tenancyDisabled` directly). + // + // [ADR-0131 D7] The deployment's #12699 `platformGlobalObjects` + // declaration used to fold in here as a third clause — a Layer 0 + // stand-down on an object that still carried its organization column, so + // the driver went on scoping what the wall had stopped scoping. That + // stand-down is retired, not kept beside its replacement: the + // declaration is now the injected-columns plan's input, and the engine's + // registry registers a declared object with no `organization_id` and + // declaring `systemFields.tenant: false` — the second clause below, read + // off the live schema like every other object's. tenancyDisabled: (obj as any)?.tenancy?.enabled === false || - (obj as any)?.systemFields?.tenant === false || - (this.orgScopingEnabled && - this.deploymentOrgScopingEntitlement().platformGlobalObjects.has(object)), + (obj as any)?.systemFields?.tenant === false, // Identity-infrastructure tables managed by the auth library // (`managedBy: 'better-auth'`: sys_user, sys_account, sys_session, // sys_oauth_application, sys_sso_provider, …). Their rows are written by diff --git a/packages/spec/src/data/injected-system-columns.ts b/packages/spec/src/data/injected-system-columns.ts index 3679d0c4e49..f1b3a148b82 100644 --- a/packages/spec/src/data/injected-system-columns.ts +++ b/packages/spec/src/data/injected-system-columns.ts @@ -37,15 +37,28 @@ * `injected-system-column-provenance.ts` by #8116, the WHAT-half move #3786 * anticipated, so author-time tools can ask the storage question too.) * - * ## Why it is a pure derivation, and total + * ## Why it is a pure derivation, and where it is not author-time-total * - * Every input is a key the object itself declares — `systemFields`, `managedBy`, - * `ownership`, `tenancy.enabled`, `name`. Nothing here depends on runtime state: - * measured against `applySystemFields`, the `multiTenant` option changes only - * whether `organization_id` is INDEXED, never whether it exists. That is what - * makes an author-time verdict trustworthy — the linter and the registry cannot - * disagree about a column's existence, because there is nothing left for them - * to disagree about. + * Every OBJECT input is a key the object itself declares — `systemFields`, + * `managedBy`, `ownership`, `tenancy.enabled`, `name`. Measured against + * `applySystemFields`, the `multiTenant` option changes only whether + * `organization_id` is INDEXED, never whether it exists. + * + * There is exactly ONE deployment input, and it is explicit: the objects a + * deployment declares platform-global (the second argument, the validated + * reading of the mounted `org-scoping` service's + * `OrgScopingEntitlement.platformGlobalObjects`). ADR-0131 D7: "an object a + * deployment declares platform-global gets no organization column on that + * deployment (the injected-columns plan reads the declaration), so Layer 0 and + * the driver agree by having nothing to scope". The runtime registry passes it; + * an author-time caller (the linter, the import mapper, the tenancy census) + * has no deployment and passes nothing, so it reads the AUTHORED plan — the + * one every deployment that declares nothing provisions. On the declaring + * deployment alone, an author-time + * verdict can therefore name an `organization_id` that deployment does not + * have; the runtime refuses it there as an unknown field. Absent, empty or + * refused (junk is refused at the reading seam, never coerced), the input + * changes nothing: the plan is byte-identical to the one-argument call. * * Tolerant of bare / un-parsed metadata records (same contract as * {@link deriveFieldGroupLayout} / {@link deriveRecordSurface}) so every @@ -72,6 +85,15 @@ const OWNING_BUSINESS_UNIT_COLUMN = 'owning_business_unit_id'; /** THE tenant isolation key. */ const TENANT_SCOPE_COLUMN = 'organization_id'; +/** Does the deployment's declaration name the object `name` platform-global? */ +function deploymentDeclaresPlatformGlobal( + declared: ReadonlySet | readonly string[] | undefined, + name: string, +): boolean { + if (declared === undefined) return false; + return declared instanceof Set ? declared.has(name) : (declared as readonly string[]).includes(name); +} + /** * Which system columns an object carries, as the four independent decisions the * injection pass makes plus the resolved name set. @@ -113,6 +135,7 @@ export interface InjectedSystemColumnPlan { * | `systemFields: false` | nothing (hard opt-out; seed/migration tables) | * | `managedBy: 'better-auth'` | nothing (better-auth owns the columns) | * | `systemFields.tenant: false` / `tenancy.enabled: false` | no `organization_id` | + * | its `name` in the deployment's `platformGlobalObjects` (2nd argument) | no `organization_id` | * | `systemFields.audit: false` | no audit family | * | `managedBy: ` or a `sys_*` name | no ownership anchors (either tier) | * | `ownership: 'org' \| 'none'` | no ownership anchors (either tier) | @@ -135,8 +158,17 @@ export interface InjectedSystemColumnPlan { * so it must not presume a Zod-narrowed value. * * @param def An object definition, or any bare record shaped like one. + * @param deployment [ADR-0131 D7] The deployment's own input, when the caller + * has a deployment. `platformGlobalObjects` is the VALIDATED reading of the + * mounted `org-scoping` service's `OrgScopingEntitlement.platformGlobalObjects` + * (exact object machine names; a junk declaration is refused at the reading + * seam and arrives here as nothing). The registry is its one runtime caller; + * an author-time caller has no deployment and omits the whole argument. */ -export function resolveInjectedSystemColumns(def: unknown): InjectedSystemColumnPlan { +export function resolveInjectedSystemColumns( + def: unknown, + deployment?: { readonly platformGlobalObjects?: ReadonlySet | readonly string[] }, +): InjectedSystemColumnPlan { const obj: AnyRec = def && typeof def === 'object' && !Array.isArray(def) ? (def as AnyRec) : {}; const systemFields = obj.systemFields; const managedBy = obj.managedBy; @@ -160,7 +192,13 @@ export function resolveInjectedSystemColumns(def: unknown): InjectedSystemColumn ? (systemFields as { tenant?: boolean; audit?: boolean }) : undefined; - const tenant = sf?.tenant !== false && !isTenancyDisabled(obj); + // [ADR-0131 D7] The deployment's platform-global declaration withholds the + // tenant column exactly where the object's own two opt-outs do: an object + // THIS deployment declares platform-global has no organization column here. + const tenant = + sf?.tenant !== false && + !isTenancyDisabled(obj) && + !(name !== '' && deploymentDeclaresPlatformGlobal(deployment?.platformGlobalObjects, name)); const audit = sf?.audit !== false; // Platform-managed tables and the `sys_*` namespace never carry a per-record diff --git a/packages/spec/src/security/tenancy-posture.ts b/packages/spec/src/security/tenancy-posture.ts index 62f502e14e8..0bdd33649fc 100644 --- a/packages/spec/src/security/tenancy-posture.ts +++ b/packages/spec/src/security/tenancy-posture.ts @@ -114,20 +114,37 @@ export function postureUsesUnionScope(posture: TenancyPosture): boolean { export interface OrgScopingEntitlement { readonly supportedPostures?: readonly TenancyPosture[]; /** - * Objects THIS deployment declares platform-global: Layer 0 must not wall - * them here, exactly as if the object had declared - * `tenancy: { enabled: false }` — but only on this deployment. Consumed by - * plugin-security when arming the Layer 0 organization wall; it composes - * with (never replaces) the object-level authoring channel. + * Objects THIS deployment declares platform-global: on this deployment each + * one gets NO organization column (ADR-0131 D7 — the #12699 declaration + * made total). The injected-columns plan reads the declaration + * (`resolveInjectedSystemColumns`' deployment input, `@objectstack/spec/data`), + * so the engine's schema registry neither injects nor provisions + * `organization_id` for a declared object and registers it as declaring + * `systemFields.tenant: false`: Layer 0 and the driver agree by having + * nothing to scope, and the object is governed by object permission, not by + * the wall. The same object keeps its column, and walls, on every + * deployment that does not declare it. It composes with (never replaces) + * the object-level authoring channel. + * + * Read by the ObjectQL plugin at `start()`, before the first schema sync: + * every `init()` has run by then (ADR-0116, the Phase 1/2 split), so a + * provider that registers `org-scoping` in its `init()` — and declares it in + * `providesServices` — has always registered. An object registered earlier + * is re-planned there, before its table is created. * * Entries are exact object machine names ({@link PlatformGlobalObjectsSchema} - * — no wildcards: a pattern would let one declaration unwall an open-ended - * set, and the whole point of the seam is an explicit, auditable carve-out). + * — no wildcards: a pattern would let one declaration drop the column from + * an open-ended set, and the whole point of the seam is an explicit, + * auditable carve-out). An object that DECLARES its own `organization_id` + * keeps that column (it is the author's, not the platform's), walls on it, + * and is reported once at boot. * - * Fail closed: absent ⇒ every object walls exactly as its own declaration - * says; a junk shape is refused loudly at the consuming seam (the + * Fail closed: absent ⇒ every object's plan is byte-identical to a runtime + * predating the key; a junk shape is refused loudly at the reading seam (the * `MembershipPolicy` precedent — never coerced), which also resolves to - * "absent". + * "absent". Existing rows' column on a declaring deployment is not touched + * by any boot step (ADR-0131 D14): schema sync is additive, and the column + * stays as an orphan until the operator removes it. */ readonly platformGlobalObjects?: readonly string[]; /** @@ -137,9 +154,9 @@ export interface OrgScopingEntitlement { * `organization_admin_no_bypass` (the de-VAMA'd variant) instead, on walled * postures too. The ADR-0105 D4 rationale for granting the unbounded set * under a wall — "Layer 0 bounds it" — stops holding on a deployment that - * carves platform-global objects OUT of the wall with - * {@link platformGlobalObjects}, so the same runtime that declares the - * carve-out declares this suppression. + * declares objects platform-global with {@link platformGlobalObjects} (they + * carry no organization column there, so no wall bounds them), so the same + * runtime that declares the carve-out declares this suppression. * * Fail closed: absent or `false` ⇒ today's posture-keyed grant; junk is * refused loudly and resolves to "absent". @@ -167,8 +184,8 @@ export type PlatformGlobalObjects = z.infer; * object schema: the `org-scoping` service is usually a live plugin instance * carrying service machinery alongside the declaration, and unknown keys are * not junk. Consumers validate PER KEY (each key fails closed independently) - * rather than all-or-nothing — see plugin-security's - * `readDeploymentOrgScopingEntitlement`. + * rather than all-or-nothing — see `readDeploymentOrgScopingEntitlement` + * (`@objectstack/core`). */ export const OrgScopingEntitlementSchema = z.object({ supportedPostures: z.array(TenancyPostureSchema).readonly().optional(), diff --git a/packages/spec/src/security/tenant-layer0-verdict.ts b/packages/spec/src/security/tenant-layer0-verdict.ts index af9f17cf4df..68d336ee581 100644 --- a/packages/spec/src/security/tenant-layer0-verdict.ts +++ b/packages/spec/src/security/tenant-layer0-verdict.ts @@ -10,10 +10,13 @@ * * `plugin-security` computes the Layer 0 predicate once per operation * (`computeTenantLayer0Filter`, `tenant-layer.ts`) from inputs that only IT - * can see in full: the posture in force, the caller's organization scope, the - * object's own tenancy declaration — and the DEPLOYMENT's carve-out - * ({@link OrgScopingEntitlement.platformGlobalObjects}, #12699), which no - * object schema carries. A producer in another package that needs to know what + * can see in full: the posture in force, the caller's organization scope and + * the object's own tenancy declaration. (The DEPLOYMENT's carve-out, + * {@link OrgScopingEntitlement.platformGlobalObjects} (#12699), was once a + * further input no object schema carried; ADR-0131 D7 made it total — a + * declared object is registered with no organization column and declaring + * `systemFields.tenant: false`, so it now arrives as the object's own + * declaration.) A producer in another package that needs to know what * the wall decided — the bulk data-event publisher in `@objectstack/objectql`, * which stamps `BulkDataEvent.organizationId` only when the wall named exactly * one organization — cannot re-derive it: every re-derivation is a MIRROR of @@ -30,7 +33,7 @@ * * | `kind` | what the wall composed | when | * |-----------------|-------------------------------------------------|------| - * | `none` | nothing — Layer 0 contributed no predicate | `single` posture; a non-tenant object (no `organization_id` column, `tenancy.enabled: false`, `systemFields.tenant: false`, or the deployment's #12699 carve-out); an exempt `PLATFORM_ADMIN` on a posture-permitting object; the #12974 verified-owner READ bypass | + * | `none` | nothing — Layer 0 contributed no predicate | `single` posture; a non-tenant object (no `organization_id` column, `tenancy.enabled: false`, `systemFields.tenant: false` — which an object the deployment declares platform-global is registered with, ADR-0131 D7); an exempt `PLATFORM_ADMIN` on a posture-permitting object; the #12974 verified-owner READ bypass | * | `organization` | `organization_id = organizationId` | `isolated` — the hard wall names exactly one organization | * | `organizations` | `organization_id IN organizationIds` | `group` — the caller's membership set (ADR-0105 D2); a SET: distinct, non-empty | * | `deny` | the fail-closed sentinel (zero rows / refused) | a walled posture on a tenant object with no organization scope to enforce with | From 72ec2f23a0b914cc52f7074c436a2b950b7f71ce Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 8 Oct 2026 14:57:14 +0000 Subject: [PATCH 2/9] test(spec,core,objectql,security): pin the no-column plan, the reader and the declared order (ADR-0131 D7) Claude-Session: https://claude.ai/code/session_01LAi5BVvQNiYzepSAcsoFLK Co-authored-by: Claude --- ...deployment-org-scoping-entitlement.test.ts | 69 ++++ ...egistry-deployment-platform-global.test.ts | 211 +++++++++++++ packages/objectql/src/registry.ts | 30 +- ...ployment-platform-global-exemption.test.ts | 230 ++++++-------- ...form-global-no-organization-column.test.ts | 296 ++++++++++++++++++ .../tenant-layer0-verdict-end-to-end.test.ts | 47 +-- ...tenant-layer0-verdict-on-operation.test.ts | 17 +- .../src/data/injected-system-columns.test.ts | 63 ++++ 8 files changed, 789 insertions(+), 174 deletions(-) create mode 100644 packages/core/src/security/deployment-org-scoping-entitlement.test.ts create mode 100644 packages/objectql/src/registry-deployment-platform-global.test.ts create mode 100644 packages/plugins/plugin-security/src/platform-global-no-organization-column.test.ts diff --git a/packages/core/src/security/deployment-org-scoping-entitlement.test.ts b/packages/core/src/security/deployment-org-scoping-entitlement.test.ts new file mode 100644 index 00000000000..ad9117adf04 --- /dev/null +++ b/packages/core/src/security/deployment-org-scoping-entitlement.test.ts @@ -0,0 +1,69 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#12699 / ADR-0131 D7] The ONE reader of the mounted `org-scoping` service's + * per-deployment keys. Its rules stand as #12699 set them: absent key ⇒ nothing + * declared; junk ⇒ the WHOLE key refused (no object declared, no partial + * honouring), each key independently, never coerced. + * + * The cases moved here with the reader: they used to run through + * plugin-security's Layer 0 stand-down, which ADR-0131 D7 retired. The engine's + * schema registry is now the `platformGlobalObjects` consumer and + * plugin-security the `suppressUnboundedOrgAdminGrant` one; both read this. + */ + +import { describe, expect, it } from 'vitest'; +import { readDeploymentOrgScopingEntitlement } from './deployment-org-scoping-entitlement.js'; + +describe('readDeploymentOrgScopingEntitlement', () => { + it.each([ + ['no service', undefined], + ['a null service', null], + ['a non-object service', 'org-scoping'], + ['a service declaring neither key', { name: 'com.example.org-scoping', supportedPostures: ['isolated'] }], + ])('%s ⇒ nothing declared, nothing refused', (_label, service) => { + const reading = readDeploymentOrgScopingEntitlement(service); + expect([...reading.platformGlobalObjects]).toEqual([]); + expect(reading.suppressUnboundedOrgAdminGrant).toBe(false); + expect(reading.refused).toEqual([]); + }); + + it('a well-formed declaration is read as declared', () => { + const reading = readDeploymentOrgScopingEntitlement({ + platformGlobalObjects: ['sys_setting', 'sys_job'], + suppressUnboundedOrgAdminGrant: true, + }); + expect([...reading.platformGlobalObjects].sort()).toEqual(['sys_job', 'sys_setting']); + expect(reading.suppressUnboundedOrgAdminGrant).toBe(true); + expect(reading.refused).toEqual([]); + }); + + it.each([ + ['a bare string', 'sys_widget_registry'], + ['a wildcard entry beside a valid one', ['sys_widget_registry', '*']], + ['an empty-string entry', ['sys_widget_registry', '']], + ['a number', 42], + ])('junk platformGlobalObjects (%s) ⇒ the WHOLE key refused, no object declared', (_label, value) => { + const reading = readDeploymentOrgScopingEntitlement({ platformGlobalObjects: value }); + expect([...reading.platformGlobalObjects]).toEqual([]); + expect(reading.refused.map((r) => r.key)).toEqual(['platformGlobalObjects']); + expect(reading.refused[0]?.value).toEqual(value); + }); + + it('junk in one key does not void the other (each key fails closed independently)', () => { + const reading = readDeploymentOrgScopingEntitlement({ + platformGlobalObjects: ['sys_widget_registry'], + suppressUnboundedOrgAdminGrant: 'yes', + }); + expect([...reading.platformGlobalObjects]).toEqual(['sys_widget_registry']); + expect(reading.suppressUnboundedOrgAdminGrant).toBe(false); + expect(reading.refused.map((r) => r.key)).toEqual(['suppressUnboundedOrgAdminGrant']); + }); + + it('memoizes per service instance; a new instance re-validates', () => { + const service = { platformGlobalObjects: ['sys_widget_registry'] }; + expect(readDeploymentOrgScopingEntitlement(service)).toBe(readDeploymentOrgScopingEntitlement(service)); + const other = { platformGlobalObjects: ['sys_widget_registry'] }; + expect(readDeploymentOrgScopingEntitlement(other)).not.toBe(readDeploymentOrgScopingEntitlement(service)); + }); +}); diff --git a/packages/objectql/src/registry-deployment-platform-global.test.ts b/packages/objectql/src/registry-deployment-platform-global.test.ts new file mode 100644 index 00000000000..a0a99a3fd99 --- /dev/null +++ b/packages/objectql/src/registry-deployment-platform-global.test.ts @@ -0,0 +1,211 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [ADR-0131 D7] The #12699 deployment declaration made total, in the registry: + * "an object a deployment declares platform-global gets no organization column + * on that deployment (the injected-columns plan reads the declaration), so + * Layer 0 and the driver agree by having nothing to scope". + * + * The registry is where the plan is applied, so these pins read its answers + * directly — the registered object, the tenant index beside it, the `/meta` + * read exit's convergence and the write-side strip — each with the control a + * wrong fix fails: + * + * - a declared object registered AFTER the declaration is installed, and one + * registered BEFORE it (re-planned at install — the engine plugin installs + * at `start()`, after objects registered in other plugins' `init()`); + * - a non-declared object on the same deployment keeps its column and index; + * - an absent / empty declaration leaves every answer byte-identical; + * - an object that DECLARES its own `organization_id` keeps it (the author's + * column, not the platform's), and the install reports it. + * + * The booted-kernel pins — the provider, the DDL, Layer 0 and the driver — are + * in plugin-security's `platform-global-no-organization-column.test.ts`. + */ + +import { describe, it, expect } from 'vitest'; +import { ObjectStackProtocolImplementation } from '@objectstack/metadata-protocol'; +import { applyInjectedSystemColumns, assertEngineDeleteDispatch, assertEngineUpdateDispatch } from '@objectstack/metadata-core'; +import { TENANT_SCOPE_FIELD_DEF } from '@objectstack/spec/data'; +import { SchemaRegistry } from './registry.js'; +import { assertEngineFindOnePredicate } from './engine-findone-predicate.js'; +import type { EngineFindOneQueryInput } from './engine-findone-predicate.js'; + +const DECLARED = 'sys_widget_registry'; +const SIBLING = 'crm_task'; +const PLATFORM_TENANT_INDEX = { fields: ['organization_id'] }; + +const objectNamed = (name: string, extra: Record = {}) => + ({ + name, + label: name, + fields: { title: { type: 'text', label: 'Title' } }, + ...extra, + }) as any; + +const tenantIndexes = (def: any) => + (def?.indexes ?? []).filter( + (i: any) => Array.isArray(i?.fields) && i.fields.length === 1 && i.fields[0] === 'organization_id', + ); + +function registry(): SchemaRegistry { + return new SchemaRegistry({ multiTenant: true, searchCompanion: false } as never); +} + +/** The registry-backed `/meta` surface with no DB behind it (the #8608 double). */ +function metaSurface(reg: SchemaRegistry) { + const engine = { + registry: reg, + find: async () => [], + findOne: async (table: string, query?: EngineFindOneQueryInput) => { + assertEngineFindOnePredicate(table, query); + return null; + }, + insert: async () => ({ id: 'x' }), + update: async (_t: string, data: Record, opts?: Record) => { + assertEngineUpdateDispatch(data, opts); + return { id: 'x' }; + }, + delete: async (_t: string, opts?: Record) => { + assertEngineDeleteDispatch(opts); + return { deleted: 0 }; + }, + count: async () => 0, + aggregate: async () => [], + } as any; + return new ObjectStackProtocolImplementation(engine); +} + +describe('[ADR-0131 D7] a declared platform-global object gets no organization column — the registry', () => { + it('registered AFTER the install: no organization_id, no tenant index, and systemFields.tenant false recorded', () => { + const reg = registry(); + reg.setDeploymentPlatformGlobalObjects([DECLARED]); + reg.registerObject(objectNamed(DECLARED), 'test', 'test', 'own'); + reg.registerObject(objectNamed(SIBLING), 'test', 'test', 'own'); + + const declared: any = reg.getObject(DECLARED); + expect(declared.fields.organization_id).toBeUndefined(); + expect(tenantIndexes(declared)).toEqual([]); + expect(declared.systemFields).toEqual({ tenant: false }); + // The rest of the plan is the authored one: the audit family is still there. + expect(declared.fields.created_at).toBeDefined(); + + // CONTROL — a non-declared object on the same deployment keeps both. + const sibling: any = reg.getObject(SIBLING); + expect(sibling.fields.organization_id).toEqual(TENANT_SCOPE_FIELD_DEF); + expect(tenantIndexes(sibling)).toEqual([PLATFORM_TENANT_INDEX]); + expect(sibling.systemFields).toBeUndefined(); + }); + + it('registered BEFORE the install: re-planned — the provider may register after the objects', () => { + const reg = registry(); + reg.registerObject(objectNamed(DECLARED), 'test', 'test', 'own'); + reg.registerObject(objectNamed(SIBLING), 'test', 'test', 'own'); + reg.registerObject( + { name: DECLARED, fields: { extra: { type: 'text', label: 'Extra' } } } as any, + 'ext', + 'ext', + 'extend', + ); + // Premise: before the install the declared object carries the column. + expect((reg.getObject(DECLARED) as any).fields.organization_id).toBeDefined(); + + const result = reg.setDeploymentPlatformGlobalObjects(new Set([DECLARED])); + + expect(result).toEqual({ replanned: [DECLARED], keptAuthoredColumn: [] }); + const declared: any = reg.getObject(DECLARED); + expect(declared.fields.organization_id).toBeUndefined(); + expect(declared.fields.extra).toBeDefined(); + expect(tenantIndexes(declared)).toEqual([]); + expect(declared.systemFields).toEqual({ tenant: false }); + // Every contributor layer, not only the merged answer. + for (const c of reg.getObjectContributors(DECLARED)) { + expect((c.definition.fields as any).organization_id).toBeUndefined(); + } + // CONTROL + expect((reg.getObject(SIBLING) as any).fields.organization_id).toEqual(TENANT_SCOPE_FIELD_DEF); + expect(tenantIndexes(reg.getObject(SIBLING))).toEqual([PLATFORM_TENANT_INDEX]); + }); + + it('an absent or empty declaration leaves every registered object byte-identical', () => { + const plain = registry(); + const empty = registry(); + empty.setDeploymentPlatformGlobalObjects([]); + for (const reg of [plain, empty]) { + reg.registerObject(objectNamed(DECLARED), 'test', 'test', 'own'); + reg.registerObject(objectNamed(SIBLING, { ownership: 'org' }), 'test', 'test', 'own'); + } + for (const name of [DECLARED, SIBLING]) { + expect(JSON.stringify(empty.getObject(name))).toBe(JSON.stringify(plain.getObject(name))); + } + expect((plain.getObject(DECLARED) as any).fields.organization_id).toEqual(TENANT_SCOPE_FIELD_DEF); + }); + + it('an object that DECLARES its own organization_id keeps it — the author\'s column — and the install names it', () => { + const authored = { type: 'lookup', reference: 'sys_organization', label: 'Org' }; + const reg = registry(); + reg.registerObject( + objectNamed(DECLARED, { fields: { title: { type: 'text', label: 'Title' }, organization_id: authored } }), + 'test', + 'test', + 'own', + ); + const result = reg.setDeploymentPlatformGlobalObjects([DECLARED]); + + expect(result).toEqual({ replanned: [], keptAuthoredColumn: [DECLARED] }); + const obj: any = reg.getObject(DECLARED); + expect(obj.fields.organization_id).toEqual(authored); + expect(obj.systemFields).toBeUndefined(); + }); + + it('an object that opted out itself is left exactly as it was (nothing to record)', () => { + const reg = registry(); + reg.setDeploymentPlatformGlobalObjects([DECLARED, SIBLING]); + reg.registerObject(objectNamed(DECLARED, { tenancy: { enabled: false } }), 'test', 'test', 'own'); + reg.registerObject(objectNamed(SIBLING, { systemFields: false }), 'test', 'test', 'own'); + expect((reg.getObject(DECLARED) as any).systemFields).toBeUndefined(); + expect((reg.getObject(SIBLING) as any).systemFields).toBe(false); + }); + + it('the /meta read exit serves the registry\'s answer — it does not re-inject the column', async () => { + const reg = registry(); + reg.setDeploymentPlatformGlobalObjects([DECLARED]); + reg.registerObject(objectNamed(DECLARED), 'test', 'test', 'own'); + reg.registerObject(objectNamed(SIBLING), 'test', 'test', 'own'); + const protocol = metaSurface(reg); + + const item: any = (await protocol.getMetaItem({ type: 'object', name: DECLARED })).item; + expect(item.fields.organization_id).toBeUndefined(); + expect(item.systemFields).toEqual({ tenant: false }); + expect(tenantIndexes(item)).toEqual([]); + // CONTROL + const sibling: any = (await protocol.getMetaItem({ type: 'object', name: SIBLING })).item; + expect(sibling.fields.organization_id).toEqual(TENANT_SCOPE_FIELD_DEF); + }); + + it('a body the registry never materialized converges at the read seam, and the write seam takes the record back off', () => { + const reg = registry(); + reg.setDeploymentPlatformGlobalObjects([DECLARED]); + reg.registerObject(objectNamed(DECLARED), 'test', 'test', 'own'); + // A stored body (an overlay row / a metadata-service body), as the read + // exit's own injection pass serves it: the authored plan adds the column. + const stored = objectNamed(DECLARED); + const injected: any = applyInjectedSystemColumns(stored); + expect(injected.fields.organization_id).toBeDefined(); + + const served: any = reg.materializeServedObjectOnto(injected); + expect(served.fields.organization_id).toBeUndefined(); + expect(served.systemFields).toEqual({ tenant: false }); + + // Write side: the recorded deployment fact comes back off, so a Studio + // GET → PUT persists the body the author wrote (#4326). + const back: any = reg.stripMaterializedStampsFrom(served); + expect(back.systemFields).toBeUndefined(); + // CONTROL: an author's own other systemFields member survives the strip. + const withAudit: any = reg.stripMaterializedStampsFrom({ ...served, systemFields: { tenant: false, audit: false } }); + expect(withAudit.systemFields).toEqual({ audit: false }); + // …and a non-declared object's systemFields are never touched. + const sibling = { name: SIBLING, fields: {}, systemFields: { tenant: false } }; + expect(reg.stripMaterializedStampsFrom(sibling)).toBe(sibling); + }); +}); diff --git a/packages/objectql/src/registry.ts b/packages/objectql/src/registry.ts index f05b3eafdb4..1a47fe3cda1 100644 --- a/packages/objectql/src/registry.ts +++ b/packages/objectql/src/registry.ts @@ -856,10 +856,10 @@ const TENANT_SCOPE_INDEX: { fields: string[] } = { fields: ['organization_id'] } function provisionTenantScopeIndex( schema: ServiceObject, - opts: { multiTenant: boolean }, + opts: { multiTenant: boolean; platformGlobalObjects?: ReadonlySet }, ): ServiceObject { if (!opts.multiTenant) return schema; - if (!carriesTenantScopeColumn(schema)) return schema; + if (!carriesTenantScopeColumn(schema, opts.platformGlobalObjects)) return schema; if (declaresTenantIndex(schema)) return schema; return { @@ -953,7 +953,10 @@ function provisionTenantScopeIndex( * sites above, and ⛔ not a contract for any other package to answer the * wall from — the wall answers for itself (ADR-0131 D8). */ -function carriesTenantScopeColumn(schema: ServiceObject): boolean { +function carriesTenantScopeColumn( + schema: ServiceObject, + platformGlobalObjects?: ReadonlySet, +): boolean { // Clause 1 — the wall's own two clauses, spelled here because // plugin-security spells them there (option C, the single exported // predicate, is bounded to no new `@objectstack/spec` export and no @@ -964,8 +967,16 @@ function carriesTenantScopeColumn(schema: ServiceObject): boolean { return false; } // Clause 2 — there is a column for the wall's predicate to filter on. + // [ADR-0131 D7] The injection plan is asked with the deployment's + // platform-global declaration, the same input `applySystemFields` planned the + // column from: a declared object carries no injected tenant column, so there + // is nothing for an index to serve. + const plan = + platformGlobalObjects && platformGlobalObjects.size > 0 + ? resolveInjectedSystemColumns(schema, { platformGlobalObjects }) + : resolveInjectedSystemColumns(schema); return ( - resolveInjectedSystemColumns(schema).tenant || + plan.tenant || (schema as { fields?: Record }).fields?.organization_id != null ); } @@ -2978,7 +2989,7 @@ export class SchemaRegistry { * platform's tenant index entry and the platform's own `organization_id` * come off and `systemFields.tenant: false` is recorded * ({@link applyDeploymentTenancy}); an `extend` layer loses the platform's - * own `organization_id`. A column the AUTHOR declared is the author's — it + * own `organization_id` and tenant index. A column the AUTHOR declared is the author's — it * stays, the object stays walled on it, and its name is returned in * `keptAuthoredColumn` for the caller to report. * @@ -3006,10 +3017,15 @@ export class SchemaRegistry { keptAuthoredColumn.add(name); continue; } + // The tenant index comes off FIRST, while the body is still in the + // state it was stamped in (the strip re-stamps to prove the entry is + // the platform's own); an `extend` layer carried both too, because + // `applySystemFields` runs on every contributor. + const unindexed = this.stripProvisionedTenantIndexFrom(before); const after = contributor.ownership === 'extend' - ? withoutField(before, 'organization_id') - : this.applyDeploymentTenancy(this.stripProvisionedTenantIndexFrom(before)); + ? withoutField(unindexed, 'organization_id') + : this.applyDeploymentTenancy(unindexed); if (after !== before) { contributor.definition = after; replanned.add(name); diff --git a/packages/plugins/plugin-security/src/deployment-platform-global-exemption.test.ts b/packages/plugins/plugin-security/src/deployment-platform-global-exemption.test.ts index 6f09f0bacb1..f7c2647237e 100644 --- a/packages/plugins/plugin-security/src/deployment-platform-global-exemption.test.ts +++ b/packages/plugins/plugin-security/src/deployment-platform-global-exemption.test.ts @@ -1,26 +1,31 @@ // Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. /** - * [#12699 / cloud#1653] Deployment-declared platform-global exemption. + * [#12699 → ADR-0131 D7] The deployment-declared platform-global carve-out, as + * plugin-security sees it now that the declaration is total. * * The mounted `org-scoping` service may declare - * `OrgScopingEntitlement.platformGlobalObjects` — objects THIS deployment - * owns platform-globally, which Layer 0 must not wall HERE even though the - * same objects genuinely wall on tenant runtimes (which is why the per-object - * `tenancy: { enabled: false }` authoring channel cannot express the fact: - * that declaration travels with the object into every deployment). + * `OrgScopingEntitlement.platformGlobalObjects`. This plugin used to fold that + * list into `tenancyDisabled` — a Layer 0 STAND-DOWN on an object that still + * carried its organization column, so the driver went on scoping what the wall + * had stopped scoping. ADR-0131 D7 retires that stand-down ("replaced by D7's + * no-column"): the declaration is the injected-columns plan's input, and the + * engine's registry registers a declared object with no `organization_id` and + * declaring `systemFields.tenant: false`. What this plugin promises after that: * - * These cases pin the four properties the contract promises: + * 1. it reads NO `platformGlobalObjects`: an object that still carries its + * column is walled, whatever the service declares — on reads and on the + * ADR-0123 D2 write path (the same choke point); + * 2. an object registered the way the registry registers a declared one is + * not walled — through its own `systemFields.tenant: false`, the clause + * every deployment-level object answers from; + * 3. a junk `platformGlobalObjects` is the engine's to refuse (it consumes + * it); this plugin warns only for the key it reads, + * `suppressUnboundedOrgAdminGrant`, once per boot; + * 4. the arming log announces the grant suppression, not the list. * - * 1. an exempted object is not walled on this deployment — on the read path - * AND on the ADR-0123 D2 write-refusal path, because both are the same - * `computeLayeredRlsFilter().layer0` (the single choke point); - * 2. a non-exempted object walls exactly as before (the exemption is a - * carve-out, never a widening); - * 3. an ABSENT declaration is byte-identical to today (fail closed) — and so - * is a JUNK one, refused loudly per the `MembershipPolicy` precedent; - * 4. the exemption composes with, never replaces, the object-level - * `tenancy: { enabled: false }` channel. + * The booted-kernel end of the same contract — the provider, the plan, the + * DDL, the driver — is `platform-global-no-organization-column.test.ts`. * * Harness pattern: `federated-tenant-layer0.test.ts` — a SecurityPlugin over a * fake ObjectQL, asserting the composed FilterCondition before any driver sees @@ -44,21 +49,22 @@ const MEMBER_CTX = { userId: 'u1', tenantId: 'org-1', positions: [], permissions /** The same member with NO active organization — the ADR-0123 D2 write case. */ const NO_ORG_CTX = { userId: 'u1', positions: [], permissions: [] }; -/** Two ordinary local tenant objects — identical shapes, different names. */ -const localSchema = (name: string, extra: Record = {}) => ({ +/** An ordinary local tenant object, still carrying its organization column. */ +const walledSchema = (name: string) => ({ name, fields: { organization_id: { type: 'text', label: 'Organization' }, title: { type: 'text', label: 'Title' }, }, - ...extra, }); -/** - * Boot a SecurityPlugin over per-name schemas, with the `org-scoping` service - * carrying `entitlement` (the deployment's declaration). Returns the plugin and - * the fake logger so cases can assert the loud-refusal channel. - */ +/** The same object as the registry registers it on a deployment that declares it. */ +const plannedSchema = (name: string) => ({ + name, + systemFields: { tenant: false }, + fields: { title: { type: 'text', label: 'Title' } }, +}); + async function boot( schemas: Record>, opts: { entitlement?: Record; tenancy?: { posture: string } } = {}, @@ -91,91 +97,63 @@ async function boot( return { plugin, logger }; } -const TWO_OBJECTS = { - sys_widget_registry: localSchema('sys_widget_registry'), - crm_task: localSchema('crm_task'), -}; - -describe('[#12699] platformGlobalObjects — the deployment carve-out', () => { - it('an exempted object is NOT walled on this deployment (`isolated`)', async () => { - const { plugin } = await boot(TWO_OBJECTS, { - entitlement: { platformGlobalObjects: ['sys_widget_registry'] }, - }); +describe('[ADR-0131 D7] plugin-security reads no platformGlobalObjects — the stand-down is retired', () => { + it('a declared object that still carries its column is WALLED (`isolated`) — reads', async () => { + const { plugin } = await boot( + { sys_widget_registry: walledSchema('sys_widget_registry') }, + { entitlement: { platformGlobalObjects: ['sys_widget_registry'] } }, + ); // eslint-disable-next-line @typescript-eslint/no-explicit-any - const filter = await (plugin as any).getReadFilter('sys_widget_registry', MEMBER_CTX); - expect(filter).toBeUndefined(); - }); - - it('a NON-exempted object still walls exactly as before', async () => { - const { plugin } = await boot(TWO_OBJECTS, { - entitlement: { platformGlobalObjects: ['sys_widget_registry'] }, + expect(await (plugin as any).getReadFilter('sys_widget_registry', MEMBER_CTX)).toEqual({ + organization_id: 'org-1', }); - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const filter = await (plugin as any).getReadFilter('crm_task', MEMBER_CTX); - expect(filter).toEqual({ organization_id: 'org-1' }); }); - it('`group` posture: the exemption holds and the union wall stays on the sibling', async () => { - const { plugin } = await boot(TWO_OBJECTS, { - entitlement: { platformGlobalObjects: ['sys_widget_registry'] }, - tenancy: { posture: 'group' }, - }); + it('…and `group`: the union wall stands on it', async () => { + const { plugin } = await boot( + { sys_widget_registry: walledSchema('sys_widget_registry') }, + { entitlement: { platformGlobalObjects: ['sys_widget_registry'] }, tenancy: { posture: 'group' } }, + ); const groupCtx = { ...MEMBER_CTX, accessible_org_ids: ['org-1', 'org-2'] }; // eslint-disable-next-line @typescript-eslint/no-explicit-any - expect(await (plugin as any).getReadFilter('sys_widget_registry', groupCtx)).toBeUndefined(); - // eslint-disable-next-line @typescript-eslint/no-explicit-any - expect(await (plugin as any).getReadFilter('crm_task', groupCtx)).toEqual({ + expect(await (plugin as any).getReadFilter('sys_widget_registry', groupCtx)).toEqual({ organization_id: { $in: ['org-1', 'org-2'] }, }); }); - it('the ADR-0123 D2 write wall derives from the SAME choke point: an exempted object escapes the no-active-org refusal, a sibling does not', async () => { - const { plugin } = await boot(TWO_OBJECTS, { - entitlement: { platformGlobalObjects: ['sys_widget_registry'] }, - }); - // computeWriteTenantCheckFilter IS computeLayeredRlsFilter().layer0 — the - // derivation the middleware's refusal reads. Null = Layer 0 contributes - // nothing (the write may land); the deny sentinel = refusal. - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const exempted = await (plugin as any).computeWriteTenantCheckFilter( - [PLAIN_MEMBER], 'sys_widget_registry', 'insert', NO_ORG_CTX, + it('…and the ADR-0123 D2 write wall (the same choke point) refuses an org-less insert on it', async () => { + const { plugin } = await boot( + { sys_widget_registry: walledSchema('sys_widget_registry') }, + { entitlement: { platformGlobalObjects: ['sys_widget_registry'] } }, ); - expect(exempted).toBeNull(); // eslint-disable-next-line @typescript-eslint/no-explicit-any const walled = await (plugin as any).computeWriteTenantCheckFilter( - [PLAIN_MEMBER], 'crm_task', 'insert', NO_ORG_CTX, + [PLAIN_MEMBER], 'sys_widget_registry', 'insert', NO_ORG_CTX, ); expect(walled).toEqual({ ...RLS_DENY_FILTER }); }); - it('REGRESSION PIN — no declaration ⇒ byte-identical to today: both objects wall', async () => { - const { plugin, logger } = await boot(TWO_OBJECTS); - // eslint-disable-next-line @typescript-eslint/no-explicit-any - expect(await (plugin as any).getReadFilter('sys_widget_registry', MEMBER_CTX)).toEqual({ - organization_id: 'org-1', - }); - // eslint-disable-next-line @typescript-eslint/no-explicit-any - expect(await (plugin as any).getReadFilter('crm_task', MEMBER_CTX)).toEqual({ - organization_id: 'org-1', - }); - // And nothing to refuse means nothing to warn about. - const warned = logger.warn.mock.calls.map((c) => String(c[0])); - expect(warned.filter((m) => m.includes('org-scoping entitlement key'))).toEqual([]); - }); - - it('composes with, never replaces, the object-level channel: `tenancy.enabled:false` stays exempt with no deployment declaration', async () => { + it('the object as the registry registers it on the declaring deployment is NOT walled — its own clause answers', async () => { const { plugin } = await boot({ - sys_catalog: localSchema('sys_catalog', { tenancy: { enabled: false } }), + sys_widget_registry: plannedSchema('sys_widget_registry'), + crm_task: walledSchema('crm_task'), }); // eslint-disable-next-line @typescript-eslint/no-explicit-any - expect(await (plugin as any).getReadFilter('sys_catalog', MEMBER_CTX)).toBeUndefined(); + expect(await (plugin as any).getReadFilter('sys_widget_registry', MEMBER_CTX)).toBeUndefined(); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + expect(await (plugin as any).computeWriteTenantCheckFilter( + [PLAIN_MEMBER], 'sys_widget_registry', 'insert', NO_ORG_CTX, + )).toBeNull(); + // CONTROL — a sibling on the same deployment walls as today. + // eslint-disable-next-line @typescript-eslint/no-explicit-any + expect(await (plugin as any).getReadFilter('crm_task', MEMBER_CTX)).toEqual({ organization_id: 'org-1' }); }); - it('`single` posture: the declaration decides nothing (Layer 0 is inert either way)', async () => { - const { plugin } = await boot(TWO_OBJECTS, { - entitlement: { platformGlobalObjects: ['sys_widget_registry'] }, - tenancy: { posture: 'single' }, - }); + it('`single` posture: Layer 0 is inert on both, declaration or not', async () => { + const { plugin } = await boot( + { sys_widget_registry: walledSchema('sys_widget_registry'), crm_task: walledSchema('crm_task') }, + { entitlement: { platformGlobalObjects: ['sys_widget_registry'] }, tenancy: { posture: 'single' } }, + ); // eslint-disable-next-line @typescript-eslint/no-explicit-any expect(await (plugin as any).getReadFilter('sys_widget_registry', MEMBER_CTX)).toBeUndefined(); // eslint-disable-next-line @typescript-eslint/no-explicit-any @@ -183,71 +161,43 @@ describe('[#12699] platformGlobalObjects — the deployment carve-out', () => { }); }); -describe('[#12699] junk declarations are REFUSED loudly, never coerced (MembershipPolicy precedent)', () => { - it('a bare string is refused: warn names the key, and the named object STILL walls', async () => { - const { plugin, logger } = await boot(TWO_OBJECTS, { - entitlement: { platformGlobalObjects: 'sys_widget_registry' }, - }); - const warned = logger.warn.mock.calls.map((c) => String(c[0])); - expect(warned.some((m) => m.includes("'platformGlobalObjects' REFUSED"))).toBe(true); - // eslint-disable-next-line @typescript-eslint/no-explicit-any - expect(await (plugin as any).getReadFilter('sys_widget_registry', MEMBER_CTX)).toEqual({ - organization_id: 'org-1', - }); - }); - - it('one junk ENTRY voids the whole key (no partial honouring): a wildcard poisons the list', async () => { - const { plugin, logger } = await boot(TWO_OBJECTS, { - entitlement: { platformGlobalObjects: ['sys_widget_registry', '*'] }, - }); - const warned = logger.warn.mock.calls.map((c) => String(c[0])); - expect(warned.some((m) => m.includes("'platformGlobalObjects' REFUSED"))).toBe(true); - // The well-formed entry is NOT honoured — refusal is whole-key, fail closed. +describe('[#12699] refusals: this plugin warns only for the key it reads', () => { + it('a junk platformGlobalObjects is not this plugin\'s to warn about (the engine consumes and refuses it)', async () => { + const { plugin, logger } = await boot( + { sys_widget_registry: walledSchema('sys_widget_registry') }, + { entitlement: { platformGlobalObjects: 'sys_widget_registry' } }, + ); // eslint-disable-next-line @typescript-eslint/no-explicit-any - expect(await (plugin as any).getReadFilter('sys_widget_registry', MEMBER_CTX)).toEqual({ - organization_id: 'org-1', - }); - }); - - it('junk in one key does not void the other: a bad suppress flag leaves a valid exemption standing', async () => { - const { plugin, logger } = await boot(TWO_OBJECTS, { - entitlement: { - platformGlobalObjects: ['sys_widget_registry'], - suppressUnboundedOrgAdminGrant: 'yes', - }, - }); + await (plugin as any).getReadFilter('sys_widget_registry', MEMBER_CTX); const warned = logger.warn.mock.calls.map((c) => String(c[0])); - expect(warned.some((m) => m.includes("'suppressUnboundedOrgAdminGrant' REFUSED"))).toBe(true); - expect(warned.some((m) => m.includes("'platformGlobalObjects' REFUSED"))).toBe(false); - // eslint-disable-next-line @typescript-eslint/no-explicit-any - expect(await (plugin as any).getReadFilter('sys_widget_registry', MEMBER_CTX)).toBeUndefined(); + expect(warned.filter((m) => m.includes('org-scoping entitlement key'))).toEqual([]); }); - it('the refusal is warned ONCE per boot, not once per read', async () => { - const { plugin, logger } = await boot(TWO_OBJECTS, { - entitlement: { platformGlobalObjects: 42 }, - }); - // eslint-disable-next-line @typescript-eslint/no-explicit-any - await (plugin as any).getReadFilter('sys_widget_registry', MEMBER_CTX); - // eslint-disable-next-line @typescript-eslint/no-explicit-any - await (plugin as any).getReadFilter('crm_task', MEMBER_CTX); + it('a junk suppressUnboundedOrgAdminGrant is warned ONCE per boot, naming the key', async () => { + const { logger } = await boot( + { sys_widget_registry: walledSchema('sys_widget_registry') }, + { entitlement: { suppressUnboundedOrgAdminGrant: 'yes' } }, + ); const refusals = logger.warn.mock.calls .map((c) => String(c[0])) - .filter((m) => m.includes("'platformGlobalObjects' REFUSED")); + .filter((m) => m.includes("'suppressUnboundedOrgAdminGrant' REFUSED")); expect(refusals).toHaveLength(1); }); }); -describe('[#12699] the arming log surfaces the declaration', () => { - it('a walled boot with exemptions logs the carve-out (count + names)', async () => { - const { logger } = await boot(TWO_OBJECTS, { - entitlement: { - platformGlobalObjects: ['sys_widget_registry'], - suppressUnboundedOrgAdminGrant: true, +describe('[#12699] the arming log', () => { + it('announces the grant suppression; the platform-global list is the engine\'s to announce', async () => { + const { logger } = await boot( + { sys_widget_registry: walledSchema('sys_widget_registry') }, + { + entitlement: { + platformGlobalObjects: ['sys_widget_registry'], + suppressUnboundedOrgAdminGrant: true, + }, }, - }); + ); const infos = logger.info.mock.calls.map((c) => String(c[0])); - expect(infos.some((m) => m.includes('1 platform-global'))).toBe(true); expect(infos.some((m) => m.includes('suppresses the unbounded organization_admin auto-grant'))).toBe(true); + expect(infos.some((m) => m.includes('platform-global'))).toBe(false); }); }); diff --git a/packages/plugins/plugin-security/src/platform-global-no-organization-column.test.ts b/packages/plugins/plugin-security/src/platform-global-no-organization-column.test.ts new file mode 100644 index 00000000000..e124fe81e15 --- /dev/null +++ b/packages/plugins/plugin-security/src/platform-global-no-organization-column.test.ts @@ -0,0 +1,296 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [ADR-0131 D7] The #12699 deployment declaration made total, through a booted + * kernel: "an object a deployment declares platform-global gets no organization + * column on that deployment (the injected-columns plan reads the declaration), + * so Layer 0 and the driver agree by having nothing to scope". + * + * Real `ObjectKernel`, real `ObjectQLPlugin` over a real SQLite driver, the + * real `SecurityPlugin`, and a FIXTURE `org-scoping` provider: the only + * declarer of `platformGlobalObjects` is cloud's control plane (ADR-0131 C10), + * so nothing in this repository declares it. The fixture is composed AFTER + * the plugin that registers the objects — the order `serve` composes the + * organizations runtime in — and registers `org-scoping` in its own `init()`, + * declaring it in `providesServices` (ADR-0116 D2), as the open + * `OrganizationsPlugin` does. + * + * What was measured before the change, on this same harness: the declared + * object was registered WITH `organization_id`, its table was created with the + * column, `getReadFilter` answered no wall for it (the #12699 stand-down in + * `getObjectSecurityMeta`), and a system read carrying an organization was + * still scoped to that organization by the SQL driver — Layer 0 and the driver + * disagreeing about one object. + */ + +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { ObjectKernel, type Plugin, type PluginContext } from '@objectstack/core'; +import { ObjectQL, ObjectQLPlugin } from '@objectstack/objectql'; +import { SqlDriver } from '@objectstack/driver-sql'; +import { SysMember, SysOrganization, SysUser } from '@objectstack/platform-objects/identity'; +import type { PermissionSet } from '@objectstack/spec/security'; +import { SecurityPlugin } from './security-plugin.js'; + +const DECLARED = 'qa_widget_registry'; +const SIBLING = 'qa_invoice'; +const OBJECTS = [ + { name: SIBLING, label: 'Invoice', ownership: 'org', fields: { title: { type: 'text', label: 'Title' } } }, + { name: DECLARED, label: 'Widget registry', ownership: 'org', fields: { title: { type: 'text', label: 'Title' } } }, +]; + +/** An ordinary member of `org_acme`: no superuser bit, no positions. */ +const MEMBER = { userId: 'usr_member', tenantId: 'org_acme', positions: [], permissions: [], posture: 'MEMBER' }; +const SYSTEM = { isSystem: true, userId: 'usr_system' }; + +function sqliteDriverPlugin(): Plugin { + return { + name: 'test.driver.sqlite', + type: 'standard', + version: '1.0.0', + async init(ctx: PluginContext) { + ctx.registerService( + 'driver.default', + new SqlDriver({ client: 'better-sqlite3', connection: { filename: ':memory:' }, useNullAsDefault: true }), + ); + }, + }; +} + +function objectsPlugin(): Plugin { + return { + name: 'test.objects', + type: 'standard', + version: '1.0.0', + dependencies: ['com.objectstack.engine.objectql'], + async init(ctx: PluginContext) { + ctx.getService<{ register(m: unknown): unknown }>('manifest').register({ + id: 'test.objects', + namespace: 'qa', + version: '1.0.0', + type: 'plugin', + name: 'Objects', + objects: [SysUser, SysMember, SysOrganization, ...OBJECTS], + }); + }, + }; +} + +/** What the fixture provider saw of the declared object when it registered. */ +const seenAtProviderInit: { declaredHadColumn?: boolean } = {}; + +/** + * The fixture `org-scoping` provider. `phase: 'start'` registers it outside + * `init()` — the order ADR-0116 gives no guarantee for. + */ +function orgScopingFixture(declaration: Record, phase: 'init' | 'start' = 'init'): Plugin { + const service = { name: 'test.org-scoping', supportedPostures: ['group', 'isolated'], ...declaration }; + const register = (ctx: PluginContext) => { + const ql = ctx.getService('objectql'); + seenAtProviderInit.declaredHadColumn = !!(ql.getSchema(DECLARED) as any)?.fields?.organization_id; + ctx.registerService('org-scoping', service); + }; + return { + name: 'test.org-scoping-fixture', + type: 'standard', + version: '1.0.0', + dependencies: ['com.objectstack.engine.objectql'], + ...(phase === 'init' ? { providesServices: ['org-scoping'] } : {}), + async init(ctx: PluginContext) { + if (phase === 'init') register(ctx); + }, + async start(ctx: PluginContext) { + if (phase === 'start') register(ctx); + }, + } as Plugin; +} + +let kernel: ObjectKernel | undefined; +afterEach(async () => { + try { + await kernel?.shutdown(); + } catch { + /* a refused boot leaves the kernel stopped */ + } + kernel = undefined; + delete seenAtProviderInit.declaredHadColumn; +}); + +async function boot(provider?: Plugin) { + kernel = new ObjectKernel({ logger: { level: 'silent' } }); + await kernel.use(sqliteDriverPlugin()); + await kernel.use(new ObjectQLPlugin()); + await kernel.use(objectsPlugin()); + if (provider) await kernel.use(provider); + await kernel.use(new SecurityPlugin({ fallbackPermissionSet: 'member_default' })); + await kernel.bootstrap(); + const ql = kernel.getService('objectql'); + const security = kernel.getService('security'); + return { ql, security }; +} + +async function columnsOf(ql: ObjectQL, object: string): Promise { + const driver: any = (ql as any).getDriver(object); + return Object.keys(await driver.knex(object).columnInfo()).sort(); +} + +const refusalOf = (p: Promise) => + p.then( + () => undefined, + (error: unknown) => error as { code?: unknown; status?: unknown }, + ); + +describe('[ADR-0131 D7] the plan: a declared object has no organization column on the declaring deployment', () => { + it('registered and provisioned with no organization_id; CONTROL: the sibling on the same deployment keeps it', async () => { + const { ql } = await boot(orgScopingFixture({ platformGlobalObjects: [DECLARED] })); + + // Premise of the ordering: the provider registered AFTER the object did. + expect(seenAtProviderInit.declaredHadColumn).toBe(true); + + const declared: any = ql.getSchema(DECLARED); + expect(declared.fields.organization_id).toBeUndefined(); + expect(declared.systemFields).toEqual({ tenant: false }); + expect(await columnsOf(ql, DECLARED)).not.toContain('organization_id'); + + const sibling: any = ql.getSchema(SIBLING); + expect(sibling.fields.organization_id).toBeDefined(); + expect(await columnsOf(ql, SIBLING)).toContain('organization_id'); + }); + + it('absent key: every object keeps its column (byte-identical plan)', async () => { + const { ql } = await boot(orgScopingFixture({})); + for (const name of [DECLARED, SIBLING]) { + expect((ql.getSchema(name) as any).fields.organization_id).toBeDefined(); + expect((ql.getSchema(name) as any).systemFields).toBeUndefined(); + expect(await columnsOf(ql, name)).toContain('organization_id'); + } + }); + + it('junk key: refused loudly, and NO object loses its column', async () => { + kernel = new ObjectKernel({ logger: { level: 'silent' } }); + // The kernel hands its own logger to every plugin context. + const warn = vi.spyOn((kernel as any).logger, 'warn'); + await kernel.use(sqliteDriverPlugin()); + await kernel.use(new ObjectQLPlugin()); + await kernel.use(objectsPlugin()); + await kernel.use(orgScopingFixture({ platformGlobalObjects: DECLARED })); + await kernel.use(new SecurityPlugin({ fallbackPermissionSet: 'member_default' })); + await kernel.bootstrap(); + const ql = kernel.getService('objectql'); + + const warned = warn.mock.calls.map((c) => String(c[0])); + expect(warned.filter((m) => m.includes("'platformGlobalObjects' REFUSED"))).toHaveLength(1); + for (const name of [DECLARED, SIBLING]) { + expect((ql.getSchema(name) as any).fields.organization_id).toBeDefined(); + expect(await columnsOf(ql, name)).toContain('organization_id'); + } + }); +}); + +describe('[ADR-0131 D7] Layer 0 and the driver agree: nothing to scope on the declared object', () => { + it('Layer 0 composes no wall on the declared object and the driver has no column to scope; CONTROL: the sibling is walled at both', async () => { + const { ql, security } = await boot(orgScopingFixture({ platformGlobalObjects: [DECLARED] })); + await ql.insert(DECLARED, [{ title: 'a' }, { title: 'b' }], { context: SYSTEM } as never); + await ql.insert(SIBLING, [ + { title: 'mine', organization_id: 'org_acme' }, + { title: 'theirs', organization_id: 'org_globex' }, + ], { context: SYSTEM } as never); + + // Layer 0: no wall on the declared object, the organization wall on the sibling. + expect(await security.getReadFilter(DECLARED, MEMBER)).toBeUndefined(); + expect(await security.getReadFilter(SIBLING, MEMBER)).toEqual({ organization_id: 'org_acme' }); + + // The driver: a read carrying the member's organization reaches every row + // of the declared table (no column to scope on), and only the member's + // organization on the sibling. + const declaredRows = (await ql.find(DECLARED, { context: { ...SYSTEM, tenantId: 'org_acme' } } as never)) as any[]; + expect(declaredRows.map((r) => r.title).sort()).toEqual(['a', 'b']); + const siblingRows = (await ql.find(SIBLING, { context: { ...SYSTEM, tenantId: 'org_acme' } } as never)) as any[]; + expect(siblingRows.map((r) => r.title)).toEqual(['mine']); + // A member's predicate write through BOTH layers at once is pinned in + // `tenant-layer0-verdict-end-to-end.test.ts` (the sweep now matches every + // row of the declared table, where the driver used to narrow it). + }); + + it('a write naming organization_id on the declared object is refused (INVALID_FIELD 400); CONTROL: the sibling accepts it', async () => { + const { ql } = await boot(orgScopingFixture({ platformGlobalObjects: [DECLARED] })); + const refusal = await refusalOf( + ql.insert(DECLARED, { title: 'x', organization_id: 'org_acme' }, { context: SYSTEM } as never), + ); + expect(refusal?.code).toBe('INVALID_FIELD'); + expect(refusal?.status).toBe(400); + expect( + await refusalOf(ql.insert(SIBLING, { title: 'x', organization_id: 'org_acme' }, { context: SYSTEM } as never)), + ).toBeUndefined(); + }); +}); + +describe('[ADR-0131 D7] no stand-down path remains in plugin-security', () => { + /** + * The plugin over an engine whose registry never received the declaration + * (no `ObjectQLPlugin` installed it): the object still carries its column. + * The plugin reads `org-scoping` — and walls the object anyway, because the + * #12699 fold that stood Layer 0 down from the declaration is gone. The + * declaration takes effect through the plan or not at all. + */ + it('a declaration the plan never received does not unwall the object', async () => { + const engine = new ObjectQL(); + engine.registerDriver( + new SqlDriver({ client: 'better-sqlite3', connection: { filename: ':memory:' }, useNullAsDefault: true }), + true, + ); + await engine.init(); + engine.registerApp({ id: 'qa.objects', name: 'Objects', version: '1.0.0', type: 'plugin', objects: OBJECTS } as never); + const member: PermissionSet = { + name: 'member_default', + label: 'Member', + objects: { '*': { allowRead: true, allowCreate: true, allowEdit: true, allowDelete: true } }, + } as unknown as PermissionSet; + const services: Record = { + manifest: { register: () => undefined }, + objectql: engine, + metadata: { + get: async (_type: string, name: string) => engine.getSchema(name) ?? null, + list: async () => [member], + }, + 'org-scoping': { name: 'test.org-scoping', platformGlobalObjects: [DECLARED] }, + tenancy: { posture: 'isolated' }, + }; + const ctx: any = { + logger: { info: () => undefined, warn: () => undefined, error: () => undefined, debug: () => undefined }, + registerService: () => undefined, + getService: (name: string) => { + if (!(name in services)) throw new Error(`service not registered: ${name}`); + return services[name]; + }, + }; + const plugin = new SecurityPlugin({ fallbackPermissionSet: 'member_default' }); + await plugin.init(ctx); + await plugin.start(ctx); + try { + expect((engine.getSchema(DECLARED) as any).fields.organization_id).toBeDefined(); + expect(await (plugin as any).getReadFilter(DECLARED, MEMBER)).toEqual({ organization_id: 'org_acme' }); + } finally { + await engine.destroy(); + } + }); +}); + +describe('[ADR-0131 D7] the ordering is declared (ADR-0116), not assumed', () => { + it('a provider registered after the objects still reaches the plan — its init() precedes the engine\'s start()', async () => { + const { ql } = await boot(orgScopingFixture({ platformGlobalObjects: [DECLARED] })); + expect(seenAtProviderInit.declaredHadColumn).toBe(true); + expect((ql.getSchema(DECLARED) as any).fields.organization_id).toBeUndefined(); + expect(await columnsOf(ql, DECLARED)).not.toContain('organization_id'); + }); + + it('a provider that registers org-scoping outside init() — after the columns were planned — refuses the boot by name', async () => { + const failure = await boot(orgScopingFixture({ platformGlobalObjects: [DECLARED] }, 'start')).then( + () => undefined, + (error: unknown) => error as Error, + ); + expect(failure).toBeInstanceOf(Error); + expect(failure?.message).toContain("platformGlobalObjects declaration changed after the engine planned the columns"); + expect(failure?.message).toContain(DECLARED); + expect(failure?.message).toContain('providesServices'); + }); +}); diff --git a/packages/plugins/plugin-security/src/tenant-layer0-verdict-end-to-end.test.ts b/packages/plugins/plugin-security/src/tenant-layer0-verdict-end-to-end.test.ts index ca76f3e926b..2b8a9f2f302 100644 --- a/packages/plugins/plugin-security/src/tenant-layer0-verdict-end-to-end.test.ts +++ b/packages/plugins/plugin-security/src/tenant-layer0-verdict-end-to-end.test.ts @@ -16,11 +16,13 @@ * Two populations, one deployment, one caller: * - a walled tenant object ⇒ the event names the caller's organization — * the wall's equality term, recorded and read; - * - a deployment-exempted object (#12699 `platformGlobalObjects`) ⇒ the - * event names NOTHING — the #15706 population: the wall composed no - * predicate, recorded `none`, and the producer (which reads nothing but - * the recorded verdict) omits the key. The former producer stamped the - * caller's organization here from the context — the wrong key. + * - a deployment-declared platform-global object (#12699 + * `platformGlobalObjects`, made total by ADR-0131 D7: no organization + * column on the declaring deployment) ⇒ the event names NOTHING — the + * #15706 population: the wall composed no predicate, recorded `none`, and + * the producer (which reads nothing but the recorded verdict) omits the + * key. The former producer stamped the caller's organization here from + * the context — the wrong key. * * Harness lineage: `walled-platform-bucket-diagnostic.test.ts` (the real * engine over SQLite) and `deployment-platform-global-exemption.test.ts` (the @@ -79,6 +81,10 @@ async function boot(opts: { platformGlobalObjects?: string[] } = {}) { true, ); await engine.init(); + // [ADR-0131 D7] The deployment's declaration is the injected-columns plan's + // input, installed on the registry before the objects register — what + // `ObjectQLPlugin.start()` does on a booted kernel (this harness has none). + if (opts.platformGlobalObjects) engine.registry.setDeploymentPlatformGlobalObjects(opts.platformGlobalObjects); engine.registerApp({ id: 'com.objectstack.qa.layer0-verdict-15813', name: 'Layer 0 verdict weld', @@ -149,13 +155,14 @@ describe('[#15813] end to end — the plugin records the verdict, the engine pub expect(event.organizationId).toBe('org_acme'); }); - it('a deployment-exempted object under the SAME wall and caller: the key is ABSENT — the #15706 population, closed', async () => { + it('a deployment-declared object under the SAME wall and caller: the key is ABSENT — the #15706 population, closed', async () => { const { engine, published } = await boot({ platformGlobalObjects: ['qa_widget_registry'] }); - // Rows across two organizations: the wall composes nothing on this object, - // so the sweep reaches both — exactly the batch a wrong key would mislabel. + // Rows written by two organizations' callers. On the declaring deployment + // the object has no organization column, so the rows carry none — nothing + // for the wall OR the driver to scope on. await seed(engine, 'qa_widget_registry', [ - { status: 'open', amount: '1', organization_id: 'org_acme' }, - { status: 'open', amount: '2', organization_id: 'org_globex' }, + { status: 'open', amount: '1' }, + { status: 'open', amount: '2' }, ]); published.length = 0; @@ -164,20 +171,14 @@ describe('[#15813] end to end — the plugin records the verdict, the engine pub const bulk = published.filter((e) => e.type === 'data.records.updated'); expect(bulk).toHaveLength(1); const event = BulkDataEventSchema.parse(bulk[0].payload); - // Ground truth, past every scope: both rows are in the table, one per - // organization — the population a wrong key would have mislabelled. + // Ground truth, past every scope: the table has no organization column. const driver: any = (engine as any).getDriver('qa_widget_registry'); - const raw = await driver.knex('qa_widget_registry').select('organization_id'); - expect(raw.map((r: any) => r.organization_id).sort()).toEqual(['org_acme', 'org_globex']); - // Measured, not assumed: the sweep matched ONE row. Layer 0 composed no - // wall here (the carve-out), but the engine still threads the caller's - // `tenantId` to the driver as `DriverOptions.tenantId` and the SQL driver - // scopes on it — the D8 driver leg, which no #12699 declaration reaches - // (filed as its own finding; not this seam's to change). So the batch - // was narrower than Layer 0 alone implies, and the ABSENT key below is - // an under-delivery in the safe direction — never the wrong key the - // former producer stamped from the context on exactly this object. - expect(event.matched).toBe(1); + expect(Object.keys(await driver.knex('qa_widget_registry').columnInfo())).not.toContain('organization_id'); + // [ADR-0131 D7] Layer 0 and the driver AGREE: the sweep matched BOTH rows. + // Before D7 the wall stood down here while the SQL driver went on scoping + // the caller's `tenantId` (the D8 driver leg the #12699 stand-down never + // reached), so this sweep matched one row — measured on this harness. + expect(event.matched).toBe(2); expect(hasOrgKey(bulk[0].payload)).toBe(false); expect(event.organizationId).toBeUndefined(); }); diff --git a/packages/plugins/plugin-security/src/tenant-layer0-verdict-on-operation.test.ts b/packages/plugins/plugin-security/src/tenant-layer0-verdict-on-operation.test.ts index 671549c154c..72fa1e7a9ed 100644 --- a/packages/plugins/plugin-security/src/tenant-layer0-verdict-on-operation.test.ts +++ b/packages/plugins/plugin-security/src/tenant-layer0-verdict-on-operation.test.ts @@ -13,8 +13,10 @@ * the verdict AND the injected `ast.where` off ONE middleware pass, so a * verdict that disagreed with the predicate would fail here first. * 2. The populations the engine could never answer are answered HERE, from - * inputs only this plugin sees: the deployment's #12699 carve-out (`none` - * under an armed wall — the #15706 population), a `PLATFORM_ADMIN` rung + * inputs only this plugin sees: the deployment's #12699 platform-global + * object (`none` under an armed wall — the #15706 population; since + * ADR-0131 D7 it reaches the wall as the object's own + * `systemFields.tenant: false`), a `PLATFORM_ADMIN` rung * on a PUBLIC tenant object (`organization` — the wall stands), a * `PLATFORM_ADMIN` on a posture-permitting object (`none` — the wall was * crossed), and a hand-built context carrying no rung whose exemption @@ -92,7 +94,14 @@ const withFields = ( const SCHEMAS: Record> = { crm_task: localSchema('crm_task'), - sys_widget_registry: localSchema('sys_widget_registry'), + // [ADR-0131 D7] As the registry registers it on a deployment that declares it + // platform-global (#12699 made total): no organization column, and declaring + // `systemFields.tenant: false`. Unregistered anywhere else in this file. + sys_widget_registry: { + name: 'sys_widget_registry', + systemFields: { tenant: false }, + fields: { status: { type: 'text', label: 'Status' } }, + }, sys_catalog: localSchema('sys_catalog', { tenancy: { enabled: false } }), crm_secret: localSchema('crm_secret', { access: { default: 'private' } }), @@ -372,7 +381,7 @@ describe('[#15813] the middleware records the Layer 0 verdict it composed — on }); describe('[#15813] the populations the engine could never answer are answered where the wall is computed', () => { - it('the deployment\'s #12699 carve-out: an exempted object under an armed wall records `none` — the #15706 population', async () => { + it('the deployment\'s #12699 declaration (no organization column, ADR-0131 D7) under an armed wall records `none` — the #15706 population', async () => { const { middleware } = await boot({ entitlement: { platformGlobalObjects: ['sys_widget_registry'] } }); const exempted = sweep('sys_widget_registry', 'update', MEMBER_CTX); await middleware(exempted, engineTerminal(exempted)); diff --git a/packages/spec/src/data/injected-system-columns.test.ts b/packages/spec/src/data/injected-system-columns.test.ts index 8f1ec718386..5989446449c 100644 --- a/packages/spec/src/data/injected-system-columns.test.ts +++ b/packages/spec/src/data/injected-system-columns.test.ts @@ -151,3 +151,66 @@ describe('resolveInjectedSystemColumns — the injected columns, so author-time expect(resolveInjectedSystemColumns({}).owner).toBe(true); }); }); + +// --------------------------------------------------------------------------- +// [ADR-0131 D7] The #12699 deployment declaration made total: the plan's one +// deployment input. An object the deployment declares platform-global gets no +// organization column on that deployment; nothing else moves, and an absent +// input is the authored plan, byte for byte. +// --------------------------------------------------------------------------- +describe('resolveInjectedSystemColumns — the deployment\'s platform-global declaration (ADR-0131 D7)', () => { + const declared = { name: 'sys_widget_registry', fields: { title: { type: 'text' } } }; + const sibling = { name: 'crm_task', fields: { title: { type: 'text' } } }; + const deployment = { platformGlobalObjects: new Set(['sys_widget_registry']) }; + + it('withholds organization_id from a declared object, and only that column', () => { + const authored = resolveInjectedSystemColumns(declared); + const plan = resolveInjectedSystemColumns(declared, deployment); + expect(authored.tenant).toBe(true); + expect(plan.tenant).toBe(false); + expect(plan.names.has('organization_id')).toBe(false); + // Every other decision is the authored one. + expect({ audit: plan.audit, owner: plan.owner, owningBusinessUnit: plan.owningBusinessUnit }).toEqual({ + audit: authored.audit, + owner: authored.owner, + owningBusinessUnit: authored.owningBusinessUnit, + }); + expect([...plan.names].sort()).toEqual([...authored.names].filter((n) => n !== 'organization_id').sort()); + }); + + it('CONTROL: a non-declared object on the same deployment keeps its column', () => { + const plan = resolveInjectedSystemColumns(sibling, deployment); + expect(plan.tenant).toBe(true); + expect(plan.names.has('organization_id')).toBe(true); + }); + + it('accepts the declaration as an array as well as a set', () => { + expect(resolveInjectedSystemColumns(declared, { platformGlobalObjects: ['sys_widget_registry'] }).tenant).toBe(false); + }); + + it.each([ + ['no second argument', undefined], + ['an absent key', {}], + ['an empty set', { platformGlobalObjects: new Set() }], + ['an empty list', { platformGlobalObjects: [] as string[] }], + ])('%s: every plan is byte-identical to the authored one', (_label, input) => { + for (const def of [ + declared, + sibling, + { ...declared, systemFields: { tenant: false } }, + { ...declared, tenancy: { enabled: false } }, + { ...declared, systemFields: false }, + { ...declared, managedBy: 'better-auth' }, + { ...sibling, ownership: 'business_unit' }, + {}, + ]) { + const authored = resolveInjectedSystemColumns(def); + const plan = resolveInjectedSystemColumns(def, input); + expect({ ...plan, names: [...plan.names] }).toEqual({ ...authored, names: [...authored.names] }); + } + }); + + it('a nameless record is never declared, whatever the declaration names', () => { + expect(resolveInjectedSystemColumns({}, { platformGlobalObjects: [''] }).tenant).toBe(true); + }); +}); From cd512526c0c412511bef799b9862de48874e87a3 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 8 Oct 2026 14:58:45 +0000 Subject: [PATCH 3/9] refactor(objectql): the registry reads the declaration only through the plan (ADR-0131 D7) Claude-Session: https://claude.ai/code/session_01LAi5BVvQNiYzepSAcsoFLK Co-authored-by: Claude --- packages/objectql/src/registry.ts | 35 +++++++++++++++++++++++-------- 1 file changed, 26 insertions(+), 9 deletions(-) diff --git a/packages/objectql/src/registry.ts b/packages/objectql/src/registry.ts index 1a47fe3cda1..b0535674335 100644 --- a/packages/objectql/src/registry.ts +++ b/packages/objectql/src/registry.ts @@ -3009,11 +3009,15 @@ export class SchemaRegistry { } for (const contributors of this.objectContributors.values()) { for (const contributor of contributors) { - const name = (contributor.definition as { name?: unknown })?.name; - if (typeof name !== 'string' || !this.deploymentPlatformGlobalObjects.has(name)) continue; const before = contributor.definition; + const name = (before as { name?: unknown })?.name; + if (typeof name !== 'string') continue; const orgField = (before.fields as Record | undefined)?.organization_id; - if (orgField !== undefined && !isInjectedColumnDefinition(orgField, TENANT_SCOPE_FIELD_DEF)) { + const authoredColumn = orgField !== undefined && !isInjectedColumnDefinition(orgField, TENANT_SCOPE_FIELD_DEF); + // Asked of the body WITHOUT its column: the plan decides whether the + // deployment withholds one, and the column's presence is not an input. + if (!this.deploymentWithholdsTenant(withoutField(before, 'organization_id'))) continue; + if (authoredColumn) { keptAuthoredColumn.add(name); continue; } @@ -3066,12 +3070,10 @@ export class SchemaRegistry { * exits ({@link materializeServedObjectOnto}) reach the same answer. */ private applyDeploymentTenancy(schema: ServiceObject): ServiceObject { - const name = (schema as { name?: unknown })?.name; - if (typeof name !== 'string' || !this.deploymentPlatformGlobalObjects.has(name)) return schema; + if (!this.deploymentWithholdsTenant(schema)) return schema; const fields = schema.fields as Record | undefined; const orgField = fields?.organization_id; if (orgField !== undefined && !isInjectedColumnDefinition(orgField, TENANT_SCOPE_FIELD_DEF)) return schema; - if (orgField === undefined && !resolveInjectedSystemColumns(schema).tenant) return schema; const sf = (schema as { systemFields?: unknown }).systemFields; const out = withoutField(schema, 'organization_id') as ServiceObject & { systemFields?: unknown }; return { @@ -3101,8 +3103,6 @@ export class SchemaRegistry { */ private stripDeploymentTenancyFrom(base: T): T { if (base === null || typeof base !== 'object') return base; - const name = (base as { name?: unknown }).name; - if (typeof name !== 'string' || !this.deploymentPlatformGlobalObjects.has(name)) return base; const sf = (base as { systemFields?: unknown }).systemFields; if (!sf || typeof sf !== 'object' || Array.isArray(sf)) return base; if ((sf as { tenant?: unknown }).tenant !== false) return base; @@ -3110,7 +3110,24 @@ export class SchemaRegistry { const out = { ...(base as Record) }; if (Object.keys(restSystemFields).length === 0) delete out.systemFields; else out.systemFields = restSystemFields; - return out as unknown as T; + // Redundant with the derivation only where the deployment's plan withholds + // the column from the body without the record — i.e. on a declared object. + return this.deploymentWithholdsTenant(out as unknown as ServiceObject) ? (out as unknown as T) : base; + } + + /** + * [ADR-0131 D7] Does the deployment's declaration — and nothing else — plan + * this body with no tenant column? The ONE reading of the declaration in + * this registry: the spec's plan answers it, with and without the + * deployment input, so the registry never re-derives which objects are + * declared. + */ + private deploymentWithholdsTenant(schema: ServiceObject): boolean { + if (this.deploymentPlatformGlobalObjects.size === 0) return false; + return ( + resolveInjectedSystemColumns(schema).tenant && + !resolveInjectedSystemColumns(schema, { platformGlobalObjects: this.deploymentPlatformGlobalObjects }).tenant + ); } /** From c4506eeee43d8decf3ec195ef4484087b17eb55f Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 8 Oct 2026 15:09:24 +0000 Subject: [PATCH 4/9] chore(spec): ADR-0087 entry and changeset for the platform-global no-column plan (ADR-0131 D7) Claude-Session: https://claude.ai/code/session_01LAi5BVvQNiYzepSAcsoFLK Co-authored-by: Claude --- ...-platform-global-no-organization-column.md | 30 +++++++++ ...obal-object-organization-column-retired.ts | 50 +++++++++++++++ packages/spec/src/migrations/registry.ts | 61 +++++++++++++++++++ 3 files changed, 141 insertions(+) create mode 100644 .changeset/15207-platform-global-no-organization-column.md create mode 100644 packages/spec/src/migrations/entries/semantic/18.platform-global-object-organization-column-retired.ts diff --git a/.changeset/15207-platform-global-no-organization-column.md b/.changeset/15207-platform-global-no-organization-column.md new file mode 100644 index 00000000000..e49f98ca100 --- /dev/null +++ b/.changeset/15207-platform-global-no-organization-column.md @@ -0,0 +1,30 @@ +--- +'@objectstack/objectql': major +'@objectstack/plugin-security': major +'@objectstack/spec': minor +'@objectstack/core': minor +'@objectstack/organizations': patch +--- + +feat(objectql,plugin-security)!: an object a deployment declares platform-global gets no organization column on that deployment — the #12699 declaration made total (ADR-0131 D7) + +Clause-②: yes (narrowing) + + + +**BREAKING** on a deployment whose `org-scoping` service declares `platformGlobalObjects`. Nothing changes on any other deployment. + +ADR-0131 D7: "an object a deployment declares platform-global gets no organization column on that deployment (the injected-columns plan reads the declaration), so Layer 0 and the driver agree by having nothing to scope." Until now the declaration stood the organization wall down for the object while the object kept its `organization_id` column, so the SQL driver went on scoping a read by the caller's organization that the wall no longer scoped. + +- **The plan** (`@objectstack/spec`): `resolveInjectedSystemColumns(def, deployment?)` takes the deployment's validated `platformGlobalObjects` as an optional second argument. An object it names is planned with no `organization_id`, and nothing else in its plan moves. With no second argument, or an empty list, the plan is the same as before. Author-time callers (the linter, the import mapper, the tenancy census) have no deployment and pass none. +- **The registry** (`@objectstack/objectql`): `ObjectQLPlugin` reads the declaration at the top of `start()`, before the first schema sync, and installs it with the new `SchemaRegistry.setDeploymentPlatformGlobalObjects()`. A declared object is registered with no `organization_id`, no tenant index and `systemFields: { tenant: false }`, and its table is created without the column. An object registered earlier, inside another plugin's `init()`, is re-planned at that moment, before its table exists. The `/meta` read exits serve the same shape. On the way back in, the write path removes the recorded `tenant: false`, so a Studio save stores the body the author wrote. The plugin logs the declared list once at boot. +- **The order** (ADR-0116): every `init()` completes before any `start()`, so a provider that registers `org-scoping` in its `init()` has registered by the time the engine reads it. `OrganizationsPlugin` now declares `providesServices: ['org-scoping']` (`@objectstack/organizations`). A provider that registers the service later, with a different declaration than the one the columns were planned from, fails the boot at `kernel:ready` with an error that names both lists and the fix. +- **The stand-down is retired** (`@objectstack/plugin-security`): `getObjectSecurityMeta` no longer reads `platformGlobalObjects`. A declared object reaches the security layer as its own `systemFields.tenant: false`, the same way every deployment-level object does: Layer 0 composes no organization predicate on it, a wildcard `organization_id` policy does not apply to it, and the ADR-0123 D2 no-active-organization write refusal does not fire on it. The `[security] deployment declares N platform-global object(s)` boot line is gone; the engine logs the list instead. +- **One reader** (`@objectstack/core`): `readDeploymentOrgScopingEntitlement` moved from plugin-security into `@objectstack/core` and is exported there, with its rules unchanged. An absent key declares nothing. A malformed key is refused whole: no object is declared, and the consumer of that key warns once. The engine warns for a malformed `platformGlobalObjects`, and plugin-security for a malformed `suppressUnboundedOrgAdminGrant`. `suppressUnboundedOrgAdminGrant` and its behaviour are unchanged. + +**What moves for consumers.** + +- **On the declaring deployment**, a declared object has no `organization_id`. FROM an authored filter, list-view column, report grouping, formula or seed key naming `organization_id` on that object, TO the same reference with that field removed. A write that still names it is refused `INVALID_FIELD`, and a filter on it `INVALID_FILTER`. The object is governed by object permission, not by the organization wall: a reader the object permission admits reads every row, where the SQL driver used to narrow a read to the caller's organization and the rows with no organization. +- **An object that declares its own `organization_id`** keeps that column, because it is the author's and not the platform's, and the organization wall keeps scoping it. The engine warns once at boot and names the object. Remove the authored field, or drop the object from the declaration. +- **Author-time tools** have no deployment, so they still list `organization_id` among a declared object's columns. Only the declaring deployment differs, and it refuses the name at runtime. +- **Existing databases: nothing moves automatically** (ADR-0131 D14). Schema sync is additive, so on a declaring deployment the physical `organization_id` column stays and the boot drift report names it orphaned. The operator removes it once the rows need nothing from it. No boot step reads or writes it. diff --git a/packages/spec/src/migrations/entries/semantic/18.platform-global-object-organization-column-retired.ts b/packages/spec/src/migrations/entries/semantic/18.platform-global-object-organization-column-retired.ts new file mode 100644 index 00000000000..7c1deecdf15 --- /dev/null +++ b/packages/spec/src/migrations/entries/semantic/18.platform-global-object-organization-column-retired.ts @@ -0,0 +1,50 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +import type { SemanticMigration } from '../../types.js'; + +// #15207 (ADR-0131 D7, C6 item 4) — the #12699 deployment declaration made +// total: an object a deployment declares platform-global +// (OrgScopingEntitlement.platformGlobalObjects) loses its injected organization +// column ON THAT DEPLOYMENT. A column retirement keyed on a deployment fact, not +// a spec-key retirement: no authorable key moves, so nothing lands in +// RETIRED_KEYS_BY_MAJOR and no D2 conversion exists to pair with. The stand-down +// semantics it replaces retire with it. Existing rows keep the orphaned column +// until the operator removes it (ADR-0131 D14; C7's inventory and the +// declarer's own backfill), which this entry does not perform. +export const entry: SemanticMigration = { + id: 'platform-global-object-organization-column-retired', + // No backticks in `surface` — build-upgrade-guide.ts renders it inside a + // code span AND a table cell. + surface: + 'OrgScopingEntitlement.platformGlobalObjects — an object a deployment declares platform-global no ' + + 'longer keeps its injected organization_id column with the organization wall stood down over it; on ' + + 'that deployment the injected-columns plan withholds the column, and the engine registers the object ' + + 'with no organization_id and declaring systemFields.tenant false', + replacement: + 'Nothing to rewrite where no deployment declares the object. On the declaring deployment, the declared ' + + 'object has no `organization_id`: rewrite any authored filter, list-view column, report grouping, ' + + 'formula or seed key that names `organization_id` on it, or drop it; a write naming it is refused ' + + '`INVALID_FIELD` and a filter `INVALID_FILTER`. The object is governed by object permission, not by ' + + 'the organization wall', + reason: + 'ADR-0131 D7: "an object a deployment declares platform-global gets no organization column on that ' + + 'deployment (the injected-columns plan reads the declaration), so Layer 0 and the driver agree by ' + + 'having nothing to scope". The #12699 declaration used to stand the security layer\'s organization ' + + 'wall down for the object while the column stayed, so the SQL driver went on scoping a read by the ' + + 'caller organization that the wall had stopped scoping — measured on a booted kernel with a fixture ' + + 'provider, before the change. ADR-0131 retires that stand-down ("replaced by D7\'s no-column"). The ' + + 'engine reads the declaration at its plugin start(), before the first schema sync: every plugin ' + + 'init() has completed by then (ADR-0116, the Phase 1/2 split) and the org-scoping provider registers ' + + 'the service in its init(), declared in providesServices, so an object registered earlier is ' + + 're-planned before its table is created. An absent declaration leaves every object\'s plan ' + + 'byte-identical; a malformed one is refused loudly and declares nothing. An object that declares its ' + + 'own organization_id keeps it and stays walled on it. Existing databases: schema sync is additive, so ' + + 'the physical column stays on a declaring deployment and the boot drift report names it orphaned; ' + + 'the operator removes it, and nothing moves at boot.', + acceptanceCriteria: + 'On a deployment whose org-scoping service declares an object platform-global, the object is registered ' + + 'and provisioned with no `organization_id`, the security layer composes no organization wall on it, ' + + 'and a read carrying the caller organization reaches every row of its table; a non-declared object ' + + 'on the same deployment keeps its column and its wall. With no declaration, or a malformed one, every ' + + 'object keeps its column.', +}; diff --git a/packages/spec/src/migrations/registry.ts b/packages/spec/src/migrations/registry.ts index dd6e6d04a3d..69c5ff54560 100644 --- a/packages/spec/src/migrations/registry.ts +++ b/packages/spec/src/migrations/registry.ts @@ -6181,6 +6181,21 @@ const STEP18_RATIONALE: readonly RationaleFragment[] = [ + 'at parse rather than rewritten. Its D3 record is the semantic entry ' + '`permission-rls-tags-retired`.', }, + { + id: 'platform-global-object-organization-column-retired', + order: 89, + text: + 'It also makes the #12699 deployment declaration total (ADR-0131 D7): an object a deployment ' + + 'declares platform-global in its `org-scoping` service\'s `platformGlobalObjects` gets no ' + + 'organization column on that deployment, because the injected-columns plan reads the declaration, ' + + 'so the organization wall and the driver agree by having nothing to scope. The engine reads it at ' + + 'its plugin start, before the first schema sync, once every plugin init has run, and re-plans the ' + + 'objects registered before it; the security layer\'s stand-down for such an object retires with ' + + 'it. An absent declaration changes nothing, and a malformed one is refused and declares nothing. ' + + 'Nothing moves automatically: a declaring deployment\'s existing table keeps the column as an ' + + 'orphan the boot drift report names. The D3 record is the ' + + '`platform-global-object-organization-column-retired` semantic entry.', + }, { id: 'plugin-manifest-contributes-dead-members-retired', order: 16, @@ -16999,6 +17014,52 @@ const step18: MigrationStep = { + 'audience has that audience in `positions`, and every compliance report, audit filter or ' + 'review process that assumed policy tags names the mechanism it actually uses instead.', }, + // #15207 (ADR-0131 D7, C6 item 4) — the #12699 deployment declaration made + // total: an object a deployment declares platform-global + // (OrgScopingEntitlement.platformGlobalObjects) loses its injected organization + // column ON THAT DEPLOYMENT. A column retirement keyed on a deployment fact, not + // a spec-key retirement: no authorable key moves, so nothing lands in + // RETIRED_KEYS_BY_MAJOR and no D2 conversion exists to pair with. The stand-down + // semantics it replaces retire with it. Existing rows keep the orphaned column + // until the operator removes it (ADR-0131 D14; C7's inventory and the + // declarer's own backfill), which this entry does not perform. + { + id: 'platform-global-object-organization-column-retired', + // No backticks in `surface` — build-upgrade-guide.ts renders it inside a + // code span AND a table cell. + surface: + 'OrgScopingEntitlement.platformGlobalObjects — an object a deployment declares platform-global no ' + + 'longer keeps its injected organization_id column with the organization wall stood down over it; on ' + + 'that deployment the injected-columns plan withholds the column, and the engine registers the object ' + + 'with no organization_id and declaring systemFields.tenant false', + replacement: + 'Nothing to rewrite where no deployment declares the object. On the declaring deployment, the declared ' + + 'object has no `organization_id`: rewrite any authored filter, list-view column, report grouping, ' + + 'formula or seed key that names `organization_id` on it, or drop it; a write naming it is refused ' + + '`INVALID_FIELD` and a filter `INVALID_FILTER`. The object is governed by object permission, not by ' + + 'the organization wall', + reason: + 'ADR-0131 D7: "an object a deployment declares platform-global gets no organization column on that ' + + 'deployment (the injected-columns plan reads the declaration), so Layer 0 and the driver agree by ' + + 'having nothing to scope". The #12699 declaration used to stand the security layer\'s organization ' + + 'wall down for the object while the column stayed, so the SQL driver went on scoping a read by the ' + + 'caller organization that the wall had stopped scoping — measured on a booted kernel with a fixture ' + + 'provider, before the change. ADR-0131 retires that stand-down ("replaced by D7\'s no-column"). The ' + + 'engine reads the declaration at its plugin start(), before the first schema sync: every plugin ' + + 'init() has completed by then (ADR-0116, the Phase 1/2 split) and the org-scoping provider registers ' + + 'the service in its init(), declared in providesServices, so an object registered earlier is ' + + 're-planned before its table is created. An absent declaration leaves every object\'s plan ' + + 'byte-identical; a malformed one is refused loudly and declares nothing. An object that declares its ' + + 'own organization_id keeps it and stays walled on it. Existing databases: schema sync is additive, so ' + + 'the physical column stays on a declaring deployment and the boot drift report names it orphaned; ' + + 'the operator removes it, and nothing moves at boot.', + acceptanceCriteria: + 'On a deployment whose org-scoping service declares an object platform-global, the object is registered ' + + 'and provisioned with no `organization_id`, the security layer composes no organization wall on it, ' + + 'and a read carrying the caller organization reaches every row of its table; a non-declared object ' + + 'on the same deployment keeps its column and its wall. With no declaration, or a malformed one, every ' + + 'object keeps its column.', + }, { id: 'platform-timezone-columns-iana-domain-refused', surface: From 4931d21596188b21784c7b32f5faa3fd8282a2a2 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 8 Oct 2026 15:18:43 +0000 Subject: [PATCH 5/9] test(objectql): census rows for the two new injected-column seams (ADR-0131 D7) Claude-Session: https://claude.ai/code/session_01LAi5BVvQNiYzepSAcsoFLK Co-authored-by: Claude --- .../src/federated-injected-column-readers.test.ts | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/packages/objectql/src/federated-injected-column-readers.test.ts b/packages/objectql/src/federated-injected-column-readers.test.ts index 2e8c39f4b5f..beae374ca8c 100644 --- a/packages/objectql/src/federated-injected-column-readers.test.ts +++ b/packages/objectql/src/federated-injected-column-readers.test.ts @@ -316,6 +316,18 @@ const READERS: Record = { disposition: 'not-a-read', why: 'reads an index declaration, not a row', }, + 'registry.ts#applyDeploymentTenancy :: organization_id': { + disposition: 'not-a-read', + why: + "drops the platform's own injected definition from a declared object's schema (ADR-0131 D7); " + + 'it reads a definition, never a row', + }, + 'registry.ts#setDeploymentPlatformGlobalObjects :: organization_id': { + disposition: 'not-a-read', + why: + 'tells an authored organization_id from the injection while re-planning registered schemas ' + + '(ADR-0131 D7); it reads definitions, never a row', + }, 'tenancy/system-write-organization.ts# :: organization_id': { disposition: 'not-a-read', why: 'the declaration of the default tenant column name', From 3b1f3f9019cdd14f4fcb119da95dd50909938b52 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 8 Oct 2026 16:15:18 +0000 Subject: [PATCH 6/9] chore(objectql): record the registry pin's engine doubles in the pinned ledger Claude-Session: https://claude.ai/code/session_01LAi5BVvQNiYzepSAcsoFLK Co-authored-by: Claude --- scripts/engine-double-contract.pinned.json | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/scripts/engine-double-contract.pinned.json b/scripts/engine-double-contract.pinned.json index bbbb4a55c3c..55006d8ec7d 100644 --- a/scripts/engine-double-contract.pinned.json +++ b/scripts/engine-double-contract.pinned.json @@ -2321,6 +2321,21 @@ "verb": "update", "pinned": 1 }, + { + "file": "packages/objectql/src/registry-deployment-platform-global.test.ts", + "verb": "delete", + "pinned": 1 + }, + { + "file": "packages/objectql/src/registry-deployment-platform-global.test.ts", + "verb": "findOne", + "pinned": 1 + }, + { + "file": "packages/objectql/src/registry-deployment-platform-global.test.ts", + "verb": "update", + "pinned": 1 + }, { "file": "packages/objectql/src/registry-tenant-index-author-declared-column.test.ts", "verb": "delete", From 686f8d9a74f51338ab6dd78d7478b67aac22c1f3 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 8 Oct 2026 16:28:13 +0000 Subject: [PATCH 7/9] test(plugin-security): type the security service lookup in the no-column pin Claude-Session: https://claude.ai/code/session_01LAi5BVvQNiYzepSAcsoFLK Co-authored-by: Claude --- .../src/platform-global-no-organization-column.test.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/plugins/plugin-security/src/platform-global-no-organization-column.test.ts b/packages/plugins/plugin-security/src/platform-global-no-organization-column.test.ts index e124fe81e15..4fcf0db7ab3 100644 --- a/packages/plugins/plugin-security/src/platform-global-no-organization-column.test.ts +++ b/packages/plugins/plugin-security/src/platform-global-no-organization-column.test.ts @@ -124,7 +124,8 @@ async function boot(provider?: Plugin) { await kernel.use(new SecurityPlugin({ fallbackPermissionSet: 'member_default' })); await kernel.bootstrap(); const ql = kernel.getService('objectql'); - const security = kernel.getService('security'); + // The one member of the `security` service these pins read. + const security = kernel.getService<{ getReadFilter(object: string, context: unknown): Promise }>('security'); return { ql, security }; } From 5322c2b7557fe7ec1bed04ad86527c58cffa0c71 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 8 Oct 2026 16:59:30 +0000 Subject: [PATCH 8/9] refactor(objectql): drop the unused platform-global accessor Claude-Session: https://claude.ai/code/session_01LAi5BVvQNiYzepSAcsoFLK Co-authored-by: Claude --- packages/objectql/src/registry.ts | 5 ----- 1 file changed, 5 deletions(-) diff --git a/packages/objectql/src/registry.ts b/packages/objectql/src/registry.ts index b0535674335..f487eae62f3 100644 --- a/packages/objectql/src/registry.ts +++ b/packages/objectql/src/registry.ts @@ -3040,11 +3040,6 @@ export class SchemaRegistry { return { replanned: [...replanned].sort(), keptAuthoredColumn: [...keptAuthoredColumn].sort() }; } - /** [ADR-0131 D7] The objects THIS deployment declares platform-global (read-only view). */ - getDeploymentPlatformGlobalObjects(): ReadonlySet { - return this.deploymentPlatformGlobalObjects; - } - /** * [ADR-0131 D7] Record the deployment's platform-global declaration on one * base-layer body: a declared object carries no `organization_id` here and From 6b055079e8f2e874d4ec46e5249246b50bc5ee1f Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 8 Oct 2026 18:20:00 +0000 Subject: [PATCH 9/9] fix(spec): the platform-global entry's printed guidance names no tracker id (ADR-0131 D7) The step-18 D3 entry's reason said the declaration by its tracker number; os migrate meta prints that field, and author-shown guidance carries none. The step rationale fragment says it the same way. Claude-Session: https://claude.ai/code/session_01LAi5BVvQNiYzepSAcsoFLK Co-authored-by: Claude --- .../18.platform-global-object-organization-column-retired.ts | 2 +- packages/spec/src/migrations/registry.ts | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/spec/src/migrations/entries/semantic/18.platform-global-object-organization-column-retired.ts b/packages/spec/src/migrations/entries/semantic/18.platform-global-object-organization-column-retired.ts index 7c1deecdf15..7e15c488507 100644 --- a/packages/spec/src/migrations/entries/semantic/18.platform-global-object-organization-column-retired.ts +++ b/packages/spec/src/migrations/entries/semantic/18.platform-global-object-organization-column-retired.ts @@ -29,7 +29,7 @@ export const entry: SemanticMigration = { reason: 'ADR-0131 D7: "an object a deployment declares platform-global gets no organization column on that ' + 'deployment (the injected-columns plan reads the declaration), so Layer 0 and the driver agree by ' - + 'having nothing to scope". The #12699 declaration used to stand the security layer\'s organization ' + + 'having nothing to scope". Before this, the declaration stood the security layer\'s organization ' + 'wall down for the object while the column stayed, so the SQL driver went on scoping a read by the ' + 'caller organization that the wall had stopped scoping — measured on a booted kernel with a fixture ' + 'provider, before the change. ADR-0131 retires that stand-down ("replaced by D7\'s no-column"). The ' diff --git a/packages/spec/src/migrations/registry.ts b/packages/spec/src/migrations/registry.ts index 69c5ff54560..868885eaadf 100644 --- a/packages/spec/src/migrations/registry.ts +++ b/packages/spec/src/migrations/registry.ts @@ -6185,7 +6185,7 @@ const STEP18_RATIONALE: readonly RationaleFragment[] = [ id: 'platform-global-object-organization-column-retired', order: 89, text: - 'It also makes the #12699 deployment declaration total (ADR-0131 D7): an object a deployment ' + 'It also makes the deployment\'s platform-global declaration total (ADR-0131 D7): an object a deployment ' + 'declares platform-global in its `org-scoping` service\'s `platformGlobalObjects` gets no ' + 'organization column on that deployment, because the injected-columns plan reads the declaration, ' + 'so the organization wall and the driver agree by having nothing to scope. The engine reads it at ' @@ -17041,7 +17041,7 @@ const step18: MigrationStep = { reason: 'ADR-0131 D7: "an object a deployment declares platform-global gets no organization column on that ' + 'deployment (the injected-columns plan reads the declaration), so Layer 0 and the driver agree by ' - + 'having nothing to scope". The #12699 declaration used to stand the security layer\'s organization ' + + 'having nothing to scope". Before this, the declaration stood the security layer\'s organization ' + 'wall down for the object while the column stayed, so the SQL driver went on scoping a read by the ' + 'caller organization that the wall had stopped scoping — measured on a booted kernel with a fixture ' + 'provider, before the change. ADR-0131 retires that stand-down ("replaced by D7\'s no-column"). The '