diff --git a/.changeset/15207-deployment-plumbing-no-organization-column.md b/.changeset/15207-deployment-plumbing-no-organization-column.md new file mode 100644 index 00000000000..126fbaef36f --- /dev/null +++ b/.changeset/15207-deployment-plumbing-no-organization-column.md @@ -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) + + + +**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. diff --git a/.changeset/15207-lifecycle-no-tenant-column-no-partition.md b/.changeset/15207-lifecycle-no-tenant-column-no-partition.md new file mode 100644 index 00000000000..6a413a92f32 --- /dev/null +++ b/.changeset/15207-lifecycle-no-tenant-column-no-partition.md @@ -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. diff --git a/packages/objectql/src/federated-injected-column-readers.test.ts b/packages/objectql/src/federated-injected-column-readers.test.ts index f41dd735485..665fb1c9f1d 100644 --- a/packages/objectql/src/federated-injected-column-readers.test.ts +++ b/packages/objectql/src/federated-injected-column-readers.test.ts @@ -151,6 +151,12 @@ const READERS: Record = { 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', diff --git a/packages/objectql/src/lifecycle/lifecycle-service.no-tenant-column.test.ts b/packages/objectql/src/lifecycle/lifecycle-service.no-tenant-column.test.ts new file mode 100644 index 00000000000..a784e857655 --- /dev/null +++ b/packages/objectql/src/lifecycle/lifecycle-service.no-tenant-column.test.ts @@ -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> = { + org_reg: { retention_overrides: { sys_job_run: { maxAge: '90d' } } }, + }; + return { + async get(_ns: string, key: string, ctx?: Record) { + 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).flatMap(([key, value]) => + key.startsWith('$') ? filteredColumns(value) : [key], + ); +} + +async function lifecycleEngine(object: Record) { + 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 => { + const registered = engine.registry.getObject(name) as { fields?: Record } | 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) { return { id: 'r_1', ...data }; }, + async update(_name: string, id: string, data: Record) { 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[0], true); + await engine.init(); + engine.registry.registerObject(object as unknown as Parameters[0], PACKAGE_ID); + engine.registry.registerObject(ORG_OBJECT as unknown as Parameters[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 } | 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 } | 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 }], + }, + ]); + }); +}); diff --git a/packages/objectql/src/lifecycle/lifecycle-service.ts b/packages/objectql/src/lifecycle/lifecycle-service.ts index 1b78524d3f2..f9a2aa01061 100644 --- a/packages/objectql/src/lifecycle/lifecycle-service.ts +++ b/packages/objectql/src/lifecycle/lifecycle-service.ts @@ -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'; @@ -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', ); diff --git a/packages/platform-objects/src/audit/sys-job-queue.object.ts b/packages/platform-objects/src/audit/sys-job-queue.object.ts index 944483cb2bd..b145a722d9e 100644 --- a/packages/platform-objects/src/audit/sys-job-queue.object.ts +++ b/packages/platform-objects/src/audit/sys-job-queue.object.ts @@ -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 diff --git a/packages/platform-objects/src/audit/sys-job-run.object.ts b/packages/platform-objects/src/audit/sys-job-run.object.ts index f8d52040a90..f55bb34903d 100644 --- a/packages/platform-objects/src/audit/sys-job-run.object.ts +++ b/packages/platform-objects/src/audit/sys-job-run.object.ts @@ -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). diff --git a/packages/platform-objects/src/audit/sys-job.global-unique.test.ts b/packages/platform-objects/src/audit/sys-job.global-unique.test.ts index e142fc126be..ab34a3bb865 100644 --- a/packages/platform-objects/src/audit/sys-job.global-unique.test.ts +++ b/packages/platform-objects/src/audit/sys-job.global-unique.test.ts @@ -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 * @@ -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); }); }); diff --git a/packages/platform-objects/src/audit/sys-job.object.ts b/packages/platform-objects/src/audit/sys-job.object.ts index a7dcf605f9b..457b25c9ef9 100644 --- a/packages/platform-objects/src/audit/sys-job.object.ts +++ b/packages/platform-objects/src/audit/sys-job.object.ts @@ -22,6 +22,19 @@ export const SysJob = ObjectSchema.create({ icon: 'clock', isSystem: true, managedBy: 'engine-owned', + // [ADR-0131 D7] Deployment-level state: NO tenant column. The writer + // decides membership, not the name — the sole writer, `DbJobAdapter`, writes + // every row under a system context carrying no organization, and no row it + // writes names one, so the injected column only ever held NULL. + // `systemFields.tenant: false` is the opt-out that removes the INJECTED + // column (see `sys_metadata_activation` for why not `tenancy.enabled`). + // + // With no column there is no tenant wall either, so who may read is decided + // by object permission (D7): the platform-only capability below. Without it, + // a walled deployment's `organization_admin` — whose `'*'` grant carries the + // superuser bits — would read every organization's job errors here. + systemFields: { tenant: false }, + requiredPermissions: ['manage_platform_settings'], description: 'Catalogue of registered background jobs', displayNameField: 'name', nameField: 'name', // [ADR-0079] canonical primary-title pointer (mirrors deprecated displayNameField) diff --git a/packages/platform-objects/src/deployment-level-no-tenant-column.test.ts b/packages/platform-objects/src/deployment-level-no-tenant-column.test.ts new file mode 100644 index 00000000000..49ca728424e --- /dev/null +++ b/packages/platform-objects/src/deployment-level-no-tenant-column.test.ts @@ -0,0 +1,88 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +import { describe, it, expect } from 'vitest'; +import { resolveInjectedSystemColumns } from '@objectstack/spec/data'; +import { PLATFORM_CAPABILITIES } from '@objectstack/spec/security'; +import { SysJob, SysJobRun, SysJobQueue } from './audit/index.js'; +import { SysMigration, SysMigrationJournal, SysSecret } from './system/index.js'; + +/** + * [ADR-0131 D7] The deployment-level plumbing this package declares carries no + * tenant column, and reads of it are platform-only. + * + * ## Why the column is asserted through the injection plan + * + * The tenant column is INJECTED at registration, never authored, so asserting + * on `fields` alone is a phantom check: an object that merely omits the field + * still gets the column. `resolveInjectedSystemColumns` is the derivation + * `applySystemFields` consumes to do the injecting, so it is the authority on + * whether the column exists. The `sys_secret` control below is the same + * question asked of an object that DOES keep the column (its writer stamps the + * caller's organization), so the predicate is shown able to answer `true`. + * + * ## Why the capability is pinned beside the column + * + * The two are one decision. With no column there is no tenant wall, and a + * walled deployment's `organization_admin` holds a `'*'` grant carrying the + * superuser bits — so without an object-level capability gate, removing the + * column would hand every organization's admin every other organization's + * job errors, queued payloads and migration traces. D7 governs these objects + * by object permission; this is that permission, and losing it is a security + * regression, not a tidy-up. + */ +const DEPLOYMENT_LEVEL = [ + ['sys_job', SysJob], + ['sys_job_run', SysJobRun], + ['sys_job_queue', SysJobQueue], + ['sys_migration', SysMigration], + ['sys_migration_journal', SysMigrationJournal], +] as const; + +describe('[ADR-0131 D7] deployment-level plumbing: no tenant column, platform-only reads', () => { + for (const [name, object] of DEPLOYMENT_LEVEL) { + describe(name, () => { + it('is the object this row names', () => { + expect(object.name).toBe(name); + }); + + it('carries no tenant column — neither injected nor declared', () => { + const plan = resolveInjectedSystemColumns(object); + expect(plan.tenant).toBe(false); + expect([...plan.names]).not.toContain('organization_id'); + expect(Object.keys(object.fields ?? {})).not.toContain('organization_id'); + // Anti-vacuity: the plan still injects the audit family here, so an + // empty name set cannot pass the assertions above by saying nothing. + expect([...plan.names]).toEqual(expect.arrayContaining(['id', 'created_at'])); + }); + + it('opts out through `systemFields.tenant`, not the platform-global posture', () => { + expect((object as { systemFields?: unknown }).systemFields).toEqual({ tenant: false }); + expect((object as { tenancy?: unknown }).tenancy).toBeUndefined(); + // Per-object control: the same declaration WITHOUT the opt-out gets the + // column, so the `false` above is the opt-out's doing. + const { systemFields: _optOut, ...withoutOptOut } = object as Record; + expect(resolveInjectedSystemColumns(withoutOptOut).tenant).toBe(true); + }); + + it('gates every read on the platform-only capability', () => { + expect((object as { requiredPermissions?: unknown }).requiredPermissions).toEqual([ + 'manage_platform_settings', + ]); + }); + }); + } + + it('the gating capability is platform-scoped, so no tenant administrator holds it by role', () => { + const cap = PLATFORM_CAPABILITIES.find((c) => c.name === 'manage_platform_settings'); + expect(cap?.scope).toBe('platform'); + }); + + it('control: a tenant-attributed object keeps the column, so the predicate can answer true', () => { + // `sys_secret` is written with the business write's driver options, so the + // SQL driver stamps the caller's organization on it. It is NOT + // deployment-level today; its fate is decided by the C7 inventory. + const plan = resolveInjectedSystemColumns(SysSecret); + expect(plan.tenant).toBe(true); + expect([...plan.names]).toContain('organization_id'); + }); +}); diff --git a/packages/platform-objects/src/system/sys-migration-journal.object.ts b/packages/platform-objects/src/system/sys-migration-journal.object.ts index 6a46f5b97b3..9e2a0f483aa 100644 --- a/packages/platform-objects/src/system/sys-migration-journal.object.ts +++ b/packages/platform-objects/src/system/sys-migration-journal.object.ts @@ -69,6 +69,14 @@ export const SysMigrationJournal = ObjectSchema.create({ icon: 'clipboard-list', isSystem: true, managedBy: 'engine-owned', + // [ADR-0131 D7] Deployment-level state: NO tenant column. The sole writer, + // `@objectstack/core`'s migration runner, appends under a system context — + // or under the transaction it opened with one — and the row contract + // (`MigrationJournalEventSchema`) has no organization field to carry. Who + // may read is object permission (D7): the platform-only capability below, + // since a failure `detail` is error text about rows of any organization. + systemFields: { tenant: false }, + requiredPermissions: ['manage_platform_settings'], description: 'Append-only trace of migration runs: which chunks committed, which were compensated, and where a killed run stopped.', nameField: 'run_id', // [ADR-0079] canonical primary-title pointer diff --git a/packages/platform-objects/src/system/sys-migration.object.ts b/packages/platform-objects/src/system/sys-migration.object.ts index ec2740e1330..5bbd2ad5b3b 100644 --- a/packages/platform-objects/src/system/sys-migration.object.ts +++ b/packages/platform-objects/src/system/sys-migration.object.ts @@ -54,6 +54,14 @@ export const SysMigration = ObjectSchema.create({ icon: 'database', isSystem: true, managedBy: 'engine-owned', + // [ADR-0131 D7] Deployment-level state: NO tenant column. Every writer + // (`migration-flag.ts`, the engine's lax-deviation and boot-admission + // revocation writes, the seed-tenancy, membership and flow-credential + // receipts) writes under a system context, and the row contract + // (`DataMigrationFlagSchema`) has no organization field to carry. Who may + // read is object permission (D7): the platform-only capability below. + systemFields: { tenant: false }, + requiredPermissions: ['manage_platform_settings'], description: 'Deployment-level data-migration flags: which gated data migrations ran here and whether their self-check passed.', nameField: 'id', // [ADR-0079] canonical primary-title pointer titleFormat: '{id}', diff --git a/packages/services/service-automation/src/sys-flow-dispatch.object.test.ts b/packages/services/service-automation/src/sys-flow-dispatch.object.test.ts new file mode 100644 index 00000000000..b41ef6133d5 --- /dev/null +++ b/packages/services/service-automation/src/sys-flow-dispatch.object.test.ts @@ -0,0 +1,51 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +import { describe, it, expect } from 'vitest'; +import { resolveInjectedSystemColumns } from '@objectstack/spec/data'; +import { SysFlowDispatch } from './sys-flow-dispatch.object.js'; +import { SysAutomationRun } from './sys-automation-run.object.js'; + +/** + * [ADR-0131 D7] `sys_flow_dispatch` is deployment-level: no tenant column, and + * reads are platform-only. + * + * The column is asserted through `resolveInjectedSystemColumns` because it is + * INJECTED at registration — a `fields` check alone would pass on an object + * that merely omits it. The capability is pinned beside it because the two are + * one decision: with no column there is no tenant wall, so without the gate a + * walled deployment's `organization_admin` (a `'*'` grant with the superuser + * bits) would read every organization's dispatch keys. + * + * Control: `sys_automation_run`, whose writer stamps the run's organization, + * keeps the column — the predicate is shown able to answer `true`. + */ +describe('[ADR-0131 D7] sys_flow_dispatch — deployment-level', () => { + it('carries no tenant column — neither injected nor declared', () => { + const plan = resolveInjectedSystemColumns(SysFlowDispatch); + expect(plan.tenant).toBe(false); + expect([...plan.names]).not.toContain('organization_id'); + expect(Object.keys(SysFlowDispatch.fields ?? {})).not.toContain('organization_id'); + expect([...plan.names]).toEqual(expect.arrayContaining(['id', 'created_at'])); + }); + + it('opts out through `systemFields.tenant`, not the platform-global posture', () => { + expect((SysFlowDispatch as { systemFields?: unknown }).systemFields).toEqual({ tenant: false }); + expect((SysFlowDispatch as { tenancy?: unknown }).tenancy).toBeUndefined(); + // The same declaration WITHOUT the opt-out gets the column, so the + // `false` above is the opt-out's doing. + const { systemFields: _optOut, ...withoutOptOut } = SysFlowDispatch as Record; + expect(resolveInjectedSystemColumns(withoutOptOut).tenant).toBe(true); + }); + + it('gates every read on the platform-only capability', () => { + expect((SysFlowDispatch as { requiredPermissions?: unknown }).requiredPermissions).toEqual([ + 'manage_platform_settings', + ]); + }); + + it('control: the tenant-attributed run ledger keeps the column', () => { + const plan = resolveInjectedSystemColumns(SysAutomationRun); + expect(plan.tenant).toBe(true); + expect([...plan.names]).toContain('organization_id'); + }); +}); diff --git a/packages/services/service-automation/src/sys-flow-dispatch.object.ts b/packages/services/service-automation/src/sys-flow-dispatch.object.ts index b4e657df5d5..f27ed0d22c0 100644 --- a/packages/services/service-automation/src/sys-flow-dispatch.object.ts +++ b/packages/services/service-automation/src/sys-flow-dispatch.object.ts @@ -75,6 +75,15 @@ export const SysFlowDispatch = ObjectSchema.create({ icon: 'repeat', isSystem: true, managedBy: 'engine-owned', + // [ADR-0131 D7] Deployment-level state: NO tenant column. The sole writer, + // `ObjectStoreFlowDispatchStore` (`claim` / `settle`), writes under a system + // context carrying no organization, and a row is only a dispatch key and its + // outcome. Who may read is object permission (D7): the platform-only + // capability below — a key names the record a dispatch was for, so without + // the wall a walled deployment's `organization_admin` would otherwise read + // every organization's dispatch keys. + systemFields: { tenant: false }, + requiredPermissions: ['manage_platform_settings'], // ADR-0057: pure telemetry — every row's key embeds the one sweep day it can // be claimed on, so rows have no read value after the window passes. 30-day // retention per the #10220 ruling (>= the cloud#1288 catch-up horizon). diff --git a/packages/services/service-realtime/src/objects/sys-presence.object.test.ts b/packages/services/service-realtime/src/objects/sys-presence.object.test.ts index 8b18ac8b08f..63dd48b448d 100644 --- a/packages/services/service-realtime/src/objects/sys-presence.object.test.ts +++ b/packages/services/service-realtime/src/objects/sys-presence.object.test.ts @@ -3,6 +3,7 @@ import { describe, it, expect } from 'vitest'; import { SysPresence } from './sys-presence.object.js'; import { StorageNameMapping } from '@objectstack/spec/system'; +import { resolveInjectedSystemColumns } from '@objectstack/spec/data'; describe('SysPresence object definition', () => { it('should use the literal sys_presence short name', () => { @@ -77,6 +78,32 @@ describe('SysPresence object definition', () => { expect(SysPresence.enable?.apiEnabled).toBe(true); }); + // [ADR-0131 D7] Deployment-level: nothing writes this table through ObjectQL, + // so no writer attributes a row to an organization. The column is asserted + // through `resolveInjectedSystemColumns` because it is INJECTED at + // registration — a `fields` check alone would pass on an object that merely + // omits it. The capability is the same decision: with no column there is no + // tenant wall, so the gate is what keeps one organization's admin off + // another organization's presence rows on a walled deployment. + it('carries no tenant column — neither injected nor declared (ADR-0131 D7)', () => { + const plan = resolveInjectedSystemColumns(SysPresence); + expect(plan.tenant).toBe(false); + expect([...plan.names]).not.toContain('organization_id'); + expect(Object.keys(SysPresence.fields)).not.toContain('organization_id'); + expect((SysPresence as { systemFields?: unknown }).systemFields).toEqual({ tenant: false }); + expect((SysPresence as { tenancy?: unknown }).tenancy).toBeUndefined(); + // Control: the same object WITHOUT the opt-out gets the column, so the + // predicate above can answer `true` and its `false` is a measurement. + const { systemFields: _optOut, ...withoutOptOut } = SysPresence as Record; + expect(resolveInjectedSystemColumns(withoutOptOut).tenant).toBe(true); + }); + + it('gates every read on the platform-only capability (ADR-0131 D7)', () => { + expect((SysPresence as { requiredPermissions?: unknown }).requiredPermissions).toEqual([ + 'manage_platform_settings', + ]); + }); + it('exposes reads only — append-only, written over the realtime path, never /data (#3220)', () => { expect(SysPresence.enable?.apiMethods).toEqual(['get', 'list']); // Guard against re-introducing a generic write verb on an append-only object. diff --git a/packages/services/service-realtime/src/objects/sys-presence.object.ts b/packages/services/service-realtime/src/objects/sys-presence.object.ts index 04530921b7c..76a38052ade 100644 --- a/packages/services/service-realtime/src/objects/sys-presence.object.ts +++ b/packages/services/service-realtime/src/objects/sys-presence.object.ts @@ -22,6 +22,14 @@ export const SysPresence = ObjectSchema.create({ icon: 'wifi', isSystem: true, managedBy: 'append-only', + // [ADR-0131 D7] Deployment-level state: NO tenant column. Nothing writes + // this table through ObjectQL (presence travels the realtime path), so no + // writer attributes a row to an organization — and a person present in + // several organizations is one person. Who may read is object permission + // (D7): the platform-only capability below, so the generic data door cannot + // hand one organization's admin another organization's presence rows. + systemFields: { tenant: false }, + requiredPermissions: ['manage_platform_settings'], description: 'Real-time user presence and activity tracking', // [ADR-0079] The record title is `display_title`, a text formula over the // same two columns `titleFormat` names. With no pointer declared, the diff --git a/packages/spec/src/migrations/entries/semantic/18.sys-flow-dispatch-organization-column-retired.ts b/packages/spec/src/migrations/entries/semantic/18.sys-flow-dispatch-organization-column-retired.ts new file mode 100644 index 00000000000..9dcd8957126 --- /dev/null +++ b/packages/spec/src/migrations/entries/semantic/18.sys-flow-dispatch-organization-column-retired.ts @@ -0,0 +1,50 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +import type { SemanticMigration } from '../../types.js'; + +// #15207 (ADR-0131 D7, C6) — one D3 entry per removed column, as the card +// requires. A platform-object COLUMN retirement, not a spec-key retirement: no +// authorable spec key moves, so nothing lands in RETIRED_KEYS_BY_MAJOR and no +// D2 conversion exists to pair with (the ups-delegated-from-column-retired +// shape). The writer census behind the verdict is cited in the reason. +export const entry: SemanticMigration = { + id: 'sys-flow-dispatch-organization-column-retired', + // No backticks in `surface` — build-upgrade-guide.ts renders it inside a + // code span AND a table cell. + surface: + 'sys_flow_dispatch.organization_id — the injected organization column left the flow trigger ' + + 'dispatch claim ledger (packages/services/service-automation/src/sys-flow-dispatch.object.ts, ' + + 'which now declares systemFields.tenant false), and reading the table now requires the ' + + 'manage_platform_settings capability', + replacement: + 'nothing on this table — `sys_flow_dispatch` is deployment-level state (ADR-0131 D7) and no ' + + 'organization owns a row. Delete any authored filter, list-view column, report grouping, ' + + 'formula or seed key that names `organization_id` on `sys_flow_dispatch`. A principal that ' + + 'must read the table needs the `manage_platform_settings` capability, which platform ' + + 'administrators hold', + reason: + 'ADR-0131 D7: a table whose rows no writer attributes to an organization is deployment-level ' + + 'and loses the column; the writer decides membership, not the name. Writer census at commit ' + + 'e67ba80049 of this repository\'s main branch: the sole writer is ObjectStoreFlowDispatchStore ' + + 'in @objectstack/service-automation: two write sites (the claim insert and the settle ' + + 'update), each under a system context whose row is a dispatch key and its outcome, naming no ' + + 'organization. So the injected column only ever held NULL. The census is the same procedure ' + + 'that reports the organization-stamping writers of sys_http_delivery, sys_secret and ' + + 'sys_email, so it can fire. Under a walled posture the tenant wall compared that NULL to the ' + + 'caller organization and hid every row from every reader, platform administrators included; ' + + 'with no column there is no wall, and the table is governed by object permission instead ' + + '(D7). The capability gate is part of the same change, not a follow-up: the ' + + 'organization_admin grant carries the superuser bits on every object, so without it a walled ' + + 'deployment would hand each organization administrator every other organization\'s rows. ' + + 'Existing databases: schema sync is additive, so the physical column stays and the boot drift ' + + 'report names it orphaned; by the census it holds only NULL, so dropping it loses nothing. ' + + 'The operator drops it with os migrate apply --allow-destructive, the remedy the drift report ' + + 'names.', + acceptanceCriteria: + 'No authored metadata names `organization_id` on `sys_flow_dispatch`: the field resolver ' + + '(lint and the data door) now answers it as an unknown field. On every tenancy posture a ' + + 'principal without `manage_platform_settings` is refused 403 PERMISSION_DENIED on a read of ' + + '`sys_flow_dispatch`, and a platform administrator lists every row with no organization ' + + 'filter. After os migrate apply --allow-destructive the boot no longer reports the orphaned ' + + 'column.', +}; diff --git a/packages/spec/src/migrations/entries/semantic/18.sys-job-organization-column-retired.ts b/packages/spec/src/migrations/entries/semantic/18.sys-job-organization-column-retired.ts new file mode 100644 index 00000000000..e31e65446b8 --- /dev/null +++ b/packages/spec/src/migrations/entries/semantic/18.sys-job-organization-column-retired.ts @@ -0,0 +1,49 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +import type { SemanticMigration } from '../../types.js'; + +// #15207 (ADR-0131 D7, C6) — one D3 entry per removed column, as the card +// requires. A platform-object COLUMN retirement, not a spec-key retirement: no +// authorable spec key moves, so nothing lands in RETIRED_KEYS_BY_MAJOR and no +// D2 conversion exists to pair with (the ups-delegated-from-column-retired +// shape). The writer census behind the verdict is cited in the reason. +export const entry: SemanticMigration = { + id: 'sys-job-organization-column-retired', + // No backticks in `surface` — build-upgrade-guide.ts renders it inside a + // code span AND a table cell. + surface: + 'sys_job.organization_id — the injected organization column left the platform background-job ' + + 'catalogue (packages/platform-objects/src/audit/sys-job.object.ts, which now declares ' + + 'systemFields.tenant false), and reading the table now requires the manage_platform_settings ' + + 'capability', + replacement: + 'nothing on this table — `sys_job` is deployment-level state (ADR-0131 D7) and no ' + + 'organization owns a row. Delete any authored filter, list-view column, report grouping, ' + + 'formula or seed key that names `organization_id` on `sys_job`. A principal that must read ' + + 'the table needs the `manage_platform_settings` capability, which platform administrators ' + + 'hold', + reason: + 'ADR-0131 D7: a table whose rows no writer attributes to an organization is deployment-level ' + + 'and loses the column; the writer decides membership, not the name. Writer census at commit ' + + 'e67ba80049 of this repository\'s main branch: the sole writer is DbJobAdapter in ' + + '@objectstack/service-job: four write sites (the update and insert arms of the schedule ' + + 'upsert, the active toggle and the run summary bump), each under a system context whose row ' + + 'literal names no organization. So the injected column only ever held NULL. The census is the ' + + 'same procedure that reports the organization-stamping writers of sys_http_delivery, ' + + 'sys_secret and sys_email, so it can fire. Under a walled posture the tenant wall compared ' + + 'that NULL to the caller organization and hid every row from every reader, platform ' + + 'administrators included; with no column there is no wall, and the table is governed by ' + + 'object permission instead (D7). The capability gate is part of the same change, not a ' + + 'follow-up: the organization_admin grant carries the superuser bits on every object, so ' + + 'without it a walled deployment would hand each organization administrator every other ' + + 'organization\'s rows. Existing databases: schema sync is additive, so the physical column ' + + 'stays and the boot drift report names it orphaned; by the census it holds only NULL, so ' + + 'dropping it loses nothing. The operator drops it with os migrate apply --allow-destructive, ' + + 'the remedy the drift report names.', + acceptanceCriteria: + 'No authored metadata names `organization_id` on `sys_job`: the field resolver (lint and the ' + + 'data door) now answers it as an unknown field. On every tenancy posture a principal without ' + + '`manage_platform_settings` is refused 403 PERMISSION_DENIED on a read of `sys_job`, and a ' + + 'platform administrator lists every row with no organization filter. After os migrate apply ' + + '--allow-destructive the boot no longer reports the orphaned column.', +}; diff --git a/packages/spec/src/migrations/entries/semantic/18.sys-job-queue-organization-column-retired.ts b/packages/spec/src/migrations/entries/semantic/18.sys-job-queue-organization-column-retired.ts new file mode 100644 index 00000000000..f957f489bbd --- /dev/null +++ b/packages/spec/src/migrations/entries/semantic/18.sys-job-queue-organization-column-retired.ts @@ -0,0 +1,48 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +import type { SemanticMigration } from '../../types.js'; + +// #15207 (ADR-0131 D7, C6) — one D3 entry per removed column, as the card +// requires. A platform-object COLUMN retirement, not a spec-key retirement: no +// authorable spec key moves, so nothing lands in RETIRED_KEYS_BY_MAJOR and no +// D2 conversion exists to pair with (the ups-delegated-from-column-retired +// shape). The writer census behind the verdict is cited in the reason. +export const entry: SemanticMigration = { + id: 'sys-job-queue-organization-column-retired', + // No backticks in `surface` — build-upgrade-guide.ts renders it inside a + // code span AND a table cell. + surface: + 'sys_job_queue.organization_id — the injected organization column left the durable job and ' + + 'message queue (packages/platform-objects/src/audit/sys-job-queue.object.ts, which now ' + + 'declares systemFields.tenant false), and reading the table now requires the ' + + 'manage_platform_settings capability', + replacement: + 'nothing on this table — `sys_job_queue` is deployment-level state (ADR-0131 D7) and no ' + + 'organization owns a row. Delete any authored filter, list-view column, report grouping, ' + + 'formula or seed key that names `organization_id` on `sys_job_queue`. A principal that must ' + + 'read the table needs the `manage_platform_settings` capability, which platform ' + + 'administrators hold', + reason: + 'ADR-0131 D7: a table whose rows no writer attributes to an organization is deployment-level ' + + 'and loses the column; the writer decides membership, not the name. Writer census at commit ' + + 'e67ba80049 of this repository\'s main branch: the sole writer is DbQueueAdapter in ' + + '@objectstack/service-queue: nine write sites (the publish insert and the worker update and ' + + 'delete paths), each under a system context whose row literal names no organization. So the ' + + 'injected column only ever held NULL. The census is the same procedure that reports the ' + + 'organization-stamping writers of sys_http_delivery, sys_secret and sys_email, so it can ' + + 'fire. Under a walled posture the tenant wall compared that NULL to the caller organization ' + + 'and hid every row from every reader, platform administrators included; with no column there ' + + 'is no wall, and the table is governed by object permission instead (D7). The capability gate ' + + 'is part of the same change, not a follow-up: the organization_admin grant carries the ' + + 'superuser bits on every object, so without it a walled deployment would hand each ' + + 'organization administrator every other organization\'s rows. Existing databases: schema sync ' + + 'is additive, so the physical column stays and the boot drift report names it orphaned; by ' + + 'the census it holds only NULL, so dropping it loses nothing. The operator drops it with os ' + + 'migrate apply --allow-destructive, the remedy the drift report names.', + acceptanceCriteria: + 'No authored metadata names `organization_id` on `sys_job_queue`: the field resolver (lint ' + + 'and the data door) now answers it as an unknown field. On every tenancy posture a principal ' + + 'without `manage_platform_settings` is refused 403 PERMISSION_DENIED on a read of ' + + '`sys_job_queue`, and a platform administrator lists every row with no organization filter. ' + + 'After os migrate apply --allow-destructive the boot no longer reports the orphaned column.', +}; diff --git a/packages/spec/src/migrations/entries/semantic/18.sys-job-run-organization-column-retired.ts b/packages/spec/src/migrations/entries/semantic/18.sys-job-run-organization-column-retired.ts new file mode 100644 index 00000000000..d8bda3f5172 --- /dev/null +++ b/packages/spec/src/migrations/entries/semantic/18.sys-job-run-organization-column-retired.ts @@ -0,0 +1,50 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +import type { SemanticMigration } from '../../types.js'; + +// #15207 (ADR-0131 D7, C6) — one D3 entry per removed column, as the card +// requires. A platform-object COLUMN retirement, not a spec-key retirement: no +// authorable spec key moves, so nothing lands in RETIRED_KEYS_BY_MAJOR and no +// D2 conversion exists to pair with (the ups-delegated-from-column-retired +// shape). The writer census behind the verdict is cited in the reason. +export const entry: SemanticMigration = { + id: 'sys-job-run-organization-column-retired', + // No backticks in `surface` — build-upgrade-guide.ts renders it inside a + // code span AND a table cell. + surface: + 'sys_job_run.organization_id — the injected organization column left the platform job run ' + + 'history (packages/platform-objects/src/audit/sys-job-run.object.ts, which now declares ' + + 'systemFields.tenant false), and reading the table now requires the manage_platform_settings ' + + 'capability', + replacement: + 'nothing on this table — `sys_job_run` is deployment-level state (ADR-0131 D7) and no ' + + 'organization owns a row. Delete any authored filter, list-view column, report grouping, ' + + 'formula or seed key that names `organization_id` on `sys_job_run`. A principal that must ' + + 'read the table needs the `manage_platform_settings` capability, which platform ' + + 'administrators hold', + reason: + 'ADR-0131 D7: a table whose rows no writer attributes to an organization is deployment-level ' + + 'and loses the column; the writer decides membership, not the name. Writer census at commit ' + + 'e67ba80049 of this repository\'s main branch: the sole writer is DbJobAdapter in ' + + '@objectstack/service-job: two write sites (the run start insert and the run finish update), ' + + 'each under a system context whose row literal names no organization, including for a job ' + + 'that declares the organization it runs as, whose stamp reaches the job data writes and never ' + + 'this ledger. So the injected column only ever held NULL. The census is the same procedure ' + + 'that reports the organization-stamping writers of sys_http_delivery, sys_secret and ' + + 'sys_email, so it can fire. Under a walled posture the tenant wall compared that NULL to the ' + + 'caller organization and hid every row from every reader, platform administrators included; ' + + 'with no column there is no wall, and the table is governed by object permission instead ' + + '(D7). The capability gate is part of the same change, not a follow-up: the ' + + 'organization_admin grant carries the superuser bits on every object, so without it a walled ' + + 'deployment would hand each organization administrator every other organization\'s rows. ' + + 'Existing databases: schema sync is additive, so the physical column stays and the boot drift ' + + 'report names it orphaned; by the census it holds only NULL, so dropping it loses nothing. ' + + 'The operator drops it with os migrate apply --allow-destructive, the remedy the drift report ' + + 'names.', + acceptanceCriteria: + 'No authored metadata names `organization_id` on `sys_job_run`: the field resolver (lint and ' + + 'the data door) now answers it as an unknown field. On every tenancy posture a principal ' + + 'without `manage_platform_settings` is refused 403 PERMISSION_DENIED on a read of ' + + '`sys_job_run`, and a platform administrator lists every row with no organization filter. ' + + 'After os migrate apply --allow-destructive the boot no longer reports the orphaned column.', +}; diff --git a/packages/spec/src/migrations/entries/semantic/18.sys-migration-journal-organization-column-retired.ts b/packages/spec/src/migrations/entries/semantic/18.sys-migration-journal-organization-column-retired.ts new file mode 100644 index 00000000000..ff7d2a9546e --- /dev/null +++ b/packages/spec/src/migrations/entries/semantic/18.sys-migration-journal-organization-column-retired.ts @@ -0,0 +1,49 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +import type { SemanticMigration } from '../../types.js'; + +// #15207 (ADR-0131 D7, C6) — one D3 entry per removed column, as the card +// requires. A platform-object COLUMN retirement, not a spec-key retirement: no +// authorable spec key moves, so nothing lands in RETIRED_KEYS_BY_MAJOR and no +// D2 conversion exists to pair with (the ups-delegated-from-column-retired +// shape). The writer census behind the verdict is cited in the reason. +export const entry: SemanticMigration = { + id: 'sys-migration-journal-organization-column-retired', + // No backticks in `surface` — build-upgrade-guide.ts renders it inside a + // code span AND a table cell. + surface: + 'sys_migration_journal.organization_id — the injected organization column left the migration ' + + 'run journal (packages/platform-objects/src/system/sys-migration-journal.object.ts, which now ' + + 'declares systemFields.tenant false), and reading the table now requires the ' + + 'manage_platform_settings capability', + replacement: + 'nothing on this table — `sys_migration_journal` is deployment-level state (ADR-0131 D7) and ' + + 'no organization owns a row. Delete any authored filter, list-view column, report grouping, ' + + 'formula or seed key that names `organization_id` on `sys_migration_journal`. A principal ' + + 'that must read the table needs the `manage_platform_settings` capability, which platform ' + + 'administrators hold', + reason: + 'ADR-0131 D7: a table whose rows no writer attributes to an organization is deployment-level ' + + 'and loses the column; the writer decides membership, not the name. Writer census at commit ' + + 'e67ba80049 of this repository\'s main branch: the sole writer is the @objectstack/core ' + + 'migration runner: one append site, under a system context or under the transaction it opened ' + + 'with one, and the row contract MigrationJournalEventSchema has no organization field to ' + + 'carry. So the injected column only ever held NULL. The census is the same procedure that ' + + 'reports the organization-stamping writers of sys_http_delivery, sys_secret and sys_email, so ' + + 'it can fire. Under a walled posture the tenant wall compared that NULL to the caller ' + + 'organization and hid every row from every reader, platform administrators included; with no ' + + 'column there is no wall, and the table is governed by object permission instead (D7). The ' + + 'capability gate is part of the same change, not a follow-up: the organization_admin grant ' + + 'carries the superuser bits on every object, so without it a walled deployment would hand ' + + 'each organization administrator every other organization\'s rows. Existing databases: schema ' + + 'sync is additive, so the physical column stays and the boot drift report names it orphaned; ' + + 'by the census it holds only NULL, so dropping it loses nothing. The operator drops it with ' + + 'os migrate apply --allow-destructive, the remedy the drift report names.', + acceptanceCriteria: + 'No authored metadata names `organization_id` on `sys_migration_journal`: the field resolver ' + + '(lint and the data door) now answers it as an unknown field. On every tenancy posture a ' + + 'principal without `manage_platform_settings` is refused 403 PERMISSION_DENIED on a read of ' + + '`sys_migration_journal`, and a platform administrator lists every row with no organization ' + + 'filter. After os migrate apply --allow-destructive the boot no longer reports the orphaned ' + + 'column.', +}; diff --git a/packages/spec/src/migrations/entries/semantic/18.sys-migration-organization-column-retired.ts b/packages/spec/src/migrations/entries/semantic/18.sys-migration-organization-column-retired.ts new file mode 100644 index 00000000000..0030c0e11e5 --- /dev/null +++ b/packages/spec/src/migrations/entries/semantic/18.sys-migration-organization-column-retired.ts @@ -0,0 +1,50 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +import type { SemanticMigration } from '../../types.js'; + +// #15207 (ADR-0131 D7, C6) — one D3 entry per removed column, as the card +// requires. A platform-object COLUMN retirement, not a spec-key retirement: no +// authorable spec key moves, so nothing lands in RETIRED_KEYS_BY_MAJOR and no +// D2 conversion exists to pair with (the ups-delegated-from-column-retired +// shape). The writer census behind the verdict is cited in the reason. +export const entry: SemanticMigration = { + id: 'sys-migration-organization-column-retired', + // No backticks in `surface` — build-upgrade-guide.ts renders it inside a + // code span AND a table cell. + surface: + 'sys_migration.organization_id — the injected organization column left the deployment ' + + 'data-migration flag ledger (packages/platform-objects/src/system/sys-migration.object.ts, ' + + 'which now declares systemFields.tenant false), and reading the table now requires the ' + + 'manage_platform_settings capability', + replacement: + 'nothing on this table — `sys_migration` is deployment-level state (ADR-0131 D7) and no ' + + 'organization owns a row. Delete any authored filter, list-view column, report grouping, ' + + 'formula or seed key that names `organization_id` on `sys_migration`. A principal that must ' + + 'read the table needs the `manage_platform_settings` capability, which platform ' + + 'administrators hold', + reason: + 'ADR-0131 D7: a table whose rows no writer attributes to an organization is deployment-level ' + + 'and loses the column; the writer decides membership, not the name. Writer census at commit ' + + 'e67ba80049 of this repository\'s main branch: eleven write sites in six files (the ' + + 'platform-objects migration flag helpers, the ObjectQL lax-deviation and boot-admission ' + + 'revocation writes, and the seed-tenancy, membership-backfill and flow-credential receipts), ' + + 'each under a system context, and the row contract DataMigrationFlagSchema has no ' + + 'organization field to carry. So the injected column only ever held NULL. The census is the ' + + 'same procedure that reports the organization-stamping writers of sys_http_delivery, ' + + 'sys_secret and sys_email, so it can fire. Under a walled posture the tenant wall compared ' + + 'that NULL to the caller organization and hid every row from every reader, platform ' + + 'administrators included; with no column there is no wall, and the table is governed by ' + + 'object permission instead (D7). The capability gate is part of the same change, not a ' + + 'follow-up: the organization_admin grant carries the superuser bits on every object, so ' + + 'without it a walled deployment would hand each organization administrator every other ' + + 'organization\'s rows. Existing databases: schema sync is additive, so the physical column ' + + 'stays and the boot drift report names it orphaned; by the census it holds only NULL, so ' + + 'dropping it loses nothing. The operator drops it with os migrate apply --allow-destructive, ' + + 'the remedy the drift report names.', + acceptanceCriteria: + 'No authored metadata names `organization_id` on `sys_migration`: the field resolver (lint ' + + 'and the data door) now answers it as an unknown field. On every tenancy posture a principal ' + + 'without `manage_platform_settings` is refused 403 PERMISSION_DENIED on a read of ' + + '`sys_migration`, and a platform administrator lists every row with no organization filter. ' + + 'After os migrate apply --allow-destructive the boot no longer reports the orphaned column.', +}; diff --git a/packages/spec/src/migrations/entries/semantic/18.sys-presence-organization-column-retired.ts b/packages/spec/src/migrations/entries/semantic/18.sys-presence-organization-column-retired.ts new file mode 100644 index 00000000000..c5aaf656d9f --- /dev/null +++ b/packages/spec/src/migrations/entries/semantic/18.sys-presence-organization-column-retired.ts @@ -0,0 +1,48 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +import type { SemanticMigration } from '../../types.js'; + +// #15207 (ADR-0131 D7, C6) — one D3 entry per removed column, as the card +// requires. A platform-object COLUMN retirement, not a spec-key retirement: no +// authorable spec key moves, so nothing lands in RETIRED_KEYS_BY_MAJOR and no +// D2 conversion exists to pair with (the ups-delegated-from-column-retired +// shape). The writer census behind the verdict is cited in the reason. +export const entry: SemanticMigration = { + id: 'sys-presence-organization-column-retired', + // No backticks in `surface` — build-upgrade-guide.ts renders it inside a + // code span AND a table cell. + surface: + 'sys_presence.organization_id — the injected organization column left the realtime presence ' + + 'table (packages/services/service-realtime/src/objects/sys-presence.object.ts, which now ' + + 'declares systemFields.tenant false), and reading the table now requires the ' + + 'manage_platform_settings capability', + replacement: + 'nothing on this table — `sys_presence` is deployment-level state (ADR-0131 D7) and no ' + + 'organization owns a row. Delete any authored filter, list-view column, report grouping, ' + + 'formula or seed key that names `organization_id` on `sys_presence`. A principal that must ' + + 'read the table needs the `manage_platform_settings` capability, which platform ' + + 'administrators hold', + reason: + 'ADR-0131 D7: a table whose rows no writer attributes to an organization is deployment-level ' + + 'and loses the column; the writer decides membership, not the name. Writer census at commit ' + + 'e67ba80049 of this repository\'s main branch: nothing writes the table through ObjectQL at ' + + 'all (presence travels the realtime path, and the generic data door exposes reads only), and ' + + 'a person present in several organizations is one person. So the injected column only ever ' + + 'held NULL. The census is the same procedure that reports the organization-stamping writers ' + + 'of sys_http_delivery, sys_secret and sys_email, so it can fire. Under a walled posture the ' + + 'tenant wall compared that NULL to the caller organization and hid every row from every ' + + 'reader, platform administrators included; with no column there is no wall, and the table is ' + + 'governed by object permission instead (D7). The capability gate is part of the same change, ' + + 'not a follow-up: the organization_admin grant carries the superuser bits on every object, so ' + + 'without it a walled deployment would hand each organization administrator every other ' + + 'organization\'s rows. Existing databases: schema sync is additive, so the physical column ' + + 'stays and the boot drift report names it orphaned; by the census it holds only NULL, so ' + + 'dropping it loses nothing. The operator drops it with os migrate apply --allow-destructive, ' + + 'the remedy the drift report names.', + acceptanceCriteria: + 'No authored metadata names `organization_id` on `sys_presence`: the field resolver (lint and ' + + 'the data door) now answers it as an unknown field. On every tenancy posture a principal ' + + 'without `manage_platform_settings` is refused 403 PERMISSION_DENIED on a read of ' + + '`sys_presence`, and a platform administrator lists every row with no organization filter. ' + + 'After os migrate apply --allow-destructive the boot no longer reports the orphaned column.', +}; diff --git a/packages/spec/src/migrations/registry.ts b/packages/spec/src/migrations/registry.ts index 50ef39e36ac..03ac0263787 100644 --- a/packages/spec/src/migrations/registry.ts +++ b/packages/spec/src/migrations/registry.ts @@ -5444,6 +5444,22 @@ const STEP18_RATIONALE: readonly RationaleFragment[] = [ + 'rewrite into a column, so the semantic entry `dataset-member-field-expression-refused` ' + 'carries the rest.', }, + { + id: 'deployment-plumbing-organization-columns-retired', + order: 86, + text: + 'It also takes the injected organization column off seven deployment-level platform tables — ' + + '`sys_job`, `sys_job_run`, `sys_job_queue`, `sys_flow_dispatch`, `sys_migration`, ' + + '`sys_migration_journal` and `sys_presence` (ADR-0131 D7). A writer census found no writer that ' + + 'attributes a row of any of them to an organization, so the column only ever held NULL, and under ' + + 'a walled posture the tenant wall hid every row from every reader. Each now declares ' + + '`systemFields: { tenant: false }` and the object-level capability gate ' + + '`requiredPermissions: [\'manage_platform_settings\']`: with no column there is no wall, so reads ' + + 'are governed by object permission, and the gate keeps one organization\'s administrator off ' + + 'another organization\'s rows. Nothing in stack metadata is rewritten; an existing database keeps ' + + 'the column as an orphan the boot drift report names, and `os migrate apply --allow-destructive` ' + + 'drops it. The D3 records are the seven `sys-*-organization-column-retired` semantic entries.', + }, { id: 'duration-keys-unit-in-key', order: 24, @@ -18621,6 +18637,322 @@ const step18: MigrationStep = { + '`sys_sso_provider` update door refuses an issuer change while accounts are still bound to ' + 'that provider.', }, + // #15207 (ADR-0131 D7, C6) — one D3 entry per removed column, as the card + // requires. A platform-object COLUMN retirement, not a spec-key retirement: no + // authorable spec key moves, so nothing lands in RETIRED_KEYS_BY_MAJOR and no + // D2 conversion exists to pair with (the ups-delegated-from-column-retired + // shape). The writer census behind the verdict is cited in the reason. + { + id: 'sys-flow-dispatch-organization-column-retired', + // No backticks in `surface` — build-upgrade-guide.ts renders it inside a + // code span AND a table cell. + surface: + 'sys_flow_dispatch.organization_id — the injected organization column left the flow trigger ' + + 'dispatch claim ledger (packages/services/service-automation/src/sys-flow-dispatch.object.ts, ' + + 'which now declares systemFields.tenant false), and reading the table now requires the ' + + 'manage_platform_settings capability', + replacement: + 'nothing on this table — `sys_flow_dispatch` is deployment-level state (ADR-0131 D7) and no ' + + 'organization owns a row. Delete any authored filter, list-view column, report grouping, ' + + 'formula or seed key that names `organization_id` on `sys_flow_dispatch`. A principal that ' + + 'must read the table needs the `manage_platform_settings` capability, which platform ' + + 'administrators hold', + reason: + 'ADR-0131 D7: a table whose rows no writer attributes to an organization is deployment-level ' + + 'and loses the column; the writer decides membership, not the name. Writer census at commit ' + + 'e67ba80049 of this repository\'s main branch: the sole writer is ObjectStoreFlowDispatchStore ' + + 'in @objectstack/service-automation: two write sites (the claim insert and the settle ' + + 'update), each under a system context whose row is a dispatch key and its outcome, naming no ' + + 'organization. So the injected column only ever held NULL. The census is the same procedure ' + + 'that reports the organization-stamping writers of sys_http_delivery, sys_secret and ' + + 'sys_email, so it can fire. Under a walled posture the tenant wall compared that NULL to the ' + + 'caller organization and hid every row from every reader, platform administrators included; ' + + 'with no column there is no wall, and the table is governed by object permission instead ' + + '(D7). The capability gate is part of the same change, not a follow-up: the ' + + 'organization_admin grant carries the superuser bits on every object, so without it a walled ' + + 'deployment would hand each organization administrator every other organization\'s rows. ' + + 'Existing databases: schema sync is additive, so the physical column stays and the boot drift ' + + 'report names it orphaned; by the census it holds only NULL, so dropping it loses nothing. ' + + 'The operator drops it with os migrate apply --allow-destructive, the remedy the drift report ' + + 'names.', + acceptanceCriteria: + 'No authored metadata names `organization_id` on `sys_flow_dispatch`: the field resolver ' + + '(lint and the data door) now answers it as an unknown field. On every tenancy posture a ' + + 'principal without `manage_platform_settings` is refused 403 PERMISSION_DENIED on a read of ' + + '`sys_flow_dispatch`, and a platform administrator lists every row with no organization ' + + 'filter. After os migrate apply --allow-destructive the boot no longer reports the orphaned ' + + 'column.', + }, + // #15207 (ADR-0131 D7, C6) — one D3 entry per removed column, as the card + // requires. A platform-object COLUMN retirement, not a spec-key retirement: no + // authorable spec key moves, so nothing lands in RETIRED_KEYS_BY_MAJOR and no + // D2 conversion exists to pair with (the ups-delegated-from-column-retired + // shape). The writer census behind the verdict is cited in the reason. + { + id: 'sys-job-organization-column-retired', + // No backticks in `surface` — build-upgrade-guide.ts renders it inside a + // code span AND a table cell. + surface: + 'sys_job.organization_id — the injected organization column left the platform background-job ' + + 'catalogue (packages/platform-objects/src/audit/sys-job.object.ts, which now declares ' + + 'systemFields.tenant false), and reading the table now requires the manage_platform_settings ' + + 'capability', + replacement: + 'nothing on this table — `sys_job` is deployment-level state (ADR-0131 D7) and no ' + + 'organization owns a row. Delete any authored filter, list-view column, report grouping, ' + + 'formula or seed key that names `organization_id` on `sys_job`. A principal that must read ' + + 'the table needs the `manage_platform_settings` capability, which platform administrators ' + + 'hold', + reason: + 'ADR-0131 D7: a table whose rows no writer attributes to an organization is deployment-level ' + + 'and loses the column; the writer decides membership, not the name. Writer census at commit ' + + 'e67ba80049 of this repository\'s main branch: the sole writer is DbJobAdapter in ' + + '@objectstack/service-job: four write sites (the update and insert arms of the schedule ' + + 'upsert, the active toggle and the run summary bump), each under a system context whose row ' + + 'literal names no organization. So the injected column only ever held NULL. The census is the ' + + 'same procedure that reports the organization-stamping writers of sys_http_delivery, ' + + 'sys_secret and sys_email, so it can fire. Under a walled posture the tenant wall compared ' + + 'that NULL to the caller organization and hid every row from every reader, platform ' + + 'administrators included; with no column there is no wall, and the table is governed by ' + + 'object permission instead (D7). The capability gate is part of the same change, not a ' + + 'follow-up: the organization_admin grant carries the superuser bits on every object, so ' + + 'without it a walled deployment would hand each organization administrator every other ' + + 'organization\'s rows. Existing databases: schema sync is additive, so the physical column ' + + 'stays and the boot drift report names it orphaned; by the census it holds only NULL, so ' + + 'dropping it loses nothing. The operator drops it with os migrate apply --allow-destructive, ' + + 'the remedy the drift report names.', + acceptanceCriteria: + 'No authored metadata names `organization_id` on `sys_job`: the field resolver (lint and the ' + + 'data door) now answers it as an unknown field. On every tenancy posture a principal without ' + + '`manage_platform_settings` is refused 403 PERMISSION_DENIED on a read of `sys_job`, and a ' + + 'platform administrator lists every row with no organization filter. After os migrate apply ' + + '--allow-destructive the boot no longer reports the orphaned column.', + }, + // #15207 (ADR-0131 D7, C6) — one D3 entry per removed column, as the card + // requires. A platform-object COLUMN retirement, not a spec-key retirement: no + // authorable spec key moves, so nothing lands in RETIRED_KEYS_BY_MAJOR and no + // D2 conversion exists to pair with (the ups-delegated-from-column-retired + // shape). The writer census behind the verdict is cited in the reason. + { + id: 'sys-job-queue-organization-column-retired', + // No backticks in `surface` — build-upgrade-guide.ts renders it inside a + // code span AND a table cell. + surface: + 'sys_job_queue.organization_id — the injected organization column left the durable job and ' + + 'message queue (packages/platform-objects/src/audit/sys-job-queue.object.ts, which now ' + + 'declares systemFields.tenant false), and reading the table now requires the ' + + 'manage_platform_settings capability', + replacement: + 'nothing on this table — `sys_job_queue` is deployment-level state (ADR-0131 D7) and no ' + + 'organization owns a row. Delete any authored filter, list-view column, report grouping, ' + + 'formula or seed key that names `organization_id` on `sys_job_queue`. A principal that must ' + + 'read the table needs the `manage_platform_settings` capability, which platform ' + + 'administrators hold', + reason: + 'ADR-0131 D7: a table whose rows no writer attributes to an organization is deployment-level ' + + 'and loses the column; the writer decides membership, not the name. Writer census at commit ' + + 'e67ba80049 of this repository\'s main branch: the sole writer is DbQueueAdapter in ' + + '@objectstack/service-queue: nine write sites (the publish insert and the worker update and ' + + 'delete paths), each under a system context whose row literal names no organization. So the ' + + 'injected column only ever held NULL. The census is the same procedure that reports the ' + + 'organization-stamping writers of sys_http_delivery, sys_secret and sys_email, so it can ' + + 'fire. Under a walled posture the tenant wall compared that NULL to the caller organization ' + + 'and hid every row from every reader, platform administrators included; with no column there ' + + 'is no wall, and the table is governed by object permission instead (D7). The capability gate ' + + 'is part of the same change, not a follow-up: the organization_admin grant carries the ' + + 'superuser bits on every object, so without it a walled deployment would hand each ' + + 'organization administrator every other organization\'s rows. Existing databases: schema sync ' + + 'is additive, so the physical column stays and the boot drift report names it orphaned; by ' + + 'the census it holds only NULL, so dropping it loses nothing. The operator drops it with os ' + + 'migrate apply --allow-destructive, the remedy the drift report names.', + acceptanceCriteria: + 'No authored metadata names `organization_id` on `sys_job_queue`: the field resolver (lint ' + + 'and the data door) now answers it as an unknown field. On every tenancy posture a principal ' + + 'without `manage_platform_settings` is refused 403 PERMISSION_DENIED on a read of ' + + '`sys_job_queue`, and a platform administrator lists every row with no organization filter. ' + + 'After os migrate apply --allow-destructive the boot no longer reports the orphaned column.', + }, + // #15207 (ADR-0131 D7, C6) — one D3 entry per removed column, as the card + // requires. A platform-object COLUMN retirement, not a spec-key retirement: no + // authorable spec key moves, so nothing lands in RETIRED_KEYS_BY_MAJOR and no + // D2 conversion exists to pair with (the ups-delegated-from-column-retired + // shape). The writer census behind the verdict is cited in the reason. + { + id: 'sys-job-run-organization-column-retired', + // No backticks in `surface` — build-upgrade-guide.ts renders it inside a + // code span AND a table cell. + surface: + 'sys_job_run.organization_id — the injected organization column left the platform job run ' + + 'history (packages/platform-objects/src/audit/sys-job-run.object.ts, which now declares ' + + 'systemFields.tenant false), and reading the table now requires the manage_platform_settings ' + + 'capability', + replacement: + 'nothing on this table — `sys_job_run` is deployment-level state (ADR-0131 D7) and no ' + + 'organization owns a row. Delete any authored filter, list-view column, report grouping, ' + + 'formula or seed key that names `organization_id` on `sys_job_run`. A principal that must ' + + 'read the table needs the `manage_platform_settings` capability, which platform ' + + 'administrators hold', + reason: + 'ADR-0131 D7: a table whose rows no writer attributes to an organization is deployment-level ' + + 'and loses the column; the writer decides membership, not the name. Writer census at commit ' + + 'e67ba80049 of this repository\'s main branch: the sole writer is DbJobAdapter in ' + + '@objectstack/service-job: two write sites (the run start insert and the run finish update), ' + + 'each under a system context whose row literal names no organization, including for a job ' + + 'that declares the organization it runs as, whose stamp reaches the job data writes and never ' + + 'this ledger. So the injected column only ever held NULL. The census is the same procedure ' + + 'that reports the organization-stamping writers of sys_http_delivery, sys_secret and ' + + 'sys_email, so it can fire. Under a walled posture the tenant wall compared that NULL to the ' + + 'caller organization and hid every row from every reader, platform administrators included; ' + + 'with no column there is no wall, and the table is governed by object permission instead ' + + '(D7). The capability gate is part of the same change, not a follow-up: the ' + + 'organization_admin grant carries the superuser bits on every object, so without it a walled ' + + 'deployment would hand each organization administrator every other organization\'s rows. ' + + 'Existing databases: schema sync is additive, so the physical column stays and the boot drift ' + + 'report names it orphaned; by the census it holds only NULL, so dropping it loses nothing. ' + + 'The operator drops it with os migrate apply --allow-destructive, the remedy the drift report ' + + 'names.', + acceptanceCriteria: + 'No authored metadata names `organization_id` on `sys_job_run`: the field resolver (lint and ' + + 'the data door) now answers it as an unknown field. On every tenancy posture a principal ' + + 'without `manage_platform_settings` is refused 403 PERMISSION_DENIED on a read of ' + + '`sys_job_run`, and a platform administrator lists every row with no organization filter. ' + + 'After os migrate apply --allow-destructive the boot no longer reports the orphaned column.', + }, + // #15207 (ADR-0131 D7, C6) — one D3 entry per removed column, as the card + // requires. A platform-object COLUMN retirement, not a spec-key retirement: no + // authorable spec key moves, so nothing lands in RETIRED_KEYS_BY_MAJOR and no + // D2 conversion exists to pair with (the ups-delegated-from-column-retired + // shape). The writer census behind the verdict is cited in the reason. + { + id: 'sys-migration-journal-organization-column-retired', + // No backticks in `surface` — build-upgrade-guide.ts renders it inside a + // code span AND a table cell. + surface: + 'sys_migration_journal.organization_id — the injected organization column left the migration ' + + 'run journal (packages/platform-objects/src/system/sys-migration-journal.object.ts, which now ' + + 'declares systemFields.tenant false), and reading the table now requires the ' + + 'manage_platform_settings capability', + replacement: + 'nothing on this table — `sys_migration_journal` is deployment-level state (ADR-0131 D7) and ' + + 'no organization owns a row. Delete any authored filter, list-view column, report grouping, ' + + 'formula or seed key that names `organization_id` on `sys_migration_journal`. A principal ' + + 'that must read the table needs the `manage_platform_settings` capability, which platform ' + + 'administrators hold', + reason: + 'ADR-0131 D7: a table whose rows no writer attributes to an organization is deployment-level ' + + 'and loses the column; the writer decides membership, not the name. Writer census at commit ' + + 'e67ba80049 of this repository\'s main branch: the sole writer is the @objectstack/core ' + + 'migration runner: one append site, under a system context or under the transaction it opened ' + + 'with one, and the row contract MigrationJournalEventSchema has no organization field to ' + + 'carry. So the injected column only ever held NULL. The census is the same procedure that ' + + 'reports the organization-stamping writers of sys_http_delivery, sys_secret and sys_email, so ' + + 'it can fire. Under a walled posture the tenant wall compared that NULL to the caller ' + + 'organization and hid every row from every reader, platform administrators included; with no ' + + 'column there is no wall, and the table is governed by object permission instead (D7). The ' + + 'capability gate is part of the same change, not a follow-up: the organization_admin grant ' + + 'carries the superuser bits on every object, so without it a walled deployment would hand ' + + 'each organization administrator every other organization\'s rows. Existing databases: schema ' + + 'sync is additive, so the physical column stays and the boot drift report names it orphaned; ' + + 'by the census it holds only NULL, so dropping it loses nothing. The operator drops it with ' + + 'os migrate apply --allow-destructive, the remedy the drift report names.', + acceptanceCriteria: + 'No authored metadata names `organization_id` on `sys_migration_journal`: the field resolver ' + + '(lint and the data door) now answers it as an unknown field. On every tenancy posture a ' + + 'principal without `manage_platform_settings` is refused 403 PERMISSION_DENIED on a read of ' + + '`sys_migration_journal`, and a platform administrator lists every row with no organization ' + + 'filter. After os migrate apply --allow-destructive the boot no longer reports the orphaned ' + + 'column.', + }, + // #15207 (ADR-0131 D7, C6) — one D3 entry per removed column, as the card + // requires. A platform-object COLUMN retirement, not a spec-key retirement: no + // authorable spec key moves, so nothing lands in RETIRED_KEYS_BY_MAJOR and no + // D2 conversion exists to pair with (the ups-delegated-from-column-retired + // shape). The writer census behind the verdict is cited in the reason. + { + id: 'sys-migration-organization-column-retired', + // No backticks in `surface` — build-upgrade-guide.ts renders it inside a + // code span AND a table cell. + surface: + 'sys_migration.organization_id — the injected organization column left the deployment ' + + 'data-migration flag ledger (packages/platform-objects/src/system/sys-migration.object.ts, ' + + 'which now declares systemFields.tenant false), and reading the table now requires the ' + + 'manage_platform_settings capability', + replacement: + 'nothing on this table — `sys_migration` is deployment-level state (ADR-0131 D7) and no ' + + 'organization owns a row. Delete any authored filter, list-view column, report grouping, ' + + 'formula or seed key that names `organization_id` on `sys_migration`. A principal that must ' + + 'read the table needs the `manage_platform_settings` capability, which platform ' + + 'administrators hold', + reason: + 'ADR-0131 D7: a table whose rows no writer attributes to an organization is deployment-level ' + + 'and loses the column; the writer decides membership, not the name. Writer census at commit ' + + 'e67ba80049 of this repository\'s main branch: eleven write sites in six files (the ' + + 'platform-objects migration flag helpers, the ObjectQL lax-deviation and boot-admission ' + + 'revocation writes, and the seed-tenancy, membership-backfill and flow-credential receipts), ' + + 'each under a system context, and the row contract DataMigrationFlagSchema has no ' + + 'organization field to carry. So the injected column only ever held NULL. The census is the ' + + 'same procedure that reports the organization-stamping writers of sys_http_delivery, ' + + 'sys_secret and sys_email, so it can fire. Under a walled posture the tenant wall compared ' + + 'that NULL to the caller organization and hid every row from every reader, platform ' + + 'administrators included; with no column there is no wall, and the table is governed by ' + + 'object permission instead (D7). The capability gate is part of the same change, not a ' + + 'follow-up: the organization_admin grant carries the superuser bits on every object, so ' + + 'without it a walled deployment would hand each organization administrator every other ' + + 'organization\'s rows. Existing databases: schema sync is additive, so the physical column ' + + 'stays and the boot drift report names it orphaned; by the census it holds only NULL, so ' + + 'dropping it loses nothing. The operator drops it with os migrate apply --allow-destructive, ' + + 'the remedy the drift report names.', + acceptanceCriteria: + 'No authored metadata names `organization_id` on `sys_migration`: the field resolver (lint ' + + 'and the data door) now answers it as an unknown field. On every tenancy posture a principal ' + + 'without `manage_platform_settings` is refused 403 PERMISSION_DENIED on a read of ' + + '`sys_migration`, and a platform administrator lists every row with no organization filter. ' + + 'After os migrate apply --allow-destructive the boot no longer reports the orphaned column.', + }, + // #15207 (ADR-0131 D7, C6) — one D3 entry per removed column, as the card + // requires. A platform-object COLUMN retirement, not a spec-key retirement: no + // authorable spec key moves, so nothing lands in RETIRED_KEYS_BY_MAJOR and no + // D2 conversion exists to pair with (the ups-delegated-from-column-retired + // shape). The writer census behind the verdict is cited in the reason. + { + id: 'sys-presence-organization-column-retired', + // No backticks in `surface` — build-upgrade-guide.ts renders it inside a + // code span AND a table cell. + surface: + 'sys_presence.organization_id — the injected organization column left the realtime presence ' + + 'table (packages/services/service-realtime/src/objects/sys-presence.object.ts, which now ' + + 'declares systemFields.tenant false), and reading the table now requires the ' + + 'manage_platform_settings capability', + replacement: + 'nothing on this table — `sys_presence` is deployment-level state (ADR-0131 D7) and no ' + + 'organization owns a row. Delete any authored filter, list-view column, report grouping, ' + + 'formula or seed key that names `organization_id` on `sys_presence`. A principal that must ' + + 'read the table needs the `manage_platform_settings` capability, which platform ' + + 'administrators hold', + reason: + 'ADR-0131 D7: a table whose rows no writer attributes to an organization is deployment-level ' + + 'and loses the column; the writer decides membership, not the name. Writer census at commit ' + + 'e67ba80049 of this repository\'s main branch: nothing writes the table through ObjectQL at ' + + 'all (presence travels the realtime path, and the generic data door exposes reads only), and ' + + 'a person present in several organizations is one person. So the injected column only ever ' + + 'held NULL. The census is the same procedure that reports the organization-stamping writers ' + + 'of sys_http_delivery, sys_secret and sys_email, so it can fire. Under a walled posture the ' + + 'tenant wall compared that NULL to the caller organization and hid every row from every ' + + 'reader, platform administrators included; with no column there is no wall, and the table is ' + + 'governed by object permission instead (D7). The capability gate is part of the same change, ' + + 'not a follow-up: the organization_admin grant carries the superuser bits on every object, so ' + + 'without it a walled deployment would hand each organization administrator every other ' + + 'organization\'s rows. Existing databases: schema sync is additive, so the physical column ' + + 'stays and the boot drift report names it orphaned; by the census it holds only NULL, so ' + + 'dropping it loses nothing. The operator drops it with os migrate apply --allow-destructive, ' + + 'the remedy the drift report names.', + acceptanceCriteria: + 'No authored metadata names `organization_id` on `sys_presence`: the field resolver (lint and ' + + 'the data door) now answers it as an unknown field. On every tenancy posture a principal ' + + 'without `manage_platform_settings` is refused 403 PERMISSION_DENIED on a read of ' + + '`sys_presence`, and a platform administrator lists every row with no organization filter. ' + + 'After os migrate apply --allow-destructive the boot no longer reports the orphaned column.', + }, { id: 'system-cache-durations-unit-in-key', // No backticks in `surface` — build-upgrade-guide.ts renders it inside a diff --git a/scripts/platform-object-tenancy-census.json b/scripts/platform-object-tenancy-census.json index 809ebaa32dd..ae35d1f8b44 100644 --- a/scripts/platform-object-tenancy-census.json +++ b/scripts/platform-object-tenancy-census.json @@ -25,12 +25,12 @@ "population": "every object registered by a tracked packages/**/*.object.ts module whose name carries a platform prefix (sys_ / cloud_ / ai_). The cloud repository's own cloud_ objects are not in this tree and so not in this census.", "totals": { "registered": 83, - "inReach": 57, - "outOfReach": 26 + "inReach": 50, + "outOfReach": 33 }, "reasonTotals": { "managedBy: 'better-auth'": 25, - "systemFields.tenant: false": 1, + "systemFields.tenant: false": 8, "tenancy.enabled: false": 2 }, "unexplained": [], @@ -192,9 +192,11 @@ { "name": "sys_flow_dispatch", "file": "packages/services/service-automation/src/sys-flow-dispatch.object.ts", - "reach": "in", - "tenantField": "organization_id", - "reasons": [] + "reach": "out", + "tenantField": null, + "reasons": [ + "systemFields.tenant: false" + ] }, { "name": "sys_http_delivery", @@ -229,23 +231,29 @@ { "name": "sys_job", "file": "packages/platform-objects/src/audit/sys-job.object.ts", - "reach": "in", - "tenantField": "organization_id", - "reasons": [] + "reach": "out", + "tenantField": null, + "reasons": [ + "systemFields.tenant: false" + ] }, { "name": "sys_job_queue", "file": "packages/platform-objects/src/audit/sys-job-queue.object.ts", - "reach": "in", - "tenantField": "organization_id", - "reasons": [] + "reach": "out", + "tenantField": null, + "reasons": [ + "systemFields.tenant: false" + ] }, { "name": "sys_job_run", "file": "packages/platform-objects/src/audit/sys-job-run.object.ts", - "reach": "in", - "tenantField": "organization_id", - "reasons": [] + "reach": "out", + "tenantField": null, + "reasons": [ + "systemFields.tenant: false" + ] }, { "name": "sys_jwks", @@ -305,16 +313,20 @@ { "name": "sys_migration", "file": "packages/platform-objects/src/system/sys-migration.object.ts", - "reach": "in", - "tenantField": "organization_id", - "reasons": [] + "reach": "out", + "tenantField": null, + "reasons": [ + "systemFields.tenant: false" + ] }, { "name": "sys_migration_journal", "file": "packages/platform-objects/src/system/sys-migration-journal.object.ts", - "reach": "in", - "tenantField": "organization_id", - "reasons": [] + "reach": "out", + "tenantField": null, + "reasons": [ + "systemFields.tenant: false" + ] }, { "name": "sys_notification", @@ -454,9 +466,11 @@ { "name": "sys_presence", "file": "packages/services/service-realtime/src/objects/sys-presence.object.ts", - "reach": "in", - "tenantField": "organization_id", - "reasons": [] + "reach": "out", + "tenantField": null, + "reasons": [ + "systemFields.tenant: false" + ] }, { "name": "sys_record_share",