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
28 changes: 28 additions & 0 deletions .changeset/15207-deployment-plumbing-no-organization-column.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
---
'@objectstack/platform-objects': minor
'@objectstack/service-automation': minor
'@objectstack/service-realtime': minor
'@objectstack/spec': minor
---

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)

Clause-②: no (narrowing)

<!-- 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 -->

**BREAKING**, shipped as `minor` under the repo's launch-window convention for breaking changes (Changesets pre mode is not on yet).

`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 }`.

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.

**What moves for consumers.**

- **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.
- **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`.
- **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.

**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.

**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.
9 changes: 9 additions & 0 deletions .changeset/15207-lifecycle-no-tenant-column-no-partition.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
---
'@objectstack/objectql': patch
---

fix(objectql): the lifecycle reaper and archiver no longer partition an object with no tenant column by organization

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.

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.
Original file line number Diff line number Diff line change
Expand Up @@ -151,6 +151,12 @@ const READERS: Record<string, Row> = {
disposition: 'skips',
why: 'the column the partition predicates name, asked about before any partition is built',
},
'lifecycle/lifecycle-service.ts#tenantWindowsFor :: resolveInjectedColumnProvenance()': {
disposition: 'skips',
why:
"an object with no organization_id at all (provenance 'absent': no injection, no declaration), " +
'federated or local, has no tenant partition either',
},
'lifecycle/lifecycle-service.ts#reap :: organization_id': {
disposition: 'skips',
via: 'tenantWindowsFor',
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,182 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

/**
* [#15207] An object with no tenant column has no tenant partition.
*
* ADR-0131 D7 takes the injected `organization_id` off the deployment-level
* platform tables (`sys_job_run`, `sys_job_queue`, `sys_flow_dispatch` among
* them): each declares `systemFields: { tenant: false }`, so the registry
* injects no tenant column and the table is provisioned without one. An
* operator can still store a tenant-scope `lifecycle.retention_overrides`
* entry naming such a table. The reaper used to answer it with a per-tenant
* window, so it partitioned the table on `organization_id`: the per-tenant
* pass and the global pass's `$or` both named a column the table does not
* have, the SQL driver refused both (`INVALID_FILTER`), and the table's
* retention stopped. The federated case (#21918) had the same refusal for the
* same reason, and the same answer: no column, no windows, one global pass.
*
* Every case runs on a REAL `ObjectQL` engine and registry, so the object the
* sweep reads is the one the registry registered, after its system-field
* injection. The stub driver provisions each table from that registered
* object's fields, and refuses a filter on a column the table lacks, as the
* SQL driver does.
*/

import { describe, it, expect, vi } from 'vitest';
import { ObjectQL } from '../engine.js';
import { LifecycleService } from './lifecycle-service.js';
import { parseLifecycleDuration } from './duration.js';

const FIXED_NOW = 1_700_000_000_000;
const PACKAGE_ID = 'lifecycle-no-tenant-column';

/** A deployment-level table as ADR-0131 D7 declares it: no injected organization column. */
const COLUMN_LESS = {
name: 'sys_job_run',
systemFields: { tenant: false },
fields: { status: { type: 'text' } },
lifecycle: { class: 'telemetry', retention: { maxAge: '30d' } },
};

/** CONTROL: the same declaration without the opt-out, so the registry injects `organization_id`. */
const WITH_COLUMN = {
name: 'sys_job_run',
fields: { status: { type: 'text' } },
lifecycle: { class: 'telemetry', retention: { maxAge: '30d' } },
};

const ORG_OBJECT = { name: 'sys_organization', fields: { name: { type: 'text' } } };

const isoCutoff = (literal: string) => new Date(FIXED_NOW - parseLifecycleDuration(literal)).toISOString();

/** One organization stores a tenant-scope override that keeps its rows three times longer. */
function fakeSettings() {
const tenantValues: Record<string, Record<string, unknown>> = {
org_reg: { retention_overrides: { sys_job_run: { maxAge: '90d' } } },
};
return {
async get(_ns: string, key: string, ctx?: Record<string, unknown>) {
const tenantId = ctx?.tenantId as string | undefined;
if (tenantId && tenantValues[tenantId] && key in tenantValues[tenantId]) {
return { value: tenantValues[tenantId][key], source: 'tenant' };
}
return { value: undefined, source: 'default' };
},
};
}

/** Every column a filter names: the non-operator keys, at any depth of `$or` / `$and`. */
function filteredColumns(where: unknown): string[] {
if (Array.isArray(where)) return where.flatMap(filteredColumns);
if (!where || typeof where !== 'object') return [];
return Object.entries(where as Record<string, unknown>).flatMap(([key, value]) =>
key.startsWith('$') ? filteredColumns(value) : [key],
);
}

async function lifecycleEngine(object: Record<string, unknown>) {
const engine = new ObjectQL();
/** The table's columns: the REGISTERED object's fields, as schema sync provisions them, plus the key. */
const columnsOf = (name: string): Set<string> => {
const registered = engine.registry.getObject(name) as { fields?: Record<string, unknown> } | undefined;
return new Set(['id', ...Object.keys(registered?.fields ?? {})]);
};
const driver = {
name: 'memory',
version: '0.0.0',
supports: {},
async connect() {}, async disconnect() {}, async checkHealth() { return true; },
async execute() { return null; },
async find(name: string, ast: { where?: unknown } | undefined) {
if (name === 'sys_organization') return [{ id: 'org_reg' }];
const columns = columnsOf(name);
const missing = filteredColumns(ast?.where).find((column) => !columns.has(column));
if (missing !== undefined) {
throw Object.assign(
new Error(`A filter on object '${name}' names a column the database could not resolve (${missing}).`),
{ code: 'INVALID_FILTER', status: 400 },
);
}
return [];
},
async findOne() { return null; },
async count() { return 0; },
async create(_name: string, data: Record<string, unknown>) { return { id: 'r_1', ...data }; },
async update(_name: string, id: string, data: Record<string, unknown>) { return { id, ...data }; },
async delete() { return true; },
async bulkCreate(_name: string, rows: unknown[]) { return rows; },
async bulkUpdate() { return []; },
async bulkDelete() {},
async syncSchema() {},
};

engine.registerDriver(driver as unknown as Parameters<ObjectQL['registerDriver']>[0], true);
await engine.init();
engine.registry.registerObject(object as unknown as Parameters<ObjectQL['registry']['registerObject']>[0], PACKAGE_ID);
engine.registry.registerObject(ORG_OBJECT as unknown as Parameters<ObjectQL['registry']['registerObject']>[0], PACKAGE_ID);

const find = vi.spyOn(engine, 'find');
return {
engine,
/** The `where` of every candidate read the reaper issued through the engine. */
reapReads: () =>
find.mock.calls.filter((call) => call[0] === 'sys_job_run').map((call) => (call[1] as { where?: unknown } | undefined)?.where),
};
}

function sweepOnce(engine: ObjectQL) {
return new LifecycleService({
getEngine: () => engine,
logger: { info: () => {}, warn: () => {}, debug: () => {} },
now: () => FIXED_NOW,
initialDelayMs: 1,
sweepIntervalMs: 10,
getSettings: () => fakeSettings(),
referenceAudit: { enabled: false },
}).sweep();
}

describe('LifecycleService.sweep — an object with no tenant column has no tenant partition (#15207)', () => {
it('premise: the registered object has no organization_id, and a filter on it is refused by the driver', async () => {
const box = await lifecycleEngine(COLUMN_LESS);

const registered = box.engine.registry.getObject('sys_job_run') as { fields?: Record<string, unknown> } | undefined;
expect(Object.keys(registered?.fields ?? {})).not.toContain('organization_id');
const refusal = await box.engine
.find('sys_job_run', { where: { organization_id: 'org_reg' }, context: { isSystem: true } })
.then(() => undefined, (error: unknown) => error as { code?: unknown; status?: unknown });
expect(refusal?.code).toBe('INVALID_FILTER');
expect(refusal?.status).toBe(400);
});

it('a tenant-scope retention override on a column-less object: one global pass, no INVALID_FILTER, no tenant window', async () => {
const box = await lifecycleEngine(COLUMN_LESS);

const report = await sweepOnce(box.engine);

expect(report.errors).toEqual([]);
// One pass at the declared window: the tenant's 90d override is not applied,
// and no read names the column the table does not have.
expect(box.reapReads()).toEqual([{ created_at: { $lt: isoCutoff('30d') } }]);
expect(report.swept).toEqual([
{ object: 'sys_job_run', class: 'telemetry', policy: 'retention', cutoff: isoCutoff('30d'), deleted: 0 },
]);
});

it('CONTROL: the same object WITH the injected column keeps its per-tenant window', async () => {
const box = await lifecycleEngine(WITH_COLUMN);

const report = await sweepOnce(box.engine);

const registered = box.engine.registry.getObject('sys_job_run') as { fields?: Record<string, unknown> } | undefined;
expect(Object.keys(registered?.fields ?? {})).toContain('organization_id');
expect(report.errors).toEqual([]);
expect(box.reapReads()).toEqual([
{ created_at: { $lt: isoCutoff('90d') }, organization_id: 'org_reg' },
{
created_at: { $lt: isoCutoff('30d') },
$or: [{ organization_id: { $nin: ['org_reg'] } }, { organization_id: null }],
},
]);
});
});
10 changes: 9 additions & 1 deletion packages/objectql/src/lifecycle/lifecycle-service.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.

import type { Lifecycle } from '@objectstack/spec/data';
import { resolveInjectedColumnProvenance, type Lifecycle } from '@objectstack/spec/data';
import type { DriverQuery } from '@objectstack/spec/contracts';
import { isMissingTableError } from '@objectstack/metadata/errors';
import { redactPropagatedDriverFault } from '@objectstack/types';
Expand Down Expand Up @@ -1559,12 +1559,20 @@ export class LifecycleService {
* pass spells for them. A tenant override naming such an object has no row
* to select either way. An `organization_id` the author declared on a
* federated object maps a real remote column and keeps its partition.
*
* [#15207] An object that has no `organization_id` at all answers the same
* way: the registry injected none (`systemFields: { tenant: false }`, the
* ADR-0131 D7 deployment-level tables, or any other opt-out the injection
* plan honours) and the author declared none, so the provenance answers
* `'absent'`. Its table has no such column, so a partitioned pass is the
* same unknown-column refusal, and no row of it belongs to an organization.
*/
private tenantWindowsFor(
obj: LifecycleObjectLike,
overrideKey: 'maxAge' | 'expireAfter',
): Array<{ tenantId: string; maxAge?: string; expireAfter?: string }> {
if (isFederatedUnprovisionedInjectedColumn(obj, 'organization_id')) return [];
if (resolveInjectedColumnProvenance(obj, 'organization_id') === 'absent') return [];
return (this.governance.tenantOverrides.get(obj.name) ?? []).filter(
(t) => typeof t[overrideKey] === 'string',
);
Expand Down
9 changes: 9 additions & 0 deletions packages/platform-objects/src/audit/sys-job-queue.object.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,15 @@ export const SysJobQueue = ObjectSchema.create({
icon: 'inbox',
isSystem: true,
managedBy: 'engine-owned',
// [ADR-0131 D7] Deployment-level state: NO tenant column. The sole writer,
// `DbQueueAdapter`, writes every row under a system context carrying no
// organization, and no row it writes names one. Who may read is object
// permission (D7): the platform-only capability below. This table holds
// message PAYLOADS, so without the wall and without the gate a walled
// deployment's `organization_admin` would read other organizations' queued
// work.
systemFields: { tenant: false },
requiredPermissions: ['manage_platform_settings'],

/**
* [ADR-0057 §3.1/§3.3, #5179] The queue table only ever GREW: the adapter
Expand Down
10 changes: 10 additions & 0 deletions packages/platform-objects/src/audit/sys-job-run.object.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,16 @@ export const SysJobRun = ObjectSchema.create({
icon: 'play',
isSystem: true,
managedBy: 'append-only',
// [ADR-0131 D7] Deployment-level state: NO tenant column. The sole writer,
// `DbJobAdapter` (`startRun` / `finishRun`), writes under a system context
// carrying no organization and no row names one — not even for a job that
// declares the organization it runs as, whose stamp reaches the job's own
// data writes, never this ledger. Who may read is object permission (D7):
// the platform-only capability below, since without the wall a walled
// deployment's `organization_admin` would otherwise read every
// organization's run errors.
systemFields: { tenant: false },
requiredPermissions: ['manage_platform_settings'],
// ADR-0057: run history is append-only telemetry. The platform
// LifecycleService is the ONE sweeper for this window (the plugin-local
// JobRunRetention it replaced kept the same 30d default).
Expand Down
25 changes: 16 additions & 9 deletions packages/platform-objects/src/audit/sys-job.global-unique.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,8 @@ import { SysJob } from './sys-job.object.js';
* incidentally (`organization_id` is kernel-injected, never authored), the
* installation-wide constraint is correct, and the remedy is to state it —
* plus correct the field `description`, which published the bare claim.
* (Since ADR-0131 D7 the incidental column itself is gone — the writer fact
* above is what removed it; the last assertion under "the reading" says so.)
*
* ## What this file pins, and why that is the point
*
Expand Down Expand Up @@ -150,17 +152,22 @@ describe('sys_job — declared uniqueness is installation-wide (#8578)', () => {
expect((flow as any).allowOrgOverride).toBe(false);
});

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

Expand Down
Loading
Loading