Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 30 additions & 0 deletions .changeset/15207-platform-global-no-organization-column.md
Original file line number Diff line number Diff line change
@@ -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)

<!-- adr-0087: registered platform-global-object-organization-column-retired -->

**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.
Original file line number Diff line number Diff line change
@@ -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));
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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.
*
Expand All @@ -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
*
Expand All @@ -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 {
Expand All @@ -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<string>;
/**
Expand Down
10 changes: 10 additions & 0 deletions packages/core/src/security/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
18 changes: 12 additions & 6 deletions packages/objectql/src/engine.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand Down Expand Up @@ -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
Expand Down
12 changes: 12 additions & 0 deletions packages/objectql/src/federated-injected-column-readers.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -316,6 +316,18 @@ const READERS: Record<string, Row> = {
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#<module> :: organization_id': {
disposition: 'not-a-read',
why: 'the declaration of the default tenant column name',
Expand Down
Loading
Loading