Skip to content

Commit 41d0d40

Browse files
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) (#22331)
Fixes #15207 Clause-②: yes (narrowing: on a deployment that declares an object platform-global, that object carries no organization column; the dev measures the built declaration closure) Measured on the built declaration closure. The value `yes` holds: `@objectstack/core` gains exports, because the one fail-closed reader of the `org-scoping` keys moved there. `resolveInjectedSystemColumns` gains an optional second parameter but no new spec export, and `check:api-surface` on the rebuilt spec reports the surface unchanged. The arm stays `(narrowing)`, because the behaviour narrows: a declared object loses its column on the declaring deployment. The changeset carries `Clause-②: yes (narrowing)`. This is the last open item of the card (items (1), (2) and (3) landed as #22107, #22266 and #22166), so line 1 closes it. ## Scope: item (4), the #12699 declaration made total 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." ADR-0131's retirement list names "#12699's stand-down semantics (replaced by D7's no-column)". Claim `6061910188`. No stored row moves and no step runs at boot (ADR-0131 D14). ## What changes - **The plan** (`spec/src/data/injected-system-columns.ts`): `resolveInjectedSystemColumns(def, deployment?)`. The second argument carries the deployment's validated `platformGlobalObjects`. A declared object is planned with no `organization_id`, and the rest of its plan is unchanged. With the argument absent or empty, every plan is byte-identical to the one-argument call (pinned over eight object shapes). Author-time callers pass nothing. The module doc says where that leaves them: see P5. - **The reader** (`objectql/src/registry.ts`, `objectql/src/plugin.ts`): - `ObjectQLPlugin.start()` reads `org-scoping` FIRST, before `loadMetadataFromService` and before the first `installRegisteredSchemas`. - It installs the validated set with the new `SchemaRegistry.setDeploymentPlatformGlobalObjects()`. - The registry passes the set to `applySystemFields` as the plan's input, and to the tenant-index predicate (`carriesTenantScopeColumn`). - `materializeBaseLayer` gains a first stamp, `applyDeploymentTenancy`. It records the plan's answer on the base layer as `systemFields: { tenant: false }`, which is the vocabulary every registered-object reader already answers "no organization column" from. Its write-side inverse is the last strip in `stripMaterializedStampsFrom`, so a Studio GET → PUT stores the body the author wrote (#4326). - Objects registered before the install (inside other plugins' `init()`) are re-planned at the install, on every contributor layer. - The registry reads the declaration in ONE place, `deploymentWithholdsTenant`, which asks the spec plan with and without the deployment input. - The plugin logs the declared list once. It warns once, by name, for an object that DECLARES its own `organization_id`: that column is the author's, so it stays and is walled. It warns once for a refused (malformed) key. - **The stand-down retires** (`plugin-security/src/security-plugin.ts`): - The third `tenancyDisabled` clause in `getObjectSecurityMeta` is gone, the one that read `platformGlobalObjects`. A declared object reaches the wall as its own `systemFields.tenant: false`. - The `[security] deployment declares N platform-global object(s)` boot line is gone; the engine logs the list. - The plugin now warns only for the refused key it reads, `suppressUnboundedOrgAdminGrant`. That key and its behaviour are unchanged. - **The reader moves** (`deployment-org-scoping-entitlement.ts`: plugin-security → `core/src/security/`). Its consumers are now in two packages that cannot import each other: objectql reads `platformGlobalObjects`, plugin-security reads `suppressUnboundedOrgAdminGrant`. It is exported from `@objectstack/core` with its rules unchanged: absent ⇒ nothing declared; junk ⇒ the whole key refused, never coerced, each key independently. - **The provider declaration** (`plugins/organizations/src/organizations-plugin.ts`): `providesServices = ['org-scoping']` (ADR-0116 D2). See P2. - **Narratives**: `tenancy-posture.ts` (the `platformGlobalObjects` and `suppressUnboundedOrgAdminGrant` docs), `tenant-layer0-verdict.ts` (the carve-out row), `engine.ts` (two docblocks), and `auto-org-admin-grant.ts` (one docblock). - **ADR-0087**: the D3 entry `18.platform-global-object-organization-column-retired.ts`, one step-18 rationale fragment (order 89), and the regenerated `registry.ts`. - **Changeset** `.changeset/15207-platform-global-no-organization-column.md`: `major` for `@objectstack/objectql` and `@objectstack/plugin-security` (Changesets pre mode is in, `.changeset/pre.json`), `minor` for spec and core, `patch` for organizations, with its BREAKING banner, its FROM → TO and the marker. ## Premise readings (each measured before code) - **P1, HOLDS.** The harness: a booted `ObjectKernel` with `ObjectQLPlugin` over SQLite, the real `SecurityPlugin`, and a fixture provider composed after the objects plugin declaring `platformGlobalObjects: ['qa_widget_registry']`. It was measured at `799eb000c7`, before any edit: - the declared object was registered with `organization_id`, and its table was created with the column (`columnInfo`); - `security.getReadFilter(declared, member)` answered no wall (the fold in `getObjectSecurityMeta`), and the sibling answered `{ organization_id: 'org_acme' }`; - a system read of the declared table carrying `tenantId: 'org_acme'` returned only the `org_acme` row of two (the driver's tenant arm). So Layer 0 and the driver disagreed about one object. Control: with no declaration, both objects were walled. - **P2, HOLDS through the Phase 1/2 split, NOT through a per-plugin edge.** - **Where the plan is computed:** `SchemaRegistry.registerObject` → `applySystemFields` → `resolveInjectedSystemColumns`. That runs inside whichever plugin's `init()` calls `manifest.register`, and again for later registrations (`loadMetadataFromService` and `restoreMetadataFromDb` at `start()`, installs after it). The columns are fixed at `ObjectQLPlugin.start()` → `installRegisteredSchemas`. - **When `org-scoping` is registered:** in `OrganizationsPlugin.init()`, which hard-depends on the engine (its `init()` calls `manifest.register`). `serve` composes it after Auth and the app plugins, so it initializes after many object registrants, and the fixture measured that too: the declared object was already registered when the provider initialized. - **What orders them:** ADR-0116's Phase 1/2 split. Every `init()` completes before any `start()`, so the provider has registered by the engine's `start()`, and no table exists yet. The provider now declares it in `providesServices`, so "absent at start" is a declared fact (ADR-0116 D2; the AGENTS.md startup-registry cure 2). - **Measured:** neither ADR-0116 edge can order the provider ahead of the registration-time plan itself. An engine-side `optionalDependencies` on the provider is a cycle, and `resolvePluginOrder` throws on an optional edge too. An edge from every object registrant is an open-ended set. That is why the registry re-plans at the install. - **Validation:** a provider that registers outside `init()` with a different declaration fails the boot at `kernel:ready`, naming both lists and `providesServices`. - **No boot move:** no plugin moved, and no data step runs at boot. - **P3, HOLDS.** With the key absent, every object's registered shape is byte-identical: pinned as JSON equality between a registry with no install and one with an empty install, plus the spec pin over eight shapes, plus the kernel pin. With junk (`platformGlobalObjects: 'qa_widget_registry'`), the engine warns `'platformGlobalObjects' REFUSED` once, and every object keeps its column and its wall. - **P4, HOLDS.** At `799eb000c7`, `git grep platformGlobalObjects` finds no declarer outside tests: the spec schema and docs, the reader, plugin-security's consumer and log, and tests only. Every pin uses a fixture provider. - **P5, measured.** Author-time surfaces compute the plan with no deployment, so on the declaring deployment they still name `organization_id` for a declared object: - the linter's addressable-name set (`lint/src/system-fields.ts`); - the import mapper (`spec/src/data/import-mapping-target.ts`); - the tenancy census (`scripts/platform-object-tenancy-census.mjs`); - the CLI's authoring filter judge. Runtime surfaces on that deployment agree with it: the registry, the DDL, the `/meta` read exits (pinned through `ObjectStackProtocolImplementation`), the metadata bridge that `describe` reads, the lifecycle provenance (`absent`), and the field doors (`INVALID_FIELD` / `INVALID_FILTER`). The plan's module doc and the changeset say so. One one-shot surface depends on composition: `os migrate plan` / `apply` composes the host config's plugins, not `serve`'s posture-driven `OrganizationsPlugin`. A declaring deployment whose provider arrives only through `serve` would get a migrate plan that adds the column back. That is under Acceptance notes, for C10. ## Pins (refused and still-accepted case each) - **The plan** (`spec/src/data/injected-system-columns.test.ts`, 5 cases): - a declared object has no `organization_id`, and only that moves; - CONTROL: a sibling keeps it; - the array form works; - absent, empty set and empty list are byte-identical over eight shapes; - a nameless record is never declared. - **The registry** (`objectql/src/registry-deployment-platform-global.test.ts`, 7 cases): - registered after the install: no column, no tenant index, the record present; - registered before the install: re-planned on every contributor layer, `extend` included; - absent or empty: JSON-identical; - an authored `organization_id` is kept and reported; - an object that opted out itself is untouched; - the `/meta` read exit serves the registry's answer; - a stored body converges at the read seam, and the write seam takes the record off. CONTROL: an author's other `systemFields` member survives, and a non-declared object is never touched. - **The kernel** (`plugin-security/src/platform-global-no-organization-column.test.ts`, 8 cases, booted kernel with a fixture provider): - the plan and the DDL, with the sibling control; - absent key; - junk key (warned once; every column kept); - Layer 0 composes no wall and the driver reaches every row, while the sibling is walled at both; - a write naming `organization_id` is refused `INVALID_FIELD` / 400, and the sibling accepts it; - **no stand-down path remains**: the plugin over an engine whose plan never received the declaration walls the object; - a provider registered after the objects reaches the plan; - a provider that registers in `start()` refuses the boot by name. - **Layer 0 and the driver at once** (`tenant-layer0-verdict-end-to-end.test.ts`): a member's predicate update on the declared object now matches both rows (it matched one before, the driver's tenant arm), and the bulk event names no organization. CONTROL: the sibling sweep matches one row and names `org_acme`. - **plugin-security reads no declaration** (`deployment-platform-global-exemption.test.ts`, rewritten): - a declared object that still carries its column is walled under `isolated` and `group`, and on the ADR-0123 D2 write path; - the registered shape is not walled, and the sibling is; - under `single`, Layer 0 is inert on both; - a junk `platformGlobalObjects` draws no warning from this plugin, and a junk suppress key is warned once; - the arming log carries no platform-global line. - **The reader** (`core/src/security/deployment-org-scoping-entitlement.test.ts`, 11 cases): absent, well-formed, four junk shapes (whole-key refusal), per-key independence, and memo per instance. ## Reverse verification (one-off, `scripts/ablation-replace.mjs` in hold mode with a trap restore) The plan's read of the declaration in `injected-system-columns.ts` was neutralised: the anchor `!(name !== '' && deploymentDeclaresPlatformGlobal` was replaced so it never matches (anchor 1 → 0, blob `6e571966dd` → `88efcbbb14`). The spec was rebuilt, and `ablation-dist-preflight.mjs` found the marker in 6 built files. Results: - the spec plan: exactly the two declared-object pins red, 20 green; - the registry: 5 of 7 red, with the absent-declaration and opted-out controls green; - the kernel: 4 of 8 red. The plan, Layer 0 / driver, write refusal and ordering pins went red. The absent-key, junk-key, no-stand-down and late-provider controls stayed green; - the end-to-end verdict: the declared-object sweep red, 2 green. Restore leg: - blob == HEAD (`6e571966dd`), `git diff HEAD` empty, the whole tree clean; - the spec rebuilt, and `ablation-dist-preflight.mjs --absent` finds the marker in none of the 234 built files; - the same files re-run green: spec 22, registry 7, kernel and end-to-end 11. ## Fate for C7 (#15211) and C10 On a declaring deployment, each declared object's existing `organization_id` column is ADR-0131 D10 fate 1 (column dropped). Schema sync is additive, so the physical column stays, and the boot drift report names it orphaned. The declarer (cloud's control plane, C10) owns the data step: confirm nothing reads it, then `os migrate apply --allow-destructive`. Its backfill decides any value that must survive. C7's inventory records the declared set per deployment with this entry id. No boot step reads or writes the column (D14). ## Files outside the claim's file surface - `packages/plugins/organizations/src/organizations-plugin.ts`: one declaration, `providesServices`. It is the "provider declaration" the claim's ordering bullet names, in the provider's own file. Its lane is re-declared by the seat. - `packages/core/src/security/deployment-org-scoping-entitlement.ts` and `.test.ts`, and `core/src/security/index.ts`: the reader's new home, so that both consumers can import it (the claim allows "if its reader moves"; `core` is on the claim's declared lanes). - `packages/objectql/src/federated-injected-column-readers.test.ts`: two census rows for the two new `organization_id` seams. That census fails on any undisposed seam. - Within the claimed packages: `objectql/src/plugin.ts`, `plugin-security/src/auto-org-admin-grant.ts` (one docblock), and four plugin-security test files. ## Acceptance notes - `os migrate plan` / `apply` compose the host config's plugins, not `serve`'s posture-driven organizations runtime. On a declaring deployment whose provider is composed only by `serve`, a migrate plan reads no declaration and would add the column back to a declared object's table (additive sync). Carrier: C10 (cloud's control plane composition), noted, not filed: there is no in-repo declarer to reach it with. - Author-time tools name `organization_id` on a declared object; the declaring deployment refuses it as an unknown field. This is inherent to a deployment input, and stated in the plan's docs and the changeset. - `OrganizationsPlugin.providesServices` was absent before, so ADR-0116's stage-1 check could not name it for an `init()`-time requirer of `org-scoping`. None exists in-repo (`check:init-service-contract` green). ## Verification (head `5322c2b755`, which merged `origin/main` at `dc4a5c6308` through `os-regen-merge.sh`; the regeneration wrote nothing) - **Suites, each through the verify lock:** - spec `--project local`: 626 files, 18728 passed, 1 todo; - objectql `--project local`: 385 files, 7562 passed; - core: 83 files, 2258 passed; - organizations: 11 files, 151 passed; - plugin-security: 183 files, 3835 passed, 45 skipped. It was run at `86db7e86f4`; since then plugin-security changed one test file's type annotation, and objectql (aliased to source there) lost one unused accessor. The four plugin-security files this PR touches were re-run green afterwards. - spec `--project repo`: `step18-rationale-merge` and `conversions-major18-merge`, 21 passed. - **Typecheck** exit 0 for core, objectql, plugin-security, organizations and spec. Each package's script includes `check:test-typecheck`, and every ledger held unchanged. - **Spec artifacts:** `check:generated` reports 15 of 15 up to date. `check:migration-registry`: 400 semantic entries. `check:api-surface` unchanged. - **Gates:** `dispatch-gates --commands --repo objectstack-ai/objectstack` (no paths) at `5322c2b755` derives 109 families. All 109 ran, each exit code captured before any pipe, all 0. The `--ran` reconciliation: 109 derived, 109 run, 0 NOT-MEASURED, 0 UNRUN. - Fixed on the way: `check:engine-double-contract` asked for the new pin's three doubles in the ledger (`--write`), and `check:slot-lookup` refused one untyped service lookup in a test. - `check:dts-closure`, `check:dual-build-cjs-loads` and `check:i18n` first stopped on unbuilt packages (a prerequisite). They are green after a full build (72 tasks, 71 cached). - Also green, run by hand: `check:init-service-contract` (36 declared) and `check:startup-registry-verdict` (none recording a verdict the boot can contradict). - **Lint, a proven narrowing:** `eslint --no-inline-config --format json` over the 21 changed `.ts` files gives 21 results, 0 errors, 0 warnings. The population is `eslint.config.mjs`'s `packages/**` and `**/*` TS globs. The config states it enables no type-aware linting, so an untouched file's verdict cannot move. The full `pnpm lint` is CI's. - **Measurements, one-off and not committed:** - P1, on a scratch copy of the kernel harness at `799eb000c7`; - P2's cycle, `resolvePluginOrder` over the two declarations: it throws `Circular dependency detected: com.objectstack.engine.objectql`. CONTROL: without the soft edge, the order is engine then organizations; - the reverse verification above, restored and proven (preflight `--absent`, tree clean, the same files green). - `origin/main` has moved 8 commits since `dc4a5c6308`, three of them through this PR's files (`registry.ts`, `engine.ts`, `security-plugin.ts`) and the double ledger. A no-commit merge probe auto-merges them with no conflict. The next hop merges them through `os-regen-merge.sh`. --- _Generated by [Claude Code](https://claude.ai/code/session_01LAi5BVvQNiYzepSAcsoFLK)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 5ff7cbe commit 41d0d40

23 files changed

Lines changed: 1479 additions & 263 deletions
Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
---
2+
'@objectstack/objectql': major
3+
'@objectstack/plugin-security': major
4+
'@objectstack/spec': minor
5+
'@objectstack/core': minor
6+
'@objectstack/organizations': patch
7+
---
8+
9+
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)
10+
11+
Clause-②: yes (narrowing)
12+
13+
<!-- adr-0087: registered platform-global-object-organization-column-retired -->
14+
15+
**BREAKING** on a deployment whose `org-scoping` service declares `platformGlobalObjects`. Nothing changes on any other deployment.
16+
17+
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.
18+
19+
- **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.
20+
- **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.
21+
- **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.
22+
- **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.
23+
- **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.
24+
25+
**What moves for consumers.**
26+
27+
- **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.
28+
- **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.
29+
- **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.
30+
- **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.
Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* [#12699 / ADR-0131 D7] The ONE reader of the mounted `org-scoping` service's
5+
* per-deployment keys. Its rules stand as #12699 set them: absent key ⇒ nothing
6+
* declared; junk ⇒ the WHOLE key refused (no object declared, no partial
7+
* honouring), each key independently, never coerced.
8+
*
9+
* The cases moved here with the reader: they used to run through
10+
* plugin-security's Layer 0 stand-down, which ADR-0131 D7 retired. The engine's
11+
* schema registry is now the `platformGlobalObjects` consumer and
12+
* plugin-security the `suppressUnboundedOrgAdminGrant` one; both read this.
13+
*/
14+
15+
import { describe, expect, it } from 'vitest';
16+
import { readDeploymentOrgScopingEntitlement } from './deployment-org-scoping-entitlement.js';
17+
18+
describe('readDeploymentOrgScopingEntitlement', () => {
19+
it.each([
20+
['no service', undefined],
21+
['a null service', null],
22+
['a non-object service', 'org-scoping'],
23+
['a service declaring neither key', { name: 'com.example.org-scoping', supportedPostures: ['isolated'] }],
24+
])('%s ⇒ nothing declared, nothing refused', (_label, service) => {
25+
const reading = readDeploymentOrgScopingEntitlement(service);
26+
expect([...reading.platformGlobalObjects]).toEqual([]);
27+
expect(reading.suppressUnboundedOrgAdminGrant).toBe(false);
28+
expect(reading.refused).toEqual([]);
29+
});
30+
31+
it('a well-formed declaration is read as declared', () => {
32+
const reading = readDeploymentOrgScopingEntitlement({
33+
platformGlobalObjects: ['sys_setting', 'sys_job'],
34+
suppressUnboundedOrgAdminGrant: true,
35+
});
36+
expect([...reading.platformGlobalObjects].sort()).toEqual(['sys_job', 'sys_setting']);
37+
expect(reading.suppressUnboundedOrgAdminGrant).toBe(true);
38+
expect(reading.refused).toEqual([]);
39+
});
40+
41+
it.each([
42+
['a bare string', 'sys_widget_registry'],
43+
['a wildcard entry beside a valid one', ['sys_widget_registry', '*']],
44+
['an empty-string entry', ['sys_widget_registry', '']],
45+
['a number', 42],
46+
])('junk platformGlobalObjects (%s) ⇒ the WHOLE key refused, no object declared', (_label, value) => {
47+
const reading = readDeploymentOrgScopingEntitlement({ platformGlobalObjects: value });
48+
expect([...reading.platformGlobalObjects]).toEqual([]);
49+
expect(reading.refused.map((r) => r.key)).toEqual(['platformGlobalObjects']);
50+
expect(reading.refused[0]?.value).toEqual(value);
51+
});
52+
53+
it('junk in one key does not void the other (each key fails closed independently)', () => {
54+
const reading = readDeploymentOrgScopingEntitlement({
55+
platformGlobalObjects: ['sys_widget_registry'],
56+
suppressUnboundedOrgAdminGrant: 'yes',
57+
});
58+
expect([...reading.platformGlobalObjects]).toEqual(['sys_widget_registry']);
59+
expect(reading.suppressUnboundedOrgAdminGrant).toBe(false);
60+
expect(reading.refused.map((r) => r.key)).toEqual(['suppressUnboundedOrgAdminGrant']);
61+
});
62+
63+
it('memoizes per service instance; a new instance re-validates', () => {
64+
const service = { platformGlobalObjects: ['sys_widget_registry'] };
65+
expect(readDeploymentOrgScopingEntitlement(service)).toBe(readDeploymentOrgScopingEntitlement(service));
66+
const other = { platformGlobalObjects: ['sys_widget_registry'] };
67+
expect(readDeploymentOrgScopingEntitlement(other)).not.toBe(readDeploymentOrgScopingEntitlement(service));
68+
});
69+
});

packages/plugins/plugin-security/src/deployment-org-scoping-entitlement.ts renamed to packages/core/src/security/deployment-org-scoping-entitlement.ts

Lines changed: 22 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,17 @@
55
* mounted `org-scoping` service (`OrgScopingEntitlement`,
66
* `@objectstack/spec/security`).
77
*
8+
* ## Why it lives in core
9+
*
10+
* The two keys have two consumers in two packages that cannot import each
11+
* other: the engine's schema registry (`@objectstack/objectql`) plans the
12+
* columns from `platformGlobalObjects` (ADR-0131 D7: a declared object gets no
13+
* organization column on this deployment), and `@objectstack/plugin-security`
14+
* shapes the `organization_admin` auto-grant from
15+
* `suppressUnboundedOrgAdminGrant`. Both depend on this package, so the ONE
16+
* reader — and its fail-closed contract — lives here. Each caller warns for
17+
* the refused key it consumes, and only that one.
18+
*
819
* ## Why a reader, and why it fails closed per key
920
*
1021
* The `org-scoping` service is the enterprise runtime's own object — a live
@@ -16,8 +27,8 @@
1627
* never coerced onto a permissive branch. Here the non-permissive branch is
1728
* "the key was never declared":
1829
*
19-
* - `platformGlobalObjects` junk ⇒ NO object is exempted (everything walls
20-
* exactly as its own declaration says);
30+
* - `platformGlobalObjects` junk ⇒ NO object is declared (every object's
31+
* injected-columns plan is exactly what its own declaration says);
2132
* - `suppressUnboundedOrgAdminGrant` junk ⇒ the auto-grant keeps today's
2233
* posture-keyed behaviour.
2334
*
@@ -28,8 +39,9 @@
2839
* ⛔ Partial honouring is refusal's other failure mode: one junk ENTRY voids
2940
* the whole `platformGlobalObjects` key rather than dropping the entry. The
3041
* declarer is first-party runtime code; half-honouring a malformed list hides
31-
* the bug behind mostly-working behaviour, while a whole-key refusal walls
32-
* every named object — loud on the first smoke test, and safe.
42+
* the bug behind mostly-working behaviour, while a whole-key refusal keeps the
43+
* organization column (and the wall) on every named object — loud on the
44+
* first smoke test, and safe.
3345
*
3446
* ## Read timing
3547
*
@@ -40,7 +52,9 @@
4052
* registers after the reader's own init). A re-registered service is a new
4153
* instance and re-validates; in-place mutation of a declaration is outside the
4254
* contract (the interface is readonly, and every declared key is expected to
43-
* be constant for the kernel's life).
55+
* be constant for the kernel's life). The engine reads `platformGlobalObjects`
56+
* once, at its plugin's `start()`, before the first schema sync — see
57+
* `SchemaRegistry.setDeploymentPlatformGlobalObjects` for the ordering.
4458
*/
4559

4660
import {
@@ -60,8 +74,9 @@ export interface RefusedEntitlementKey {
6074
/** The validated, fail-closed reading of the deployment's declaration. */
6175
export interface DeploymentOrgScopingEntitlementReading {
6276
/**
63-
* Objects this deployment declares platform-global (Layer 0 must not wall
64-
* them here). Empty when the key is absent, junk, or no service is mounted.
77+
* Objects this deployment declares platform-global — each gets no
78+
* organization column on this deployment (ADR-0131 D7). Empty when the key is
79+
* absent, junk, or no service is mounted.
6580
*/
6681
readonly platformGlobalObjects: ReadonlySet<string>;
6782
/**

‎packages/core/src/security/index.ts‎

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,16 @@ export {
5454
// re-exports both names unchanged.
5555
export { HTTP_SIGNATURE_HEADER, signHttpBody } from './http-signature.js';
5656

57+
// [#12699 / ADR-0131 D7] The ONE fail-closed reader of the mounted `org-scoping`
58+
// service's per-deployment keys — shared by the engine's schema registry
59+
// (`platformGlobalObjects`: no organization column on a declared object) and
60+
// plugin-security (`suppressUnboundedOrgAdminGrant`).
61+
export {
62+
readDeploymentOrgScopingEntitlement,
63+
type DeploymentOrgScopingEntitlementReading,
64+
type RefusedEntitlementKey,
65+
} from './deployment-org-scoping-entitlement.js';
66+
5767
// `PluginConfigValidator` / `createPluginConfigValidator` were RETIRED here on
5868
// 2026-08-27 (#11982, ADR-0049 enforce-or-remove; recorded in ADR-0025 §3.7).
5969
// The kernel never received a plugin's config to validate — factories close

‎packages/objectql/src/engine.ts‎

Lines changed: 12 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -2883,9 +2883,11 @@ export interface OperationContext {
28832883
* to stamp `BulkDataEvent.organizationId`.
28842884
*
28852885
* The seam ruled on #15706: the wall is computed ONCE, where every input is
2886-
* visible (the posture in force, the caller's organization scope, the
2887-
* object's tenancy clauses AND the deployment's #12699 carve-out, which no
2888-
* schema carries), and its decision travels here as a value. A reader
2886+
* visible (the posture in force, the caller's organization scope and the
2887+
* object's tenancy clauses — which, since ADR-0131 D7, include the
2888+
* deployment's #12699 platform-global declaration, recorded on the
2889+
* registered object as `systemFields.tenant: false`), and its decision
2890+
* travels here as a value. A reader
28892891
* answers from this member ALONE and re-derives nothing — a re-derivation
28902892
* is a mirror of the wall, and a mirror structurally sees only the clauses
28912893
* it was taught (the #15706 mislabel).
@@ -3545,10 +3547,14 @@ function eventOrganizationValue(value: unknown): string | undefined {
35453547
* re-derived the wall here — from the enforced posture, the execution
35463548
* context's `tenantId` / `accessible_org_ids` / `posture` rung, and the
35473549
* object schema's tenancy clauses. It could not see the third clause
3548-
* plugin-security folds into `tenancyDisabled` — the deployment-declared
3549-
* `platformGlobalObjects` carve-out (#12699), which no schema carries — and
3550+
* plugin-security then folded into `tenancyDisabled` — the deployment-declared
3551+
* `platformGlobalObjects` carve-out (#12699), which no schema carried — and
35503552
* stamped the caller's organization onto a batch Layer 0 had never
3551-
* constrained: a WRONG key, the #13566 leak shape. The ruling's acceptance
3553+
* constrained: a WRONG key, the #13566 leak shape. (ADR-0131 D7 has since
3554+
* made that declaration total: a declared object is registered with no
3555+
* organization column and declaring `systemFields.tenant: false`, so the fold
3556+
* is retired — but the rule below does not lean on that; the ruling's
3557+
* acceptance criterion is about the seam, not one clause.) The ruling's acceptance
35523558
* criterion, verbatim: the verdict recorded must be what the wall decided,
35533559
* not a re-statement of its inputs; if the recorded value can be derived by
35543560
* the reader from anything else on the context, the mirror has not been

‎packages/objectql/src/federated-injected-column-readers.test.ts‎

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -316,6 +316,18 @@ const READERS: Record<string, Row> = {
316316
disposition: 'not-a-read',
317317
why: 'reads an index declaration, not a row',
318318
},
319+
'registry.ts#applyDeploymentTenancy :: organization_id': {
320+
disposition: 'not-a-read',
321+
why:
322+
"drops the platform's own injected definition from a declared object's schema (ADR-0131 D7); " +
323+
'it reads a definition, never a row',
324+
},
325+
'registry.ts#setDeploymentPlatformGlobalObjects :: organization_id': {
326+
disposition: 'not-a-read',
327+
why:
328+
'tells an authored organization_id from the injection while re-planning registered schemas ' +
329+
'(ADR-0131 D7); it reads definitions, never a row',
330+
},
319331
'tenancy/system-write-organization.ts#<module> :: organization_id': {
320332
disposition: 'not-a-read',
321333
why: 'the declaration of the default tenant column name',

0 commit comments

Comments
 (0)