Skip to content

Commit 1920cf3

Browse files
feat(platform-objects,service-automation,service-realtime)!: seven deployment-level tables lose their injected organization column, and reads need manage_platform_settings (ADR-0131 D7) (#22107)
Part of #15207 Clause-②: no (narrowing: deployment-level objects lose their injected organization column and the global settings rung moves; whether any `@objectstack/spec` export widens is measured on the built declaration closure by the dev, and the measurement decides) The line above is the claim's, copied verbatim. This PR moves no settings rung (scope item 3 is not here). The measurement it names: `check:api-surface` is green on the built spec, so no `@objectstack/spec` export widens. ## Scope and the two decisions This PR lands **scope item (1)** of #15207, plus the one `objectql` edit item (1) needs so that it ships no regression. The seat's claim revision on the card (comment `6043540291`) narrows this claim's landing to item (1) and records two decisions on the first round's dev report (comment `6042515710`): - **Decision 1 = A.** `packages/objectql/src/lifecycle/lifecycle-service.ts`, `tenantWindowsFor` only, joins the file surface. The edit is in this PR (section below), so the lifecycle regression the first round measured is closed here, and nothing outside the PR remains before it can be readied. Refusing such an override at save is not decided, and it is not built. - **Decision 2 = A.** The seven objects keep `requiredPermissions: ['manage_platform_settings']`. ADR-0131 D7 says objects without the column "are governed by object permission, not by the wall", and the security table below shows what happens without the gate. Items (2), (3) and (4) are not built under this claim, so this PR says `Part of` and the card stays open for them. ## What this PR does (scope item 1) `sys_job`, `sys_job_run`, `sys_job_queue`, `sys_flow_dispatch`, `sys_migration`, `sys_migration_journal` and `sys_presence` now declare `systemFields: { tenant: false }`, so the registry injects no `organization_id` on them (ADR-0131 D7). Each also declares `requiredPermissions: ['manage_platform_settings']`; the security table below is why that is part of the same change. - One ADR-0087 D3 semantic entry per removed column (`18.sys-*-organization-column-retired.ts`), as the card requires, plus one step-18 rationale fragment. - The platform-object tenancy census artefact regenerated: 57 → 50 objects in reach, `systemFields.tenant: false` 1 → 8. - `sys_job.global-unique.test.ts`: the pin that asserted the injected column is re-premised on its absence, which is the direction its own comment asked to re-check from. - New pins: the injection plan, the declared opt-out and the capability, per object, each with a control (the same declaration with the opt-out removed gets the column; `sys_secret` and `sys_automation_run`, which are tenant-attributed, keep it). ## The lifecycle guard (Decision 1) A tenant-scope `lifecycle.retention_overrides` entry gives one organization its own retention window, and the reaper and the archiver apply it by partitioning the object's rows on `organization_id`: a pass for that organization's rows, then a global pass whose `$or` covers everyone else. On a table with no `organization_id` column both passes name a column the table lacks. The first round measured it on the real SQL driver (better-sqlite3): both predicates throw `INVALID_FILTER`, so on a new database such an override on `sys_job_run`, `sys_job_queue` or `sys_flow_dispatch` (the three of the seven that declare a `lifecycle`) stopped that table's retention. `tenantWindowsFor` is the one decision both passes ask. It already answered no windows for a federated object whose `organization_id` is the registry's unprovisioned injection. It now also answers no windows when the registry provenance of `organization_id` (`resolveInjectedColumnProvenance`, `@objectstack/spec/data`) is `'absent'`: no injection plan put the column there and the author declared none. The object then runs its one global pass at the global window. No row of such a table belongs to an organization, so a tenant override naming it has nothing to select, and it is not applied. An object that has the column keeps its per-tenant windows. - **Measured on the real registry**, at this head: the seven objects, registered through `ObjectQL`'s registry, carry `systemFields: { tenant: false }`, have no `organization_id` field, and answer provenance `'absent'`. The #21918 federated shape answers `'injected-unprovisioned'`, not `'absent'`, so the federated line stays and the new line is a second answer to the same question (is there a provisioned `organization_id`). A federated object that declares `systemFields: { tenant: false }` answers `'absent'` and is covered by the new line. - **Pin and control** (`lifecycle-service.no-tenant-column.test.ts`, on a real `ObjectQL` engine and registry; the stub driver provisions each table from the registered object's fields and refuses a filter on a column the table lacks, as the SQL driver does): a premise case shows the driver refuses an `organization_id` filter on the column-less table (`INVALID_FILTER`, 400); the pin sweeps a column-less `sys_job_run` with one organization's `90d` tenant override, and gets no error and exactly one read, `created_at` before the `30d` cutoff; the control sweeps the same declaration without the opt-out and gets the per-tenant read at `90d` and the global `$or` read at `30d`. - **Ablation, one-off.** Through `scripts/ablation-replace.mjs` (anchor hit 1 → 0, blob changed): deleting the new line turns exactly the pin red (`report.errors` gains the driver's `INVALID_FILTER` refusal for `sys_job_run`), with the premise and the control green; the federated reader census turns red too, because its row for the new seam no longer finds a use. Restored: blob equals HEAD and `git diff HEAD` is empty. - **The federated reader census** (`federated-injected-column-readers.test.ts`) scans every non-test source of the package for each use of a provenance seam, and fails on a use without a row. The new call is one, so it gains one row, `skips`, on the site that already skips; the set of skipping sites is unchanged. The three federated lifecycle pins from #21918 stay green. - **Other readers of the override and the window**, census at this head: in `lifecycle-service.ts`, `loadGovernance` reads the global and per-tenant `retention_overrides` (organization ids from `sys_organization`, no read of the swept table), `tenantWindowsFor` is the only reader of the per-tenant map, and `reap` and `archiveObject` are the only sites that put `organization_id` into a predicate, both built only from `tenantWindowsFor`'s answer. Governance quotas and growth count rows with no filter; the rotator falls back to `reap`; the archive's cold `keep` prune filters on `created_at` only; the retention floors compare durations and read no rows. Outside `objectql`, the settings manifest declares the key and `service-queue` registers a floor; neither reads rows. None names the column. ## Writer census, with a firing control Read at `e67ba80049`, all non-test sources under `packages/`. Every write call naming the object (literal or a constant bound to it), its context, and whether the row or options name an organization; a row that is not an inline literal was traced to its type. | object | writers | write sites | verdict | |---|---|---|---| | `sys_job` | `DbJobAdapter` (service-job) | 4 | system context; rows name no organization | | `sys_job_run` | `DbJobAdapter` | 2 | system context; rows name no organization | | `sys_job_queue` | `DbQueueAdapter` (service-queue) | 9 | system context; rows name no organization | | `sys_flow_dispatch` | `ObjectStoreFlowDispatchStore` (service-automation) | 2 | system context; key and outcome only | | `sys_migration` | migration-flag helpers, the engine's two flag writes, seed-tenancy, membership-backfill and flow-credential receipts | 11 in 6 files | system context; `DataMigrationFlagSchema` has no organization field | | `sys_migration_journal` | core migration runner | 1 | system context, or the transaction it opened with one; `MigrationJournalEventSchema` has no organization field | | `sys_presence` | none through ObjectQL | 0 | `apiMethods: ['get', 'list']`; presence travels the realtime path | Raw-SQL writes to the seven tables: 0. The same grep shape finds the raw `INSERT INTO sys_packages` writes elsewhere, so it can match. **Firing control.** The same procedure, run over the three tables the card and triage name as tenant-attributed: - `sys_http_delivery`: 6 write sites flagged (caller-supplied context). - `sys_secret`: the engine's secret write passes the business write's driver options, so the SQL driver stamps the caller's organization. - `sys_email`: the call-site pass is silent (system context), and the row trace fires: the email service stamps `organization_id` into the row it hands the persistence insert. That is why rows that are not inline literals were traced. `sys_secret` stays off this PR (triage's correction; its fate is C7's). `sys_http_delivery` and `sys_email` are excluded by the card. ## Security: who reads these tables, before and after Measured by driving the real `SecurityPlugin` middleware with a `find` on `sys_job_queue` (a scratch harness, not committed), with the shipped `organization_admin`, `admin_full_access` and `member_default` sets: | posture | shape | organization admin | platform admin | member | |---|---|---|---|---| | isolated | before (column injected) | admitted, `organization_id = org-1`: 0 rows, since every row is NULL | same: 0 rows | 403 | | isolated | column removed, **no gate** | **admitted, no filter: every organization's rows** | admitted, no filter | 403 | | isolated | column removed, with the gate (this PR) | 403 (missing `manage_platform_settings`) | admitted, no filter | 403 | | single | before | admitted, no filter | admitted, no filter | 403 | | single | with the gate (this PR) | 403 | admitted, no filter | 403 | The middle row is the reason for the gate (Decision 2). With no column there is no tenant wall, and `organization_admin`'s `'*'` grant carries the superuser bits, so Layer 1 is skipped too. D7 governs these tables by object permission, and the gate is that permission, on the `sys_sso_provider` precedent. The `single` rows are a declared narrowing: an organization administrator who is not a platform administrator loses generic reads of these seven tables. No shipped app or nav entry names any of them. Every platform reader and writer uses a system context, which the gate does not apply to. ## Existing databases Schema sync only adds. On an existing database each table keeps its physical `organization_id` column (and its index where one was provisioned), and the boot drift report names it orphaned: "column exists in the database but not in metadata (orphaned) — os migrate apply --allow-destructive to drop it". It is never dropped automatically, and never in production. By the census the column holds only NULL, so no data step is needed and the drop loses nothing. The v18 ceremony of ADR-0131 D10 (C7) is where a guided drop belongs; this PR invents no migration. The lifecycle guard reads the registered object, not the physical table, so on such a database the orphaned column is simply never named. ## Acceptance notes - **Scope items not in this PR** (not built under this claim, per the revision `6043540291`): - (2) `sys_audit_log`: removing the column opens the no-record rows (`config_change`, `import`, `platform_admin_standing_change`) to every organization's admin, by the same mechanism as the middle row above. The card's "RLS readers filter on `tenant_id` explicitly" needs either a platform RLS policy on `tenant_id` plus an explicit `sys_audit_log` entry in `organization_admin` without the superuser bits (`plugin-security`), or a filter in `plugin-audit` that re-derives the wall's posture ladder. With the guard in this PR, a column-less `sys_audit_log` would get the global retention window only; whether that is right for audit is item (2)'s call, not made here. - (3) the settings global rung: six `service-settings` manifests (ai, auth, knowledge, mail, sms, storage) and `objectql`'s `lifecycle` manifest are global and edited at runtime in Setup, so §6 Q3's measurement points to a tenant-less `sys_platform_setting`, not to configuration. Existing global rows would need a data step to move; that is the C7 ceremony's. - (4) #12699 made total: `applySystemFields` (`objectql`) has to receive the deployment's declaration, and the stand-down in `plugin-security` retires. - **Not decided, not built:** a tenant-scope `retention_overrides` entry naming a table with no organization column is still accepted at save; it now has no effect instead of stopping retention. - **Noted only:** `sys_job_queue.metadata_json`'s description says it carries `tenant_id`; no producer measured writes it there. - **Step-18 rationale order.** After the merge of `main`, this PR's fragment and the builtin node-config fragment both sat at order 85, and the id tie-break rendered this one between the approval-node fragment and the sentence that continues it ("Then the builtin arm stops being presence-only"). No pin refuses a duplicate order, but the rendered text read wrong, so this fragment moved to 86, the next free order. The rendered rationale now runs approval-node, builtin values, then this fragment. ## Merge of `main` Two merges, both through `scripts/pm/os-regen-merge.sh`, no rebase and no force-push: `b016a64661` (main at `aa71c4d9d1`) and `1ef09a5127` (main at `dd39171835`). Neither stopped on a conflict. `packages/spec/src/migrations/registry.ts` text-merged with the step-18 entries of the flow builtin node-config change, and `check:migration-registry` reads its generated regions current. `scripts/platform-object-tenancy-census.json` kept this branch's bytes (main had not changed it), and `check-platform-object-tenancy-census` is green at this head. `.changeset/pre.json` does not exist at `dd39171835`, so the changeset keeps `minor` with its **BREAKING** banner. ## Tests and gates Readings at this head, `1ef09a5127`, unless a line says otherwise. - **`@objectstack/objectql`**: `vitest --project local` 379 files, 7516 passed; typecheck (`tsc --noEmit` and the test-layer check) exit 0. The four lifecycle and census files alone: 128 passed. - **`@objectstack/spec`**: build exit 0; `check:generated` 15 of 15 up to date; typecheck exit 0; `vitest --project local` 622 files, 18567 passed, 1 todo, and the step-18 ledger merge tests (`step18-rationale-merge`, `conversions-major18-merge`, project `repo`) 2 files, 21 passed, both measured at `dc24a4e051`. The second merge changed no file under `packages/spec` (`git diff dc24a4e 1ef09a5 -- packages/spec` is empty). - **`@objectstack/platform-objects`** 64 files, 1028 passed; **`@objectstack/service-automation`** 175 files, 2120 passed; **`@objectstack/service-realtime`** 5 files, 35 passed. Typecheck exit 0 for each. - **Derived gate families**: `dispatch-gates --commands`, derived with no paths at `1ef09a5127`: 103 commands. On the first pass 100 exited 0; `check-engine-split-ratio --days 90` exited 2 (this clone was shallow inside its 90-day window), and `check:dual-build-cjs-loads` and `check:i18n` exited 3 (prerequisite: built output). After deepening history to 2026-07-02 and a full workspace build, all three re-ran with exit 0. Reconciled with `--ran`: "103 derived famil(ies) accounted for — 103 run, 0 NOT-MEASURED (a DERIVED zero — all 103 recorded an exit code and none of them is 3)". Among them: `check-adr-0087-registration` ("1 declared-breaking changeset(s), each carrying an ADR-0087 disposition", the seven ids registered), `check-changeset-no-major` ("This diff introduces no `major` bump"), `check-platform-object-tenancy-census` (83 objects, 50 in reach, 33 outside) and `check:nul-bytes` OK. `check-governed-merges --pr 22107` after the push: 0 of 25 paths hit the governed register. - **Lint, narrowed and proven**: `eslint --no-inline-config --format json` over the 22 changed `.ts` files: 22 files in the report, 0 errors, 0 warnings. All 22 are inside the config's `packages/**` population, and `eslint.config.mjs` enables no type-aware linting (no `parserOptions.project`, no typed rules), so this diff cannot move a verdict on an untouched file. The full `pnpm lint` is CI's. - **Changed lines**: 1229 (1195 added, 34 deleted) across 25 files against the merge base `dd39171835`, under the 5,000-line human-merge threshold. The first round's 1022 grew by the lifecycle guard, its pin and census row, and the objectql changeset. ## Changesets - `.changeset/15207-deployment-plumbing-no-organization-column.md`: `minor` with the **BREAKING** banner and the ADR-0087 marker registering the seven ids (pre mode is not on at `dd39171835`). - `.changeset/15207-lifecycle-no-tenant-column-no-partition.md`: `@objectstack/objectql` `patch`, the lifecycle guard. This body was revised in patch round 1 by session `session_01GV6oYwgc1kWiUCb1YaprQ7`, from the dispatch of the `domain:spec` seat 2 PM. --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent f2a45db commit 1920cf3

25 files changed

Lines changed: 1195 additions & 34 deletions
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
---
2+
'@objectstack/platform-objects': minor
3+
'@objectstack/service-automation': minor
4+
'@objectstack/service-realtime': minor
5+
'@objectstack/spec': minor
6+
---
7+
8+
feat(platform-objects,service-automation,service-realtime)!: seven deployment-level platform tables lose their injected organization column, and reading them needs `manage_platform_settings` (ADR-0131 D7)
9+
10+
Clause-②: no (narrowing)
11+
12+
<!-- adr-0087: registered sys-flow-dispatch-organization-column-retired, sys-job-organization-column-retired, sys-job-queue-organization-column-retired, sys-job-run-organization-column-retired, sys-migration-journal-organization-column-retired, sys-migration-organization-column-retired, sys-presence-organization-column-retired -->
13+
14+
**BREAKING**, shipped as `minor` under the repo's launch-window convention for breaking changes (Changesets pre mode is not on yet).
15+
16+
`sys_job`, `sys_job_run`, `sys_job_queue`, `sys_flow_dispatch`, `sys_migration`, `sys_migration_journal` and `sys_presence` hold deployment-level state. No writer attributes a row of any of them to an organization: every write is a system-context write whose row names none, and nothing writes `sys_presence` through ObjectQL at all. So the injected `organization_id` column only ever held NULL. ADR-0131 D7 takes it off: each object now declares `systemFields: { tenant: false }`.
17+
18+
With no column there is no tenant wall, so these tables are governed by object permission. Each also declares `requiredPermissions: ['manage_platform_settings']`. Without that gate, a walled deployment's `organization_admin`, whose grant carries the superuser bits on every object, would read every other organization's job errors, queued payloads, dispatch keys and migration traces.
19+
20+
**What moves for consumers.**
21+
22+
- **The column.** `organization_id` is no longer a field of these seven objects. A filter, list-view column, report grouping, formula or seed key naming it on one of them is now an unknown field. Delete the reference: no organization owns a row of these tables.
23+
- **Who reads, on a walled posture** (`group` or `isolated`). Before: the wall compared the NULL column to the caller's organization, so every reader got zero rows, platform administrators included (unless the deployment declared the table platform-global, which stood the wall down). Now: a principal holding `manage_platform_settings` (platform administrators hold it) lists every row; anyone else is refused `403 PERMISSION_DENIED`.
24+
- **Who reads, on the `single` posture.** Before: any principal with a read grant on the object read every row, an organization administrator included. Now: only a principal holding `manage_platform_settings` reads; an organization administrator who is not a platform administrator is refused `403 PERMISSION_DENIED`. Grant the capability to an operator who needs these tables.
25+
26+
**Unchanged.** Every platform writer and reader of these tables uses a system context, which no capability gate applies to, so job scheduling, the queue, flow dispatch, migration flags and the migration journal behave as before. The physical unique indexes are unchanged: none of these objects declares an organization-scoped one.
27+
28+
**Existing databases.** Schema sync only adds, so the physical `organization_id` column stays on each existing table (with its index, where the deployment indexed it), and the boot drift report names it orphaned. By the writer census it holds only NULL, so dropping it loses nothing: `os migrate apply --allow-destructive` drops it, the remedy the drift report names.
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
---
2+
'@objectstack/objectql': patch
3+
---
4+
5+
fix(objectql): the lifecycle reaper and archiver no longer partition an object with no tenant column by organization
6+
7+
A tenant-scope `lifecycle.retention_overrides` entry gives one organization its own retention window, and the reaper and the archiver apply it by partitioning the object's rows on `organization_id`: one pass for that organization's rows, then a global pass for everyone else's. On an object that has no `organization_id` column — one declaring `systemFields: { tenant: false }`, such as the deployment-level platform tables (`sys_job`, `sys_job_run`, `sys_job_queue`, `sys_flow_dispatch`, `sys_migration`, `sys_migration_journal`, `sys_presence`), or any other object the registry injects no tenant column into and whose author declares none — both passes named a column the table does not have. The SQL driver refused them (`INVALID_FILTER`), the sweep reported the object in its errors, and the table's retention stopped.
8+
9+
Such an object now has no tenant partition, the answer a federated object already got: the sweep runs its one global pass at the global window. No row of it belongs to an organization, so a tenant override naming it has nothing to select, and it is not applied. An object that has the column keeps its per-tenant windows unchanged.

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

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -151,6 +151,12 @@ const READERS: Record<string, Row> = {
151151
disposition: 'skips',
152152
why: 'the column the partition predicates name, asked about before any partition is built',
153153
},
154+
'lifecycle/lifecycle-service.ts#tenantWindowsFor :: resolveInjectedColumnProvenance()': {
155+
disposition: 'skips',
156+
why:
157+
"an object with no organization_id at all (provenance 'absent': no injection, no declaration), " +
158+
'federated or local, has no tenant partition either',
159+
},
154160
'lifecycle/lifecycle-service.ts#reap :: organization_id': {
155161
disposition: 'skips',
156162
via: 'tenantWindowsFor',
Lines changed: 182 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,182 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* [#15207] An object with no tenant column has no tenant partition.
5+
*
6+
* ADR-0131 D7 takes the injected `organization_id` off the deployment-level
7+
* platform tables (`sys_job_run`, `sys_job_queue`, `sys_flow_dispatch` among
8+
* them): each declares `systemFields: { tenant: false }`, so the registry
9+
* injects no tenant column and the table is provisioned without one. An
10+
* operator can still store a tenant-scope `lifecycle.retention_overrides`
11+
* entry naming such a table. The reaper used to answer it with a per-tenant
12+
* window, so it partitioned the table on `organization_id`: the per-tenant
13+
* pass and the global pass's `$or` both named a column the table does not
14+
* have, the SQL driver refused both (`INVALID_FILTER`), and the table's
15+
* retention stopped. The federated case (#21918) had the same refusal for the
16+
* same reason, and the same answer: no column, no windows, one global pass.
17+
*
18+
* Every case runs on a REAL `ObjectQL` engine and registry, so the object the
19+
* sweep reads is the one the registry registered, after its system-field
20+
* injection. The stub driver provisions each table from that registered
21+
* object's fields, and refuses a filter on a column the table lacks, as the
22+
* SQL driver does.
23+
*/
24+
25+
import { describe, it, expect, vi } from 'vitest';
26+
import { ObjectQL } from '../engine.js';
27+
import { LifecycleService } from './lifecycle-service.js';
28+
import { parseLifecycleDuration } from './duration.js';
29+
30+
const FIXED_NOW = 1_700_000_000_000;
31+
const PACKAGE_ID = 'lifecycle-no-tenant-column';
32+
33+
/** A deployment-level table as ADR-0131 D7 declares it: no injected organization column. */
34+
const COLUMN_LESS = {
35+
name: 'sys_job_run',
36+
systemFields: { tenant: false },
37+
fields: { status: { type: 'text' } },
38+
lifecycle: { class: 'telemetry', retention: { maxAge: '30d' } },
39+
};
40+
41+
/** CONTROL: the same declaration without the opt-out, so the registry injects `organization_id`. */
42+
const WITH_COLUMN = {
43+
name: 'sys_job_run',
44+
fields: { status: { type: 'text' } },
45+
lifecycle: { class: 'telemetry', retention: { maxAge: '30d' } },
46+
};
47+
48+
const ORG_OBJECT = { name: 'sys_organization', fields: { name: { type: 'text' } } };
49+
50+
const isoCutoff = (literal: string) => new Date(FIXED_NOW - parseLifecycleDuration(literal)).toISOString();
51+
52+
/** One organization stores a tenant-scope override that keeps its rows three times longer. */
53+
function fakeSettings() {
54+
const tenantValues: Record<string, Record<string, unknown>> = {
55+
org_reg: { retention_overrides: { sys_job_run: { maxAge: '90d' } } },
56+
};
57+
return {
58+
async get(_ns: string, key: string, ctx?: Record<string, unknown>) {
59+
const tenantId = ctx?.tenantId as string | undefined;
60+
if (tenantId && tenantValues[tenantId] && key in tenantValues[tenantId]) {
61+
return { value: tenantValues[tenantId][key], source: 'tenant' };
62+
}
63+
return { value: undefined, source: 'default' };
64+
},
65+
};
66+
}
67+
68+
/** Every column a filter names: the non-operator keys, at any depth of `$or` / `$and`. */
69+
function filteredColumns(where: unknown): string[] {
70+
if (Array.isArray(where)) return where.flatMap(filteredColumns);
71+
if (!where || typeof where !== 'object') return [];
72+
return Object.entries(where as Record<string, unknown>).flatMap(([key, value]) =>
73+
key.startsWith('$') ? filteredColumns(value) : [key],
74+
);
75+
}
76+
77+
async function lifecycleEngine(object: Record<string, unknown>) {
78+
const engine = new ObjectQL();
79+
/** The table's columns: the REGISTERED object's fields, as schema sync provisions them, plus the key. */
80+
const columnsOf = (name: string): Set<string> => {
81+
const registered = engine.registry.getObject(name) as { fields?: Record<string, unknown> } | undefined;
82+
return new Set(['id', ...Object.keys(registered?.fields ?? {})]);
83+
};
84+
const driver = {
85+
name: 'memory',
86+
version: '0.0.0',
87+
supports: {},
88+
async connect() {}, async disconnect() {}, async checkHealth() { return true; },
89+
async execute() { return null; },
90+
async find(name: string, ast: { where?: unknown } | undefined) {
91+
if (name === 'sys_organization') return [{ id: 'org_reg' }];
92+
const columns = columnsOf(name);
93+
const missing = filteredColumns(ast?.where).find((column) => !columns.has(column));
94+
if (missing !== undefined) {
95+
throw Object.assign(
96+
new Error(`A filter on object '${name}' names a column the database could not resolve (${missing}).`),
97+
{ code: 'INVALID_FILTER', status: 400 },
98+
);
99+
}
100+
return [];
101+
},
102+
async findOne() { return null; },
103+
async count() { return 0; },
104+
async create(_name: string, data: Record<string, unknown>) { return { id: 'r_1', ...data }; },
105+
async update(_name: string, id: string, data: Record<string, unknown>) { return { id, ...data }; },
106+
async delete() { return true; },
107+
async bulkCreate(_name: string, rows: unknown[]) { return rows; },
108+
async bulkUpdate() { return []; },
109+
async bulkDelete() {},
110+
async syncSchema() {},
111+
};
112+
113+
engine.registerDriver(driver as unknown as Parameters<ObjectQL['registerDriver']>[0], true);
114+
await engine.init();
115+
engine.registry.registerObject(object as unknown as Parameters<ObjectQL['registry']['registerObject']>[0], PACKAGE_ID);
116+
engine.registry.registerObject(ORG_OBJECT as unknown as Parameters<ObjectQL['registry']['registerObject']>[0], PACKAGE_ID);
117+
118+
const find = vi.spyOn(engine, 'find');
119+
return {
120+
engine,
121+
/** The `where` of every candidate read the reaper issued through the engine. */
122+
reapReads: () =>
123+
find.mock.calls.filter((call) => call[0] === 'sys_job_run').map((call) => (call[1] as { where?: unknown } | undefined)?.where),
124+
};
125+
}
126+
127+
function sweepOnce(engine: ObjectQL) {
128+
return new LifecycleService({
129+
getEngine: () => engine,
130+
logger: { info: () => {}, warn: () => {}, debug: () => {} },
131+
now: () => FIXED_NOW,
132+
initialDelayMs: 1,
133+
sweepIntervalMs: 10,
134+
getSettings: () => fakeSettings(),
135+
referenceAudit: { enabled: false },
136+
}).sweep();
137+
}
138+
139+
describe('LifecycleService.sweep — an object with no tenant column has no tenant partition (#15207)', () => {
140+
it('premise: the registered object has no organization_id, and a filter on it is refused by the driver', async () => {
141+
const box = await lifecycleEngine(COLUMN_LESS);
142+
143+
const registered = box.engine.registry.getObject('sys_job_run') as { fields?: Record<string, unknown> } | undefined;
144+
expect(Object.keys(registered?.fields ?? {})).not.toContain('organization_id');
145+
const refusal = await box.engine
146+
.find('sys_job_run', { where: { organization_id: 'org_reg' }, context: { isSystem: true } })
147+
.then(() => undefined, (error: unknown) => error as { code?: unknown; status?: unknown });
148+
expect(refusal?.code).toBe('INVALID_FILTER');
149+
expect(refusal?.status).toBe(400);
150+
});
151+
152+
it('a tenant-scope retention override on a column-less object: one global pass, no INVALID_FILTER, no tenant window', async () => {
153+
const box = await lifecycleEngine(COLUMN_LESS);
154+
155+
const report = await sweepOnce(box.engine);
156+
157+
expect(report.errors).toEqual([]);
158+
// One pass at the declared window: the tenant's 90d override is not applied,
159+
// and no read names the column the table does not have.
160+
expect(box.reapReads()).toEqual([{ created_at: { $lt: isoCutoff('30d') } }]);
161+
expect(report.swept).toEqual([
162+
{ object: 'sys_job_run', class: 'telemetry', policy: 'retention', cutoff: isoCutoff('30d'), deleted: 0 },
163+
]);
164+
});
165+
166+
it('CONTROL: the same object WITH the injected column keeps its per-tenant window', async () => {
167+
const box = await lifecycleEngine(WITH_COLUMN);
168+
169+
const report = await sweepOnce(box.engine);
170+
171+
const registered = box.engine.registry.getObject('sys_job_run') as { fields?: Record<string, unknown> } | undefined;
172+
expect(Object.keys(registered?.fields ?? {})).toContain('organization_id');
173+
expect(report.errors).toEqual([]);
174+
expect(box.reapReads()).toEqual([
175+
{ created_at: { $lt: isoCutoff('90d') }, organization_id: 'org_reg' },
176+
{
177+
created_at: { $lt: isoCutoff('30d') },
178+
$or: [{ organization_id: { $nin: ['org_reg'] } }, { organization_id: null }],
179+
},
180+
]);
181+
});
182+
});

‎packages/objectql/src/lifecycle/lifecycle-service.ts‎

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
22

3-
import type { Lifecycle } from '@objectstack/spec/data';
3+
import { resolveInjectedColumnProvenance, type Lifecycle } from '@objectstack/spec/data';
44
import type { DriverQuery } from '@objectstack/spec/contracts';
55
import { isMissingTableError } from '@objectstack/metadata/errors';
66
import { redactPropagatedDriverFault } from '@objectstack/types';
@@ -1559,12 +1559,20 @@ export class LifecycleService {
15591559
* pass spells for them. A tenant override naming such an object has no row
15601560
* to select either way. An `organization_id` the author declared on a
15611561
* federated object maps a real remote column and keeps its partition.
1562+
*
1563+
* [#15207] An object that has no `organization_id` at all answers the same
1564+
* way: the registry injected none (`systemFields: { tenant: false }`, the
1565+
* ADR-0131 D7 deployment-level tables, or any other opt-out the injection
1566+
* plan honours) and the author declared none, so the provenance answers
1567+
* `'absent'`. Its table has no such column, so a partitioned pass is the
1568+
* same unknown-column refusal, and no row of it belongs to an organization.
15621569
*/
15631570
private tenantWindowsFor(
15641571
obj: LifecycleObjectLike,
15651572
overrideKey: 'maxAge' | 'expireAfter',
15661573
): Array<{ tenantId: string; maxAge?: string; expireAfter?: string }> {
15671574
if (isFederatedUnprovisionedInjectedColumn(obj, 'organization_id')) return [];
1575+
if (resolveInjectedColumnProvenance(obj, 'organization_id') === 'absent') return [];
15681576
return (this.governance.tenantOverrides.get(obj.name) ?? []).filter(
15691577
(t) => typeof t[overrideKey] === 'string',
15701578
);

‎packages/platform-objects/src/audit/sys-job-queue.object.ts‎

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,15 @@ export const SysJobQueue = ObjectSchema.create({
3434
icon: 'inbox',
3535
isSystem: true,
3636
managedBy: 'engine-owned',
37+
// [ADR-0131 D7] Deployment-level state: NO tenant column. The sole writer,
38+
// `DbQueueAdapter`, writes every row under a system context carrying no
39+
// organization, and no row it writes names one. Who may read is object
40+
// permission (D7): the platform-only capability below. This table holds
41+
// message PAYLOADS, so without the wall and without the gate a walled
42+
// deployment's `organization_admin` would read other organizations' queued
43+
// work.
44+
systemFields: { tenant: false },
45+
requiredPermissions: ['manage_platform_settings'],
3746

3847
/**
3948
* [ADR-0057 §3.1/§3.3, #5179] The queue table only ever GREW: the adapter

‎packages/platform-objects/src/audit/sys-job-run.object.ts‎

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,16 @@ export const SysJobRun = ObjectSchema.create({
2222
icon: 'play',
2323
isSystem: true,
2424
managedBy: 'append-only',
25+
// [ADR-0131 D7] Deployment-level state: NO tenant column. The sole writer,
26+
// `DbJobAdapter` (`startRun` / `finishRun`), writes under a system context
27+
// carrying no organization and no row names one — not even for a job that
28+
// declares the organization it runs as, whose stamp reaches the job's own
29+
// data writes, never this ledger. Who may read is object permission (D7):
30+
// the platform-only capability below, since without the wall a walled
31+
// deployment's `organization_admin` would otherwise read every
32+
// organization's run errors.
33+
systemFields: { tenant: false },
34+
requiredPermissions: ['manage_platform_settings'],
2535
// ADR-0057: run history is append-only telemetry. The platform
2636
// LifecycleService is the ONE sweeper for this window (the plugin-local
2737
// JobRunRetention it replaced kept the same 30d default).

‎packages/platform-objects/src/audit/sys-job.global-unique.test.ts‎

Lines changed: 16 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,8 @@ import { SysJob } from './sys-job.object.js';
5656
* incidentally (`organization_id` is kernel-injected, never authored), the
5757
* installation-wide constraint is correct, and the remedy is to state it —
5858
* plus correct the field `description`, which published the bare claim.
59+
* (Since ADR-0131 D7 the incidental column itself is gone — the writer fact
60+
* above is what removed it; the last assertion under "the reading" says so.)
5961
*
6062
* ## What this file pins, and why that is the point
6163
*
@@ -150,17 +152,22 @@ describe('sys_job — declared uniqueness is installation-wide (#8578)', () => {
150152
expect((flow as any).allowOrgOverride).toBe(false);
151153
});
152154

153-
it('carries an injected organization_id — so the scope is a real choice, not a default', () => {
154-
// `sys_job` IS tenant-scoped structurally (this is why the sweep flagged
155-
// it at all). The column exists; the verdict is that no writer ever
156-
// populates it per organization. Pinning this keeps the `'global'`
157-
// spelling an argued decision rather than an artifact of the column
158-
// being absent — and if the injection is ever switched off, the reading
159-
// above needs re-checking from a different direction (ADR-0120 S11).
155+
it("carries NO tenant column (ADR-0131 D7) — so `'global'` is the only scope that states the truth", () => {
156+
// This assertion used to pin the OPPOSITE: the column was injected, and
157+
// the `'global'` verdict was argued against it from the writer — no
158+
// writer ever populated it per organization. That same writer fact is
159+
// what ADR-0131 D7 turns into the column's removal
160+
// (`systemFields: { tenant: false }`), so the reading was re-checked
161+
// from the direction this comment asked for (ADR-0120 S11), and it
162+
// holds more strongly: with no tenant column, an `'organization'` scope
163+
// would silently degrade to the listed columns alone — identical DDL,
164+
// and a declaration claiming a per-organization boundary that does not
165+
// exist. `'global'` is now the only spelling that is true.
160166
const plan = resolveInjectedSystemColumns(SysJob);
161167
expect((SysJob as any).tenancy).toBeUndefined();
162-
expect(plan.tenant).toBe(true);
163-
expect(plan.names.has('organization_id')).toBe(true);
168+
expect((SysJob as any).systemFields).toEqual({ tenant: false });
169+
expect(plan.tenant).toBe(false);
170+
expect(plan.names.has('organization_id')).toBe(false);
164171
});
165172
});
166173

0 commit comments

Comments
 (0)