diff --git a/.changeset/24583-7b3-organizationless-counter-retired.md b/.changeset/24583-7b3-organizationless-counter-retired.md new file mode 100644 index 00000000000..60c1fb88c2e --- /dev/null +++ b/.changeset/24583-7b3-organizationless-counter-retired.md @@ -0,0 +1,29 @@ +--- +'@objectstack/cli': minor +'@objectstack/driver-sql': minor +'@objectstack/driver-turso': minor +'@objectstack/objectql': minor +'@objectstack/lint': patch +'@objectstack/spec': patch +--- + +feat!: an organization-less row on a tenant table no longer draws its autonumber from the `__global__` counter, and a production server boot with no auth secret is refused + +Clause-②: yes (narrowing) + + + +**BREAKING** accept-set narrowing, shipped as `minor` under the repo's launch-window convention for breaking changes (ADR-0131 D9, stage 8d). + +- **`os serve`: a production server boot whose auth did not compose for a missing secret is refused.** With the `auth` tier on and no `OS_AUTH_SECRET` (outside `--dev`), `serve` used to skip AuthPlugin with a warning and serve anyway. That boot had no security model: anonymous object data is always denied, so its data API was unusable. It also had no organization object, so its inline seed wrote rows with `organization_id` NULL and drew their autonumbers from the `__global__` counter. Measured on `examples/app-showcase` before this change: health 200, two NULL-organization rows and a `__global__` counter row on a fresh install. The boot now stops before the kernel starts, so nothing is written, and the message names both fixes. Unchanged: `--no-server` (kernel only) still boots with the warning, a development boot still uses its fallback secret, and the cloud host kernel (`ObjectOSEnvironmentPlugin`, where auth is each project's) still only warns. `os start` never reaches the refusal: it always supplies a secret to the `serve` child it spawns. +- **`@objectstack/driver-sql` (and `@objectstack/driver-turso`'s remote face, which calls the same rule): the counter refuses an organization-less row on a tenant table.** Wherever the driver registered the organization object (`sys_organization`), a `create` / `bulkCreate` / `upsert` on an object with a tenant column, whose row carries no organization and whose options carry no `tenantId`, is refused with `ERR_SYSTEM_WRITE_ORGANIZATION_REQUIRED` (status 500), the code the engine already answers for the same write. It is refused before any counter row is read or written, and nothing is written. New exports: `OrganizationlessCounterRefusal`, `ORGANIZATIONLESS_COUNTER_REFUSAL_CODE`, `ORGANIZATIONLESS_COUNTER_REFUSAL_STATUS`, `renderOrganizationlessCounterRefusal`. Unchanged: `__global__` stays the counter key of an object with no tenant column, and of a composition that registers no organization object on the driver (a lean `@objectstack/objectql/core` embedding), where no organization can exist. Counter rows a 17.x install filed under `__global__` for a tenant table stay in `_objectstack_sequences`; the counter no longer reads them, and `os migrate duplicates` still reports them. +- **`@objectstack/objectql`: `GLOBAL_TENANT` is no longer exported**, and `SystemWriteOrganizationRequiredError`'s message no longer describes the `__global__` counter as where the write would land. The code, status and fields are unchanged. +- **Texts.** The lint explanation of `unique/legacy-organization-composite`, the `UniqueScopeSchema` TSDoc and the error-code ledger now say that a NULL-organization row is residue an earlier release wrote, not something a new write produces. + +**The remedy.** +- A production boot refused for the missing secret: set `OS_AUTH_SECRET` (a long random value, identical across restarts and nodes), or run kernel-only with `--no-server`. +- A direct driver write refused by the counter: carry the organization — `driver.create(object, { ...row, organization_id: orgId })` or `driver.create(object, row, { tenantId: orgId })` — or write through the engine, which derives it under `single`. + +The retired export: + +- FROM `GLOBAL_TENANT` TO `GLOBAL_TENANT` imported from `@objectstack/driver-sql`, where the counter and the index emitters keep it (or the literal `'__global__'`). diff --git a/packages/cli/src/commands/migrate/duplicates.ts b/packages/cli/src/commands/migrate/duplicates.ts index ed0a67919dc..062a85f80c9 100644 --- a/packages/cli/src/commands/migrate/duplicates.ts +++ b/packages/cli/src/commands/migrate/duplicates.ts @@ -43,8 +43,14 @@ import type { RuntimeIndexPreflight, SeedTenancyExec } from '@objectstack/metada * schema, no rows written, nothing left behind. * 4. **The live CONDITION is reported too** — an install still holding a * `__global__` counter beside an organization-scoped one for the same - * object is about to mint more duplicates. The only forward-looking line in - * the report. + * object. Under 17.x that install was about to mint more duplicates, and + * this was the only forward-looking line in the report. Since ADR-0131 D9 + * (stage 8d) the counter refuses an organization-less row on a tenant + * table wherever the organization object is registered, so such a + * `__global__` row is no longer drawn from: the line now names the residue + * the split left in `_objectstack_sequences` — the evidence of which + * counter minted the duplicates above — rather than a forecast. The report + * shape is unchanged. * 5. **A data-side probe** — `GROUP BY HAVING COUNT(*) > 1` within the * object's own table, never an enumeration of `_objectstack_sequences`. A * duplicate on an object whose counter was since merged is therefore still @@ -154,10 +160,12 @@ export interface DuplicateIdentifier { } /** - * One object/field still running two counters — the live condition (point 4). + * One object/field still holding two counters — the live condition (point 4). * - * Not damage: a prediction. While both counters stand, each allocates within - * its own partition and neither can see the other's numbers. + * Not damage. Under 17.x it was a prediction: while both counters stood, each + * allocated within its own partition and neither could see the other's + * numbers. Since ADR-0131 D9 the `__global__` one is residue the counter no + * longer draws from on a tenant table (point 4 above). */ export interface DuplicateLiveCondition { object: string; diff --git a/packages/cli/src/commands/serve.ts b/packages/cli/src/commands/serve.ts index fe02bae615b..462b361f4fa 100644 --- a/packages/cli/src/commands/serve.ts +++ b/packages/cli/src/commands/serve.ts @@ -4674,6 +4674,29 @@ export default class Serve extends Command { + "view, a share link, or `book.audience: 'public'`.", ); } + // [ADR-0131 D9] The same case reached the other way: the `auth` tier is + // on, but a production boot resolved no secret, so AuthPlugin is not + // composed (rule 4 of `resolvePlatformAuthComposition`). A1's conclusion + // holds unchanged — no security model, so no data API — and it holds for + // a second reason too: with no AuthPlugin there is no organization + // object, so every write the boot makes (the inline seed first) would land + // with no organization and draw its autonumbers from an organization-less + // counter. Refused here, while the kernel has only been composed: no + // plugin has started, so nothing has been written. The `host-kernel` + // reason (the cloud runtime, where auth is each project's) is not this + // case and still only warns above. + if (flags.server && !authComposition.composes && authComposition.reason === 'no-secret') { + throw new Error( + 'This stack enables auth, but no auth secret is set, so AuthPlugin was not composed and no caller ' + + 'can authenticate — anonymous access to object data is always denied, so the data API would be ' + + 'unusable, and with no organization every row this boot writes would have no owner.\n' + + 'Fix it one of two ways:\n' + + ' • set OS_AUTH_SECRET (a long random value, identical across restarts and nodes), e.g. ' + + 'OS_AUTH_SECRET=$(openssl rand -hex 32);\n' + + ' • or serve without the data API — run with --no-server, or drop the REST/dispatcher plugins.\n' + + 'A local trial does not need the secret: `os dev` (or `os serve --dev`) uses a development fallback.', + ); + } try { const { createRestApiPlugin } = await import('@objectstack/rest'); diff --git a/packages/cli/test/helpers/serve-process.ts b/packages/cli/test/helpers/serve-process.ts index 0345dcee422..27d241095f8 100644 --- a/packages/cli/test/helpers/serve-process.ts +++ b/packages/cli/test/helpers/serve-process.ts @@ -792,6 +792,16 @@ export function portDriftError( */ export const E2E_SECRET_KEY = '0e2e'.repeat(16); +/** + * A fixed, obviously-synthetic auth secret for spawned PRODUCTION server boots + * (`bin/run.js`, `NODE_ENV` unset). Since ADR-0131 D9 such a boot is refused + * before the kernel starts when no secret composes AuthPlugin + * (`serve-no-auth-secret-refusal.integration.test.ts`), so a rig whose + * property is not about auth hands this in rather than steering around the + * refusal. ⛔ Test fixtures only; it signs nothing anyone keeps. + */ +export const E2E_AUTH_SECRET = 'os-e2e-auth-secret-not-a-real-credential-0e2e0e2e'; + export const VITEST_WORKER_ENV_KEYS = [ 'TEST', 'VITEST', diff --git a/packages/cli/test/serve-no-auth-secret-refusal.integration.test.ts b/packages/cli/test/serve-no-auth-secret-refusal.integration.test.ts new file mode 100644 index 00000000000..b97633453b0 --- /dev/null +++ b/packages/cli/test/serve-no-auth-secret-refusal.integration.test.ts @@ -0,0 +1,137 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; +import { spawn, type ChildProcessByStdio } from 'node:child_process'; +import { existsSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join, resolve } from 'node:path'; +import type { Readable } from 'node:stream'; +import { fileURLToPath } from 'node:url'; +import { + childEnv, + E2E_SECRET_KEY, + KERNEL_ASSIGNED_PORT, + requireBuiltCli, + RUN_JS_RESOLVES_FROM_DIST, +} from './helpers/serve-process.js'; + +/** + * ADR-0131 D9 — a production `os serve` whose `auth` tier is on but whose auth + * secret is missing is REFUSED before the kernel starts. + * + * Without the secret `resolvePlatformAuthComposition` composes no AuthPlugin, + * so the boot has no security model (anonymous object data is always denied, + * the data API is unusable) and no organization object — and every row its + * inline seed writes on a tenant table landed with no organization, its + * autonumber drawn from the organization-less counter. Measured before this + * refusal on `examples/app-showcase`: a fresh install answered health 200 and + * held two rows with a NULL `organization_id` and a `__global__` counter row. + * + * The property pinned is the order as much as the refusal: it fires while the + * kernel is only composed, so the database is never even opened. The healthy + * leg is the control — the same fixture, entrypoint and posture with the + * secret set reaches the ready banner and creates the database file, which is + * what makes the absent file in the refused leg mean something. + * + * Spawned through `bin/run.js` with `NODE_ENV` unset, the only shape that + * reaches production posture (`bin/run-dev.js` sets `development`, and a + * development boot falls back to a local secret). + */ + +const READY_BANNER_TAIL = /Press Ctrl\+C to stop/; +const HERE = resolve(fileURLToPath(import.meta.url), '..'); +const CLI = resolve(HERE, '../bin/run.js'); + +type BootChild = ChildProcessByStdio; + +const children: BootChild[] = []; +let fixtureDir: string; + +interface Outcome { + output: string; + exitCode: number | null; +} + +/** Boot until the ready banner (returns `exitCode: null`) or until the process exits. */ +async function boot(databaseFile: string, env: Record = {}): Promise { + const child = spawn(process.execPath, [CLI, 'serve', 'objectstack.config.ts', '--port', KERNEL_ASSIGNED_PORT], { + cwd: fixtureDir, + stdio: ['ignore', 'pipe', 'pipe'], + // `childEnv`, never a bare `...process.env` — see its header (commit 1ddda1d00). + env: childEnv({ + NO_COLOR: '1', + OS_DATABASE_URL: `file:${databaseFile}`, + OS_LOG_LEVEL: '', + OS_DISABLE_CONSOLE: '1', + OS_SECRET_KEY: E2E_SECRET_KEY, + // Truly unset in both legs unless a leg sets it: the refusal's subject. + // The two legacy spellings `resolveAuthSecret` still reads are unset too, + // so an ambient one cannot compose auth behind the leg's back. + OS_AUTH_SECRET: undefined, + AUTH_SECRET: undefined, + BETTER_AUTH_SECRET: undefined, + // Production posture — see the header. + NODE_ENV: undefined, + ...env, + }), + }) as BootChild; + children.push(child); + + let output = ''; + return new Promise((done, fail) => { + const timer = setTimeout(() => { + fail(new Error(`serve neither refused nor reached ready\n${output}`)); + }, 150_000); + const onData = (d: unknown) => { + output += String(d); + if (READY_BANNER_TAIL.test(output)) { + clearTimeout(timer); + done({ output, exitCode: null }); + } + }; + child.stdout.on('data', onData); + child.stderr.on('data', onData); + child.on('exit', (code) => { + clearTimeout(timer); + done({ output, exitCode: code }); + }); + }); +} + +beforeAll(() => { + requireBuiltCli(RUN_JS_RESOLVES_FROM_DIST); + fixtureDir = mkdtempSync(join(tmpdir(), 'os-no-auth-secret-')); + writeFileSync(join(fixtureDir, 'objectstack.config.ts'), 'export default {};\n', 'utf8'); +}); + +afterAll(() => { + for (const child of children) { + try { child.kill('SIGTERM'); } catch { /* already gone */ } + } + if (fixtureDir) rmSync(fixtureDir, { recursive: true, force: true }); +}); + +describe('os serve — a production server boot with no auth secret is refused (ADR-0131 D9)', () => { + it('refuses before the kernel starts: non-zero exit, the remedy named, the database never opened', async () => { + const databaseFile = join(fixtureDir, 'refused.db'); + const { output, exitCode } = await boot(databaseFile); + + expect(exitCode, `the boot was not refused\n${output}`).not.toBeNull(); + expect(exitCode).not.toBe(0); + expect(output).not.toMatch(READY_BANNER_TAIL); + expect(output).toContain('no auth secret is set'); + expect(output).toContain('OS_AUTH_SECRET'); + expect(existsSync(databaseFile), `the refused boot opened its database\n${output}`).toBe(false); + }, 240_000); + + it('control: the same boot with the secret reaches ready and opens its database', async () => { + const databaseFile = join(fixtureDir, 'healthy.db'); + const { output, exitCode } = await boot(databaseFile, { + OS_AUTH_SECRET: 'os-adr-0131-d9-control-leg-secret-not-a-real-credential', + }); + + expect(exitCode, `the control leg exited\n${output}`).toBeNull(); + expect(output).not.toContain('no auth secret is set'); + expect(existsSync(databaseFile), `the control leg never opened its database\n${output}`).toBe(true); + }, 240_000); +}); diff --git a/packages/cli/test/serve-ready-degraded-boot.e2e.test.ts b/packages/cli/test/serve-ready-degraded-boot.e2e.test.ts index d2fd05e2b63..9ee1b403861 100644 --- a/packages/cli/test/serve-ready-degraded-boot.e2e.test.ts +++ b/packages/cli/test/serve-ready-degraded-boot.e2e.test.ts @@ -48,6 +48,12 @@ import { * incidents reached by other routes: a plugin that did not load, and a core * service that is not there. * + * [ADR-0131 D9] Both legs boot `--no-server`. A production SERVER boot with no + * secret is now refused before the kernel starts + * (`serve-no-auth-secret-refusal.integration.test.ts`), so it reaches no banner; the + * kernel-only boot still skips auth with the warning and still prints the + * ready banner, which is the property under test here. + * * ⛔ `runServe()` cannot reach it. That helper spawns `bin/run-dev.js`, which * sets `NODE_ENV = 'development'` before argv is parsed, so `isDev` is true, * the dev fallback secret applies, auth ALWAYS loads and the boot is never @@ -92,7 +98,7 @@ async function boot(env: Record = {}): Promise // A kernel-assigned port (#24295): production posture refuses to auto-shift, // so a port drawn and released beforehand is a red whenever another process // takes it in the gap. Nothing here talks to the port. - const child = spawn(process.execPath, [CLI, 'serve', 'objectstack.config.ts', '--port', KERNEL_ASSIGNED_PORT], { + const child = spawn(process.execPath, [CLI, 'serve', 'objectstack.config.ts', '--no-server', '--port', KERNEL_ASSIGNED_PORT], { cwd: fixtureDir, stdio: ['ignore', 'pipe', 'pipe'], // `childEnv`, never a bare `...process.env` — see its header (commit 1ddda1d00). diff --git a/packages/cli/test/uncreatable-directory-doors.integration.test.ts b/packages/cli/test/uncreatable-directory-doors.integration.test.ts index 937dba37988..83fc6e51d21 100644 --- a/packages/cli/test/uncreatable-directory-doors.integration.test.ts +++ b/packages/cli/test/uncreatable-directory-doors.integration.test.ts @@ -74,7 +74,7 @@ import { existsSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, rmSync, import { tmpdir } from 'node:os'; import { join, resolve } from 'node:path'; import { fileURLToPath } from 'node:url'; -import { E2E_SECRET_KEY, RUN_JS_RESOLVES_FROM_DIST, requireBuiltCli } from './helpers/serve-process.js'; +import { E2E_AUTH_SECRET, E2E_SECRET_KEY, RUN_JS_RESOLVES_FROM_DIST, requireBuiltCli } from './helpers/serve-process.js'; const HERE = resolve(fileURLToPath(import.meta.url), '..'); const RUN_JS = resolve(HERE, '../bin/run.js'); @@ -171,7 +171,15 @@ function runCli(args: string[], env: Record, opts: { cwd?: strin function runServe(home: string): Promise { return runCli( ['serve', '--port', '0'], - { OS_HOME: home, OS_DATABASE_URL: ':memory:', OS_SECRET_KEY: E2E_SECRET_KEY, OS_DISABLE_CONSOLE: '1' }, + { + OS_HOME: home, + OS_DATABASE_URL: ':memory:', + OS_SECRET_KEY: E2E_SECRET_KEY, + // A production server boot with no auth secret is refused (ADR-0131 D9); + // this file's property is about OS_HOME, not auth. + OS_AUTH_SECRET: E2E_AUTH_SECRET, + OS_DISABLE_CONSOLE: '1', + }, { cwd: serveDir, until: BANNER_TAIL }, ); } diff --git a/packages/drivers/driver-sql/src/index.ts b/packages/drivers/driver-sql/src/index.ts index 402696dff79..0203010133c 100644 --- a/packages/drivers/driver-sql/src/index.ts +++ b/packages/drivers/driver-sql/src/index.ts @@ -29,6 +29,14 @@ export { UnsupportedDialectEmissionError, renderDialectEmissionRefusal, } from './dialect-emission-refusal.js'; +// [ADR-0131 D9] The autonumber counter's refusal of an organization-less row on +// a tenant table — exported for the same reason: a caller recognises it by `code`. +export { + ORGANIZATIONLESS_COUNTER_REFUSAL_CODE, + ORGANIZATIONLESS_COUNTER_REFUSAL_STATUS, + OrganizationlessCounterRefusal, + renderOrganizationlessCounterRefusal, +} from './organizationless-counter-refusal.js'; export type { SqlDriverConfig, SqliteJournalMode, diff --git a/packages/drivers/driver-sql/src/organizationless-counter-refusal.ts b/packages/drivers/driver-sql/src/organizationless-counter-refusal.ts new file mode 100644 index 00000000000..0ee58fc95ec --- /dev/null +++ b/packages/drivers/driver-sql/src/organizationless-counter-refusal.ts @@ -0,0 +1,79 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * The autonumber counter's refusal of an organization-less row on a tenant + * table (ADR-0131 D9, stage 8d). + * + * The counter is keyed by `(table, tenant, field, scope)`. On an object with a + * tenant column the tenant is the row's organization; a row that carries none + * used to be filed under `GLOBAL_TENANT` — a second counter beside the + * organization's own, which cannot see it, so a field declared unique minted + * the same value twice. ADR-0131 D9 makes a missing organization a refused + * write: the engine derives it under `single` and refuses otherwise + * (`Engine.resolveSystemInsertOrganization`), so on a composition that + * registers the organization object no write the engine makes reaches this + * point without one. This refusal is the driver's own half of the same rule, + * for a caller that writes through the driver directly: it fires before a + * counter row is read or created, so nothing is minted and nothing is written. + * + * ## Where `GLOBAL_TENANT` stays the counter key + * + * - an object with NO tenant column (`systemFields: { tenant: false }`, + * `tenancy: { enabled: false }`) — one counter for the installation, by + * declaration; + * - a composition that registers no organization object on this driver (a + * lean `@objectstack/objectql/core` embedding with no identity objects, a + * bare-kernel test): no organization can exist there, so there is nothing + * to refuse in the name of, and the engine's `no-organization-object` + * branch answers the same. `os serve` refuses a server boot whose auth was + * not composed, so a served stack is never this composition. + * + * Counter rows a 17.x install filed under `GLOBAL_TENANT` for a tenant table + * stay in `_objectstack_sequences`; after this refusal the counter never + * reads them again, and `os migrate duplicates` is what reports them. + */ + +/** + * The code, shared with the engine's refusal of the same write + * (`SystemWriteOrganizationRequiredError`, `@objectstack/objectql`): one + * condition, one name, whichever layer catches it. + */ +export const ORGANIZATIONLESS_COUNTER_REFUSAL_CODE = 'ERR_SYSTEM_WRITE_ORGANIZATION_REQUIRED'; + +/** + * 500, as the engine's twin: the caller that reaches this is server-side code + * that wrote through the driver without threading an organization, never an + * HTTP client. + */ +export const ORGANIZATIONLESS_COUNTER_REFUSAL_STATUS = 500; + +/** Thrown by `SqlDriver.resolveSequenceTenantId` — both faces, one throw site. */ +export class OrganizationlessCounterRefusal extends Error { + readonly code = ORGANIZATIONLESS_COUNTER_REFUSAL_CODE; + readonly status = ORGANIZATIONLESS_COUNTER_REFUSAL_STATUS; + /** ADR-0112 D5's spelling of the same number. */ + readonly httpStatus = ORGANIZATIONLESS_COUNTER_REFUSAL_STATUS; + + constructor( + /** The table the autonumber belongs to. */ + readonly table: string, + /** The autonumber field. */ + readonly field: string, + /** The object's tenant column. */ + readonly tenantField: string, + ) { + super(renderOrganizationlessCounterRefusal(table, field, tenantField)); + this.name = 'OrganizationlessCounterRefusal'; + } +} + +/** The refusal's prose: what was refused, why, and how to carry the organization. */ +export function renderOrganizationlessCounterRefusal(table: string, field: string, tenantField: string): string { + return ( + `Autonumber '${table}.${field}' was REFUSED: the row carries no ${tenantField}, and '${table}' is ` + + `owned per organization. Every row on it belongs to an organization (ADR-0131 D9), so there is no ` + + `organization-less counter to draw the number from. Nothing was written. Fix it by carrying the ` + + `organization: set ${tenantField} on the record, or pass it as the driver option tenantId — or ` + + `write through the engine, which derives it under the 'single' posture.` + ); +} diff --git a/packages/drivers/driver-sql/src/sql-driver-organizationless-counter-refusal.test.ts b/packages/drivers/driver-sql/src/sql-driver-organizationless-counter-refusal.test.ts new file mode 100644 index 00000000000..c68724e0e43 --- /dev/null +++ b/packages/drivers/driver-sql/src/sql-driver-organizationless-counter-refusal.test.ts @@ -0,0 +1,129 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * ADR-0131 D9, stage 8d — the autonumber counter's organization-less bucket on + * a tenant table is retired. + * + * Under 17.x a row on a tenant table that carried no organization drew its + * number from the `__global__` counter, a second counter beside the + * organization's own that could not see it. The engine now derives or refuses + * every such write; this file pins the driver's own half, which a caller + * writing through the driver directly reaches: refused before anything is + * minted or written, wherever the driver registered the organization object. + * + * Pinned beside it, the two places `__global__` stays the counter key: an + * object with no tenant column, and a composition with no organization object + * (a lean embedding), where no organization can exist. + */ + +import { afterEach, describe, expect, it } from 'vitest'; +import { SqlDriver } from './index.js'; +import { + ORGANIZATIONLESS_COUNTER_REFUSAL_CODE, + ORGANIZATIONLESS_COUNTER_REFUSAL_STATUS, +} from './organizationless-counter-refusal.js'; + +const ORG = 'org_8d_counter'; + +const ORGANIZATION_OBJECT = { name: 'sys_organization', tenancy: { enabled: false }, fields: { name: { type: 'text' } } }; + +const CASE_OBJECT = { + name: 'crm_case', + fields: { + subject: { type: 'text' }, + organization_id: { type: 'text' }, + case_number: { type: 'autonumber', format: 'CASE-{0000}' }, + }, +}; + +const CATALOG_OBJECT = { + name: 'catalog_entry', + tenancy: { enabled: false }, + fields: { + label: { type: 'text' }, + organization_id: { type: 'text' }, + entry_number: { type: 'autonumber', format: 'ENT-{0000}' }, + }, +}; + +const open: SqlDriver[] = []; + +async function bootDriver(objects: any[]): Promise { + const driver = new SqlDriver({ client: 'better-sqlite3', connection: { filename: ':memory:' }, useNullAsDefault: true }); + open.push(driver); + await driver.initObjects(objects); + return driver; +} + +const sequences = async (driver: SqlDriver) => + ((await (driver as any).knex('_objectstack_sequences').select('object', 'tenant_id', 'last_value')) as any[]).map( + (r) => ({ object: String(r.object), tenant: String(r.tenant_id), lastValue: Number(r.last_value) }), + ); + +const rowCount = async (driver: SqlDriver, table: string) => + Number(((await (driver as any).knex(table).count({ n: '*' })) as any[])[0].n); + +afterEach(async () => { + while (open.length) await open.pop()?.disconnect(); +}); + +describe('[ADR-0131 D9, 8d] the counter refuses an organization-less row on a tenant table', () => { + it('create(): refused with the engine twin code, nothing minted and nothing written', async () => { + const driver = await bootDriver([ORGANIZATION_OBJECT, CASE_OBJECT]); + + let refusal: any; + try { + await driver.create('crm_case', { subject: 'no organization' }); + } catch (e) { + refusal = e; + } + expect(refusal, 'the organization-less create was accepted').toBeDefined(); + expect(refusal.code).toBe(ORGANIZATIONLESS_COUNTER_REFUSAL_CODE); + expect(refusal.code).toBe('ERR_SYSTEM_WRITE_ORGANIZATION_REQUIRED'); + expect(refusal.status).toBe(ORGANIZATIONLESS_COUNTER_REFUSAL_STATUS); + expect(refusal.message).toMatch(/^Autonumber 'crm_case\.case_number' was REFUSED/); + + expect(await rowCount(driver, 'crm_case')).toBe(0); + expect(await sequences(driver)).toEqual([]); + }); + + it('bulkCreate(): one organization-less row refuses the batch, nothing written', async () => { + const driver = await bootDriver([ORGANIZATION_OBJECT, CASE_OBJECT]); + + await expect( + driver.bulkCreate('crm_case', [ + { subject: 'carries one', organization_id: ORG }, + { subject: 'carries none' }, + ]), + ).rejects.toMatchObject({ code: ORGANIZATIONLESS_COUNTER_REFUSAL_CODE, status: 500 }); + + expect(await rowCount(driver, 'crm_case')).toBe(0); + }); + + it('a row that carries its organization — on the record or as the tenantId option — draws from that organization', async () => { + const driver = await bootDriver([ORGANIZATION_OBJECT, CASE_OBJECT]); + + const onRecord = await driver.create('crm_case', { subject: 'on the record', organization_id: ORG }); + const onOption = await driver.create('crm_case', { subject: 'as the option' }, { tenantId: ORG } as any); + + expect(onRecord.case_number).toBe('CASE-0001'); + expect(onOption.case_number).toBe('CASE-0002'); + expect(await sequences(driver)).toEqual([{ object: 'crm_case', tenant: ORG, lastValue: 2 }]); + }); + + it('an object with NO tenant column keeps the installation counter, `__global__`', async () => { + const driver = await bootDriver([ORGANIZATION_OBJECT, CATALOG_OBJECT]); + + const first = await driver.create('catalog_entry', { label: 'one' }); + expect(first.entry_number).toBe('ENT-0001'); + expect(await sequences(driver)).toEqual([{ object: 'catalog_entry', tenant: '__global__', lastValue: 1 }]); + }); + + it('a composition with no organization object (a lean embedding) keeps `__global__` on a tenant table', async () => { + const driver = await bootDriver([CASE_OBJECT]); + + const first = await driver.create('crm_case', { subject: 'lean embedding' }); + expect(first.case_number).toBe('CASE-0001'); + expect(await sequences(driver)).toEqual([{ object: 'crm_case', tenant: '__global__', lastValue: 1 }]); + }); +}); diff --git a/packages/drivers/driver-sql/src/sql-driver.ts b/packages/drivers/driver-sql/src/sql-driver.ts index 5e40140c8ff..206bca5f0bc 100644 --- a/packages/drivers/driver-sql/src/sql-driver.ts +++ b/packages/drivers/driver-sql/src/sql-driver.ts @@ -94,9 +94,10 @@ import { import { hasDanglingLikeEscape, hasNulInLikePattern, likePatternToGlobPattern } from '@objectstack/spec/data'; import type { DriverQuery, IDataDriver } from '@objectstack/spec/contracts'; import { StandardErrorCode } from '@objectstack/spec/api'; -import { StorageNameMapping } from '@objectstack/spec/system'; +import { StorageNameMapping, SystemObjectName } from '@objectstack/spec/system'; import { ExternalSchemaModeViolationError } from '@objectstack/spec/shared'; import { UnsupportedDialectEmissionError } from './dialect-emission-refusal.js'; +import { OrganizationlessCounterRefusal } from './organizationless-counter-refusal.js'; import { isUniqueViolationError, isUnbackedConflictTargetError, @@ -5703,6 +5704,19 @@ export class SqlDriver implements IDataDriver { */ protected tenantOptOutByTable: Set = new Set(); + /** + * Has this driver registered the organization object + * (`SystemObjectName.ORGANIZATION`)? Set by {@link computeAndRecordTenantField}, + * the one funnel every registration door (managed, external, remote, shard) + * passes through, and never cleared. It is the composition fact + * {@link resolveSequenceTenantId} reads: where it holds, an organization-less + * row on a tenant table is refused (ADR-0131 D9); where it does not — a lean + * embedding with no identity objects — no organization can exist, and the + * counter keeps `GLOBAL_TENANT`, as the engine's `no-organization-object` + * branch does. + */ + protected organizationObjectRegistered = false; + /** Throttle table for missing-tenantId warnings ({object}:{op}). */ protected tenantAuditWarned: Set = new Set(); /** [ADR-0139 D3] Whether this instance has logged its `ReadableIds` refusal (once per driver). */ @@ -7773,13 +7787,29 @@ export class SqlDriver implements IDataDriver { } /** - * The tenant bucket a counter row is keyed by: the row's tenant when the - * object HAS a tenant column and the row carries one, else the platform - * bucket `GLOBAL_TENANT`. One spelling for the issue path, the re-seed path - * and the counter identity, which used to carry it three times. + * The tenant bucket a counter row is keyed by. One spelling for the issue + * path, the re-seed path and the counter identity, on both faces: + * + * - an object with NO tenant column → `GLOBAL_TENANT`, the installation's + * one counter for it; + * - a tenant column and a row that carries its organization → that + * organization; + * - a tenant column and NO organization → REFUSED + * ({@link OrganizationlessCounterRefusal}, ADR-0131 D9) wherever this + * driver registered the organization object, before any counter row is + * read or written. Only a composition with no organization object (a lean + * embedding) keeps `GLOBAL_TENANT` here — no organization can exist there. */ - protected resolveSequenceTenantId(tenantField: string | null, tenantId: string | null): string { - return tenantField && tenantId ? String(tenantId) : GLOBAL_TENANT; + protected resolveSequenceTenantId( + tenantField: string | null, + tenantId: string | null, + table: string, + field: string, + ): string { + if (!tenantField) return GLOBAL_TENANT; + if (tenantId) return String(tenantId); + if (this.organizationObjectRegistered) throw new OrganizationlessCounterRefusal(table, field, tenantField); + return GLOBAL_TENANT; } /** @@ -8136,8 +8166,10 @@ export class SqlDriver implements IDataDriver { // transaction ensures the table on the right connection instead of dead- // locking on a second one (SQLite pool max=1). `initObjects` normally warms // this up front, making the call a no-op — this only bites the lazy path. + // The bucket first: an organization-less row on a tenant table is refused + // here (ADR-0131 D9), before the lazy DDL below or any counter read. + const resolvedTenantId = this.resolveSequenceTenantId(tenantField, tenantId, tableName, field); await this.ensureSequencesTable(parentTrx); - const resolvedTenantId = this.resolveSequenceTenantId(tenantField, tenantId); if (scope !== '' && !this.sequencesHasKeyHash) { // The legacy sequences table could not be migrated to the key_hash shape, // so it cannot represent per-scope counters. Fail with a clear, actionable @@ -8397,7 +8429,12 @@ export class SqlDriver implements IDataDriver { */ protected async resyncSequenceToDataMax(reservation: AutoNumberReservation): Promise { await this.ensureSequencesTable(); - const resolvedTenantId = this.resolveSequenceTenantId(reservation.tenantField, reservation.tenantId); + const resolvedTenantId = this.resolveSequenceTenantId( + reservation.tenantField, + reservation.tenantId, + reservation.tableName, + reservation.field, + ); const key = this.sequencesHasKeyHash ? { key_hash: this.sequenceKeyHash(reservation.tableName, resolvedTenantId, reservation.field, reservation.scope) } : { object: reservation.tableName, tenant_id: resolvedTenantId, field: reservation.field }; @@ -8431,7 +8468,12 @@ export class SqlDriver implements IDataDriver { * that drew from it. See {@link bulkCreate}. */ protected autoNumberCounterKey(reservation: AutoNumberReservation): string { - const resolvedTenantId = this.resolveSequenceTenantId(reservation.tenantField, reservation.tenantId); + const resolvedTenantId = this.resolveSequenceTenantId( + reservation.tenantField, + reservation.tenantId, + reservation.tableName, + reservation.field, + ); return this.sequenceKeyHash(reservation.tableName, resolvedTenantId, reservation.field, reservation.scope); } @@ -12130,6 +12172,7 @@ export class SqlDriver implements IDataDriver { key: string, schema: { fields?: Record; tenancy?: any }, ): string | null { + if (key === SystemObjectName.ORGANIZATION) this.organizationObjectRegistered = true; if (schema?.tenancy != null) { if (isTenancyDisabled(schema)) this.tenantOptOutByTable.add(key); else this.tenantOptOutByTable.delete(key); diff --git a/packages/drivers/driver-turso/src/turso-driver.ts b/packages/drivers/driver-turso/src/turso-driver.ts index 88f0db0e362..bc6756449da 100644 --- a/packages/drivers/driver-turso/src/turso-driver.ts +++ b/packages/drivers/driver-turso/src/turso-driver.ts @@ -2775,9 +2775,11 @@ export class TursoDriver extends SqlDriver { scope: string, suffix: string, ): Promise { + // The bucket first, as the local face resolves it: an organization-less + // row on a tenant table is refused (ADR-0131 D9) before any DDL or read. + const resolvedTenantId = this.resolveSequenceTenantId(tenantField, tenantId, tableName, field); await this.ensureRemoteSequencesTable(); const table = this.sequencesTableName; - const resolvedTenantId = this.resolveSequenceTenantId(tenantField, tenantId); const keyHash = this.sequenceKeyHash(tableName, resolvedTenantId, field, scope); const bumped = await this.remoteSequenceRows( @@ -2878,7 +2880,12 @@ export class TursoDriver extends SqlDriver { ): Promise { if (!this.isRemote) return super.resyncSequenceToDataMax(reservation); await this.ensureRemoteSequencesTable(); - const resolvedTenantId = this.resolveSequenceTenantId(reservation.tenantField, reservation.tenantId); + const resolvedTenantId = this.resolveSequenceTenantId( + reservation.tenantField, + reservation.tenantId, + reservation.tableName, + reservation.field, + ); const keyHash = this.sequenceKeyHash(reservation.tableName, resolvedTenantId, reservation.field, reservation.scope); const observedMax = await this.scanRemoteMaxCounter( reservation.tableName, diff --git a/packages/lint/src/data-model-rules.ts b/packages/lint/src/data-model-rules.ts index dd03cc6b34c..2cb1309274e 100644 --- a/packages/lint/src/data-model-rules.ts +++ b/packages/lint/src/data-model-rules.ts @@ -592,11 +592,12 @@ export function lintUniqueDeclarations(objects: any[]): LocatedLintIssue[] { * before protocol 18 — the conversion respells it, zero drift) says "per * organization" to a reader and materializes as a plain composite — and SQL * UNIQUE is NULL-distinct, so on every row where the organization column is - * NULL it enforces **nothing** (#5030, measured). On a single-organization - * deployment that is *every* row. The `'organization'` respelling is what closes - * that hole: the driver makes the LISTED organization column NULL-safe in place - * (`COALESCE(organization_id, '__global__')`), so the NULL rows become one - * platform bucket that is unique among themselves. + * NULL it enforces **nothing** (#5030, measured). A 17.x single-organization + * deployment wrote *every* row that way; since ADR-0131 D9 a new row carries its + * organization, so the gap now admits that residue. The `'organization'` + * respelling is what closes the hole: the driver makes the LISTED organization + * column NULL-safe in place (`COALESCE(organization_id, '__global__')`), so the + * NULL rows become one bucket that is unique among themselves. * * **Advisory, and deliberately no auto-fix.** ADR-0120 D5c is explicit that the * legacy spelling stays valid and unmigrated forever if untouched — zero forced diff --git a/packages/lint/src/data-model-rules.unique-verdicts.test.ts b/packages/lint/src/data-model-rules.unique-verdicts.test.ts index 48902987526..77c8c1f24f3 100644 --- a/packages/lint/src/data-model-rules.unique-verdicts.test.ts +++ b/packages/lint/src/data-model-rules.unique-verdicts.test.ts @@ -138,7 +138,10 @@ describe('[#22161] one-line verdicts — the ADR-0120 uniqueness rules', () => { [UNIQUE_LEGACY_ORGANIZATION_COMPOSITE]: [ 'ADR-0120 S6', '`tenancy.tenantField`', - 'single-organization deployment is every row', + // [ADR-0131 D9] The NULL rows the composite admits are what 17.x wrote, + // not what a new write produces. + 'every row a 17.x single-organization deployment', + 'ADR-0131 D9', "`COALESCE(organization_id, '__global__')`", 'ADR-0120 D5c', 'D4 ceremony', diff --git a/packages/lint/src/rule-explanations.ts b/packages/lint/src/rule-explanations.ts index 8ba09420acc..0f33f5f5a78 100644 --- a/packages/lint/src/rule-explanations.ts +++ b/packages/lint/src/rule-explanations.ts @@ -2684,9 +2684,10 @@ const UNIQUE_LEGACY_ORGANIZATION_COMPOSITE_EXPLANATION: RuleExplanation = { 'object\'s `tenancy.tenantField` — is the hand-written per-organization composite that ' + 'predates the scope vocabulary (ADR-0120 S6). It reads as "unique per organization" but ' + 'materializes as a plain composite, and SQL UNIQUE is NULL-distinct: it enforces nothing on a ' + - 'row whose organization column is NULL, which on a single-organization deployment is every ' + - 'row.', - 'Respelled `unique: \'organization\'`, the NULL rows become one platform bucket ' + + 'row whose organization column is NULL — every row a 17.x single-organization deployment ' + + 'wrote. Since ADR-0131 D9 a new row carries its organization, so what the gap still admits is ' + + 'that residue.', + 'Respelled `unique: \'organization\'`, the NULL rows become one bucket ' + '(`COALESCE(organization_id, \'__global__\')`) that is unique among themselves, which is what ' + 'closes the hole. The respelling keeps `fields` exactly as they are: the driver makes the listed ' + 'organization column NULL-safe in place rather than prepending a second organization key part, ' + diff --git a/packages/objectql/src/index.ts b/packages/objectql/src/index.ts index efde3c270c9..f4d3494f181 100644 --- a/packages/objectql/src/index.ts +++ b/packages/objectql/src/index.ts @@ -550,7 +550,6 @@ export { SystemWriteOrganizationRequiredError, SYSTEM_WRITE_ORGANIZATION_REQUIRED_CODE, ORGANIZATION_OBJECT, - GLOBAL_TENANT, DEFAULT_TENANT_FIELD, } from './tenancy/system-write-organization.js'; diff --git a/packages/objectql/src/system-write-organization.test.ts b/packages/objectql/src/system-write-organization.test.ts index f186611c03b..98e6b95705b 100644 --- a/packages/objectql/src/system-write-organization.test.ts +++ b/packages/objectql/src/system-write-organization.test.ts @@ -315,7 +315,12 @@ describe('#8844 multi-organization — the write is REFUSED, never defaulted', ( expect(refusal.status).toBe(500); // Loud: the condition, what would have been written, and the remedy. expect(refusal.message).toContain('dispatch_order'); - expect(refusal.message).toContain('__global__'); + // What would have been written: a row no organization owns (ADR-0131 D9) + // — and, since stage 8d, NOT the retired organization-less counter, which + // no write can reach any more. + expect(refusal.message).toContain('organization_id = NULL'); + expect(refusal.message).toContain('ADR-0131 D9'); + expect(refusal.message).not.toContain('__global__'); expect(refusal.message).toContain('tenantId'); expect(refusal.message).toContain('systemFields: { tenant: false }'); diff --git a/packages/objectql/src/tenancy/system-write-organization.ts b/packages/objectql/src/tenancy/system-write-organization.ts index 8cce07e4574..99c3195df02 100644 --- a/packages/objectql/src/tenancy/system-write-organization.ts +++ b/packages/objectql/src/tenancy/system-write-organization.ts @@ -4,17 +4,17 @@ * System-context write organization resolution (#8844) — the RUNTIME twin of * #8686's seed-path fix, one layer up. * - * ## The defect + * ## The defect (17.x) * * A session write carries the caller's active organization * (`ExecutionContext.tenantId`), and the driver stamps it onto the row * (`injectTenantOnInsert`) before the autonumber counter reads it * (`fillAutoNumberFields`, which resolves `row[tenantField] ?? options.tenantId * ?? null`). A SYSTEM-context write — a hook, a scheduled job, a custom - * endpoint, a `runAs: system` flow — carries no organization at all, so the - * column lands NULL and the counter files the row under the `__global__` - * pseudo-tenant. One object then runs TWO counters that cannot see each other, - * each correct within its own scope, and both hand out the same number: + * endpoint, a `runAs: system` flow — carried no organization at all, so the + * column landed NULL and the counter filed the row under the `__global__` + * pseudo-tenant. One object then ran TWO counters that cannot see each other, + * each correct within its own scope, and both handed out the same number: * * _objectstack_sequences, one object, one scope key: * tenant_id = 'org_msokm9oaz0cal87q' last_value = 2 <- REST / Console @@ -57,6 +57,15 @@ * and (since the Default Organization became a boot invariant, D3) zero * organizations alike. ⛔ Nothing defaults to any owner but the derivable one. * + * Stage 8d closed the counter half from below as well: the SQL driver refuses + * an organization-less row on a tenant table under this module's code + * (`OrganizationlessCounterRefusal`, `@objectstack/driver-sql`) wherever it + * registered the organization object, so no writer — the engine or a direct + * driver caller — can reach the `__global__` counter for a tenant table any + * more. `__global__` stays the counter key of an object with no tenant + * column, and of a composition that registers no organization object (the + * `no-organization-object` decision below), where no organization can exist. + * * ## The exclusions, and why the refusal is not broader * * A refusal that fires too widely breaks every system write on a walled install @@ -112,14 +121,6 @@ import { */ export const ORGANIZATION_OBJECT = SystemObjectName.ORGANIZATION; -/** - * The NULL-organization sentinel the SQL driver's counter and partitioned - * unique index collapse a missing organization to (`GLOBAL_TENANT` in - * `driver-sql`, ADR-0120 D3). Named here only to make the refusal message say - * what it is refusing to write; nothing in this module produces the value. - */ -export const GLOBAL_TENANT = '__global__'; - /** * The tenant column the kernel injects into every tenant-scoped object * (`TENANT_SCOPE_FIELD_DEF`, `registry.ts`), and the fallback every layer @@ -268,10 +269,9 @@ function buildRefusalMessage( return ( `Insert on '${object}' was REFUSED: a system-context write on a tenant-scoped object must carry an ` + `organization, and ${condition}. Writing it anyway would store ` + - `${DEFAULT_TENANT_FIELD} = NULL, which the autonumber counter and the partitioned unique index ` + - `(COALESCE(${DEFAULT_TENANT_FIELD}, '${GLOBAL_TENANT}'), ) both collapse to the ` + - `'${GLOBAL_TENANT}' pseudo-tenant — a second counter that cannot see the organization's own, so a ` + - `field declared unique silently gets the same value twice. Nothing was written. Fix it by ` + + `${DEFAULT_TENANT_FIELD} = NULL — a row no organization owns, whose autonumber has no counter to ` + + `draw from, and which a field declared unique per organization cannot compare with the ` + + `organization's own rows (ADR-0131 D9). Nothing was written. Fix it by ` + `carrying the organization the way a session write does: pass it on the execution context ` + `({ context: { isSystem: true, tenantId: '' } }), or set ${DEFAULT_TENANT_FIELD} ` + `on the record itself. If rows of '${object}' are genuinely platform-global and belong to no ` + diff --git a/packages/runtime/src/app-plugin.ts b/packages/runtime/src/app-plugin.ts index 27e74df94cc..5a61990bf25 100644 --- a/packages/runtime/src/app-plugin.ts +++ b/packages/runtime/src/app-plugin.ts @@ -2116,29 +2116,34 @@ export class AppPlugin implements Plugin { * [#8686] Adopt untenanted seed rows into the install's organization the * moment that organization first exists. * - * ## Why this cannot be done at seed time, and why boot is too late + * ## What 17.x left behind, and why this still runs * - * The seed loader already stamps `organization_id` on business seeds — but - * only when it can resolve an organization to stamp WITH - * (`resolveSoleOrganizationId`). On a first boot there is none: seeds land - * during `start()`, and the admin (and their organization) are created later, - * by a sign-up POST against the running server. So the loader correctly - * declines, the rows land `organization_id = NULL`, and the SQL driver files - * their numbers under the `__global__` counter. + * Under 17.x the seed loader stamped `organization_id` on business seeds only + * when it could resolve an organization to stamp WITH + * (`resolveSoleOrganizationId`), and on a first boot there was none: the + * admin and their organization arrived later, by a sign-up POST. So the rows + * landed `organization_id = NULL`, and the SQL driver filed their numbers + * under the `__global__` counter. The first API create then drew from a + * DIFFERENT counter that was correctly empty and minted `CASE-00001` — a + * value the seed already used — and the partitioned unique index + * (`COALESCE(organization_id, '__global__'), `) could not see the + * collision. Measured on 17.0.0 GA: four duplicate identifiers, four 201s, + * no warning. * - * Then the first API create arrives carrying a real organization, draws from a - * DIFFERENT counter that is correctly empty, and mints `CASE-00001` — a value - * the seed already used. The partitioned unique index - * (`COALESCE(organization_id, '__global__'), `) cannot see the - * collision because the two rows sit in different partitions. Measured on - * 17.0.0 GA: four duplicate identifiers, four 201s, no warning. + * [ADR-0131 D3/D9] A fresh install no longer produces that state: the + * Default Organization exists before the seeds load, the loader stamps it, + * and the driver's counter refuses an organization-less row on a tenant + * table (`OrganizationlessCounterRefusal`) instead of filing it under + * `__global__`. What remains is the residue an install seeded under 17.x + * carries — `__global__` counter rows the ownership ceremony leaves in + * `_objectstack_sequences` — and this handoff is one of its two repairs. * * `metadata-protocol`'s `kernel:ready` migration repairs an install that is - * ALREADY in that state, which covers every existing deployment. It cannot - * cover a fresh one: at `kernel:ready` the organization still does not exist, - * so the earliest that migration can act is the NEXT restart — by which time - * the duplicates of this session have already been minted and, per the ruling, - * are not the platform's to renumber. + * ALREADY in that state. It could not cover a 17.x first boot: at + * `kernel:ready` the organization did not exist yet, so the earliest that + * migration could act was the NEXT restart — by which time the duplicates of + * that session had been minted and, per the ruling, are not the platform's to + * renumber. * * Hence this seam. `sys_organization` gaining its first row is exactly the * event that makes the answer derivable, and it is the ownership handoff's diff --git a/packages/runtime/src/seed-tenancy-autonumber-split.integration.test.ts b/packages/runtime/src/seed-tenancy-autonumber-split.integration.test.ts index 0442ee042cd..21467f13584 100644 --- a/packages/runtime/src/seed-tenancy-autonumber-split.integration.test.ts +++ b/packages/runtime/src/seed-tenancy-autonumber-split.integration.test.ts @@ -142,18 +142,43 @@ async function seedFreshInstall(engine: ObjectQL) { } as any); } +/** + * Run `land` as a 17.x driver would have: with no organization-less refusal on + * the counter. Since ADR-0131 D9 stage 8d the driver refuses an + * organization-less row on a tenant table wherever it registered the + * organization object (`OrganizationlessCounterRefusal`); 17.x had no such + * refusal and minted the number on the `__global__` counter. The residue this + * file repairs is exactly what that older driver wrote, so it is landed through + * the same driver call with the refusal's composition fact withheld — the + * driver's own 17.x path, not a hand-written imitation of its rows. + */ +async function asA17xDriver(driver: SqlDriver, land: () => Promise): Promise { + const d = driver as any; + const registered = d.organizationObjectRegistered; + expect(registered, 'the 18.x driver under test never registered the organization object').toBe(true); + d.organizationObjectRegistered = false; + try { + await land(); + } finally { + d.organizationObjectRegistered = registered; + } +} + /** * The RESIDUE an install seeded before ADR-0131 carries: the same rows the * pre-C1 loader wrote when no organization existed yet, through the very driver - * call the engine made for them — no `tenantId`, so the driver mints each - * number on the `__global__` counter. The loader and the engine now refuse that - * write; an install that made it before keeps the rows, and repairing them is - * what the backfill and the handoff below exist for (ADR-0131 C7's residue). + * call the engine made for them — no `tenantId`, so the 17.x driver minted each + * number on the `__global__` counter. The loader, the engine and (since stage + * 8d) the driver now refuse that write; an install that made it before keeps + * the rows, and repairing them is what the backfill and the handoff below exist + * for (ADR-0131 C7's residue). */ async function landPreC1SeedResidue(driver: SqlDriver, count = SEEDED_ROWS) { - for (let i = 0; i < count; i++) { - await (driver as any).create('crm_case', { subject: `seeded ${i + 1}` }); - } + await asA17xDriver(driver, async () => { + for (let i = 0; i < count; i++) { + await (driver as any).create('crm_case', { subject: `seeded ${i + 1}` }); + } + }); } /** The sign-up that first brings an organization into existence. */ @@ -488,8 +513,10 @@ describe('#8686 seed/API tenancy split — autonumber scope', () => { // for it then (the engine now refuses it at zero organizations, D9). const seed = (region: string, n: number) => (driver as any).create('crm_ticket', { subject: `seeded ${region} ${n}`, region }); - for (let i = 1; i <= 3; i++) await seed('EU', i); - for (let i = 1; i <= 2; i++) await seed('US', i); + await asA17xDriver(driver, async () => { + for (let i = 1; i <= 3; i++) await seed('EU', i); + for (let i = 1; i <= 2; i++) await seed('US', i); + }); // Two independent counters, one per rendered scope. expect(await readScopedSequences(driver)).toEqual([ @@ -555,9 +582,11 @@ describe('#8686 seed/API tenancy split — autonumber scope', () => { // [C8 stage 8b] Straight through the driver, as `landPreC1SeedResidue` does: // under `single` the engine no longer writes an organization-less platform // row, so these are the residue an older release left behind. - for (let i = 0; i < 3; i++) { - await (driver as any).create('sys_audit_entry', { subject: `e${i}` }); - } + await asA17xDriver(driver, async () => { + for (let i = 0; i < 3; i++) { + await (driver as any).create('sys_audit_entry', { subject: `e${i}` }); + } + }); await createOrganization(engine); expect(await readSequences(driver)).toEqual([{ tenant: GLOBAL_TENANT, lastValue: 3 }]); diff --git a/packages/runtime/src/system-write-tenancy-autonumber-split.integration.test.ts b/packages/runtime/src/system-write-tenancy-autonumber-split.integration.test.ts index 9b655d4fc51..651e95c289c 100644 --- a/packages/runtime/src/system-write-tenancy-autonumber-split.integration.test.ts +++ b/packages/runtime/src/system-write-tenancy-autonumber-split.integration.test.ts @@ -195,7 +195,13 @@ describe('#8844 system-context writes — the runtime autonumber fork', () => { expect(refusal.code).toBe('ERR_SYSTEM_WRITE_ORGANIZATION_REQUIRED'); expect(refusal.status).toBe(500); expect(refusal.message).toContain('dispatch_order'); - expect(refusal.message).toContain(GLOBAL_TENANT); + // [ADR-0131 D9, 8d] The ENGINE's refusal, not the driver counter's twin + // (which carries the same code): it fires above the driver, before any + // autonumber is reserved. And it no longer offers the retired + // organization-less counter as what the write would have reached. + expect(refusal.message).toMatch(/^Insert on 'dispatch_order' was REFUSED/); + expect(refusal.message).toContain('ADR-0131 D9'); + expect(refusal.message).not.toContain(GLOBAL_TENANT); // ⛔ Not defaulted: no row, and — the assertion that distinguishes "refused" // from "wrote it under `__global__` and then threw" — no `__global__` diff --git a/packages/spec/src/api/error-code-ledger.zod.ts b/packages/spec/src/api/error-code-ledger.zod.ts index c1463e2555f..b92ec23bd73 100644 --- a/packages/spec/src/api/error-code-ledger.zod.ts +++ b/packages/spec/src/api/error-code-ledger.zod.ts @@ -1454,6 +1454,17 @@ export const ERROR_CODE_LEDGER = { // connector, plugin or host calling the driver directly, and that is why // it ships in `dist` and is registered (#16404, "Door or no door"). 'UNIQUE_VIOLATION', + // [ADR-0131 D9, stage 8d] The autonumber counter refuses an organization- + // less row on a tenant table wherever this driver registered the + // organization object, instead of filing it under the retired + // organization-less counter. Stamped through + // `ORGANIZATIONLESS_COUNTER_REFUSAL_CODE` + // (`packages/drivers/driver-sql/src/organizationless-counter-refusal.ts`); + // `TursoDriver`'s remote face throws through the same + // `resolveSequenceTenantId`, so `@objectstack/driver-turso` carries no row. + // Provenance ONLY: the engine refuses the same write under this code + // (`@objectstack/objectql` above), and one condition keeps one name. + 'ERR_SYSTEM_WRITE_ORGANIZATION_REQUIRED', ], '@objectstack/driver-turso': [ // [#14287] Provenance for the Turso REMOTE transport's unsafe-identifier diff --git a/packages/spec/src/data/field.zod.ts b/packages/spec/src/data/field.zod.ts index c63f57be633..f5bb83884bd 100644 --- a/packages/spec/src/data/field.zod.ts +++ b/packages/spec/src/data/field.zod.ts @@ -693,14 +693,17 @@ const uniqueScopeError: z.core.$ZodErrorMap = (issue) => { * (`stripe_customer_id`), a DNS hostname, a globally reserved slug, a device * identity. Global uniqueness is the special case and has to say so. * - * NULL-safety of the per-organization scope (ADR-0120 D3, #5030): the kernel - * injects `organization_id` unconditionally, so on single-organization stacks - * the column exists and is NULL on every row — and SQL UNIQUE is - * NULL-distinct, so a raw-column composite `(organization_id, field)` enforces - * NOTHING there. The organization key part therefore materializes NULL-safe as + * NULL-safety of the per-organization scope (ADR-0120 D3, #5030): SQL UNIQUE + * is NULL-distinct, so a raw-column composite `(organization_id, field)` + * enforces nothing between rows whose `organization_id` is NULL — and under + * 17.x a single-organization stack wrote every row that way. The organization + * key part therefore materializes NULL-safe as * `COALESCE(organization_id, '__global__')` (driver-side, #5030): NULL-org - * rows collapse into one platform bucket, unique among themselves; non-NULL - * rows are untouched. On an object with no tenant column at all + * rows collapse into one bucket, unique among themselves; non-NULL rows are + * untouched. Since ADR-0131 D9 every write on a tenant-column object carries + * its organization or is refused wherever the organization object is + * registered, so there a NULL-org row is residue an earlier release wrote, + * never a new one. On an object with no tenant column at all * (`tenancy.enabled: false`) both per-organization spellings degrade to the * listed column alone. * diff --git a/scripts/check-cli-test-child-env.mjs b/scripts/check-cli-test-child-env.mjs index 4ec0aa5d9ed..51f4cbec651 100644 --- a/scripts/check-cli-test-child-env.mjs +++ b/scripts/check-cli-test-child-env.mjs @@ -2635,7 +2635,7 @@ export function selfTest() { .filter((abs) => builtEntrypointSpawns(abs, readFileSync(abs, 'utf8')).spawns > 0) .map((abs) => relative(REPO_ROOT, abs).split(sep).join('/')) .sort(); - t('the built-entrypoint population is exactly the thirteen files that spawn bin/run.js', + t('the built-entrypoint population is exactly the fourteen files that spawn bin/run.js', JSON.stringify(builtFiles) === JSON.stringify([ // [#21733] Joined the population deliberately: the card's acceptance is // a plain `os dev` restart on the BUILT packages, and its production leg @@ -2664,6 +2664,12 @@ export function selfTest() { // PRODUCTION posture, and `bin/run-dev.js` sets `NODE_ENV=development` // before argv is parsed. Every spawn hands its child `NODE_ENV: undefined`. 'packages/cli/test/serve-mcp-stdio-early-initialize.integration.test.ts', + // [ADR-0131 D9] Joined the population deliberately: its subject is the + // refusal of a PRODUCTION server boot with no auth secret, and + // `bin/run-dev.js` sets `NODE_ENV=development` before argv is parsed, + // where the development fallback secret always composes auth. Every + // spawn hands its child `NODE_ENV: undefined`. + 'packages/cli/test/serve-no-auth-secret-refusal.integration.test.ts', // [#23573] Joined the population deliberately: the no-storage boot // guard is registered only when a PRODUCTION storage step declared // nothing, and `bin/run-dev.js` sets `NODE_ENV=development` before argv