Skip to content

Commit 4369677

Browse files
fix(cli): the ceremony's column drops answer not-declared for a table the booted composition declares no object for (#23446)
Fixes #23399 Clause-②: no Seat ruling B (`6163376799`), under claim `6163392076`. The measurement is in the round-1 report `6163300422`. ## What changes This changes step 5 (`column-drops`) of `os migrate organization-ownership --apply`. - **The port.** The `columnDrops` port in `runApply` (`packages/cli/src/commands/migrate/organization-ownership.ts`) now reads the booted stack's declared objects: `liveStack.allObjects()`, each name passed through `StorageNameMapping.resolveTableName`, which is the key the SQL driver gives its managed set. For a due table that the drift differ proposed no drop for, it answers: - `not-declared` when that set does not hold the table. A rotation shard is judged by its base, through `rotationBaseOf`. - `not-proposed` only when the set does hold it. - Both modules are imported inside the port, the way `runApply` already loads its other modules. That keeps the diff out of the import block, which the in-flight #15211 branches also edit. - **The step.** `ColumnDropOutcome` (`packages/cli/src/utils/organization-ownership-apply.ts`) gains `not-declared`. `runColumnDrops` counts it into the step's `summary.notDeclared` and gives it a blocker that names the table and the remedy: re-run the ceremony where the deployment's composition is declared. The existing `not-proposed` sentence is byte-identical, and it now fires only for a declared object. - One changeset: a `@objectstack/cli` patch. ## Why On `main`, the ceremony's apply boot declares only what the composition it runs in mounts, and `automation` is not on `PLATFORM_ALWAYS_ON_CAPABILITIES`. So in a bare run, or with a config that has no `requires: ['automation']`, `sys_flow_dispatch` is not declared and the differ proposes nothing. The step then told the operator that "the object this runtime declares still carries the column … Run the ceremony with the release whose declaration drops it". That is false: the object is not declared, and this release drops the column wherever it is declared. The same mechanism holds `sys_presence` (realtime) and `sys_audit_log` (the auth family). B covers all three, and any host object, by construction. ## Pins - **`organization-ownership-apply.test.ts`, one new case.** The port answers `not-declared` for `sys_flow_dispatch` and drops `sys_job`. Expected: step blocked, summary `{ dropped: 1, due: 2, notDeclared: 1 }`, and the column kept. The control is the resumed run, with the same table answered `not-proposed`: `notDeclared: 0`, and its blocker differs. - **`organization-ownership-not-declared.integration.test.ts`**, converted from the round-1 probe at `b2ce88193f`. It runs the real command on a v17-shaped SQLite fixture: a served boot of this tree, then `organization_id` added to `sys_flow_dispatch`, and to `sys_migration_journal` as the in-run control. - (i) A config without `requires`: `notDeclared` 1, the column kept, `sys_migration_journal`'s column dropped in the same run, exit 1. - (ii) `requires: ['automation']`: the column is dropped, and `notDeclared` is 0. - vitest's per-file duration is 3.96 s at `e7e7ad5145` and 4.52 s at `e99ada9e83`, under the 10 s rule, so it stays its own file. - **Tier.** `organization-ownership-apply.test.ts` imports `better-sqlite3`, so `vitest-tiers.ts` puts it in the integration tier. Both files run under `--project integration`. ## Reverse verification Both mutations ran at `e7e7ad5145` through `scripts/ablation-replace.mjs` in wrap mode. Each restore was proven: blob equal to HEAD, and an empty `git diff HEAD`. 1. **The port.** Anchor `declared.has(rotationBaseOf(table) ?? table)` replaced by `true`, so every unproposed table is classed as declared, which was the old behaviour. Pin (i) went red (`expected +0 to be 1`, the `notDeclared` count) and (ii) stayed green. 2. **The step.** Anchor `if (outcome.outcome === 'not-declared') {` replaced by `if (false) {`. The new unit case went red (`notDeclared: +0` vs `1`) and the other 17 stayed green. ## Local verification - **Head `e99ada9e83`** (`main` `04a314873c` merged; the delta against `main` is these 5 files, +292/−4): - full rebuild: 75/75; - both pin files: 20/20; - `pnpm --filter @objectstack/cli typecheck`: `tsc --noEmit` plus `check:test-typecheck`, green. All four changed TS files are in the `tsc` program (`--listFiles`). - CLI unit tier (`vitest run --project unit`): 287/287 files, 4307/4307 tests. - **Derived gates**, `dispatch-gates --commands --repo objectstack-ai/objectstack`: 65 commands, run at `e7e7ad5145`, whose diff is identical. The list re-derived at `e99ada9e83` is the same. - 63 exited 0. - `check:dual-build-cjs-loads` and `check:i18n-coverage` first exited 3 (PREREQUISITE NOT MET: packages outside the CLI closure were not built). After a full build both exit 0. - `--ran` reconciliation: 65 run, 0 NOT MEASURED. - **Lint, a proven narrowing at `e99ada9e83`:** 1. The population is read from ESLint's own config: the four changed TS files each return a result, and the fifth changed path (the changeset) is answered "File ignored because no matching configuration was supplied". 2. `--format json` reports 4 files, 0 errors and 0 warnings. 3. Invariance: `eslint.config.mjs` enables no type-aware linting, and its file inputs (`eslint.config.mjs`, `scripts/slot-lookup-baseline.json`, `scripts/query-options-erasure-baseline.json`) are unchanged by this diff. So no untouched file's verdict can move. ## The seat's hypotheses, measured - **(a) Names agree.** The driver keys its managed tables with the same `StorageNameMapping.resolveTableName`. In pin (i) the four `sys_metadata`-family tables (declared, not proposed) are classed `not-proposed`, since `notDeclared` is exactly 1. No column-drop object rotates on `main`: only `sys_activity` does, and its fate is attribution. The datasource does diverge (see the first acceptance note). - **(b) No conflict with the in-flight branches.** `git merge-tree` of this branch with `claude/issue-15211-c7b-plan-digest` (`bc3525a9cf`) and with `claude/issue-15211-c7b-promotions` (`7a9c239658`) exits 0 for both. Neither had landed by `a7a135dc26`, which also merges cleanly with this head. ## Acceptance notes - **Telemetry sibling: measured, outside this card's regions.** With `OS_TELEMETRY_DB` set for the ceremony run and `requires: ['automation']`, `sys_flow_dispatch` is declared, so the port says `not-proposed`. But its drift is read on the telemetry sibling, where the v17 table does not exist. The column stays, and the `not-proposed` sentence is false there too. Whether a real v17 database keeps that table in the primary or in the sibling under that posture was not established. - **Host fate-supplement column-drop rows** take the same port. B classifies a host object the composition does not declare as `not-declared` by construction; this is an inference and was not measured. - #15211 S2 still holds the `sys_metadata` family, and #15211 remains open. #15211 S3 will later edit this same port (`permission_set_id`). --- _Generated by [Claude Code](https://claude.ai/code/session_01Q9PZ5Pzqcus4woVvTZwWNU)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 301eec6 commit 4369677

5 files changed

Lines changed: 292 additions & 4 deletions

File tree

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
---
2+
'@objectstack/cli': patch
3+
---
4+
5+
fix(cli): `os migrate organization-ownership --apply` no longer blames the release for a column the ceremony's composition does not declare (ADR-0131 D10, column drops)
6+
7+
Clause-②: no
8+
9+
The ceremony drops `organization_id` only through the schema-drift ceremony, so only on an object its boot declares, and its boot declares the composition it is run in (the project directory and environment). Run in a composition that does not mount an object's provider, the step used to answer that table with "the object this runtime declares still carries the column … Run the ceremony with the release whose declaration drops it". That sentence was false: the object was not declared at all, and the same release drops the column wherever it is declared. Measured cases are `sys_flow_dispatch` without `requires: ['automation']`, `sys_presence` without `realtime`, and `sys_audit_log` without the auth family (no `OS_AUTH_SECRET`).
10+
11+
Such a table is now answered `not-declared`:
12+
13+
- the step still blocks and keeps the column;
14+
- its blocker names the table and the remedy: re-run the ceremony where the deployment's composition is declared, from its project directory and environment (the `requires` capability whose provider declares the object, and `OS_AUTH_SECRET` for the objects the auth family declares);
15+
- the `column-drops` step's summary counts these tables as `notDeclared` in the apply report (the report file and `--json`).
16+
17+
The "not proposed" blocker is unchanged. It is now given only for an object the composition does declare.

‎packages/cli/src/commands/migrate/organization-ownership.ts‎

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -491,9 +491,25 @@ export default class MigrateOrganizationOwnership extends Command {
491491
});
492492
const outcomes: ColumnDropOutcome[] = [];
493493
const proposed = new Set(entries.map((d) => String(d.table)));
494+
// The tables this booted composition declares, keyed as the driver
495+
// keys its managed set. A table outside it can never be proposed,
496+
// whatever a release declares: `not-declared`, and `not-proposed`
497+
// only for a declared one. A rotation shard is judged by its base.
498+
const { StorageNameMapping } = await import('@objectstack/spec/system');
499+
const { rotationBaseOf } = await import('../../utils/unmanaged-tables.js');
500+
const declared = new Set(
501+
(liveStack.allObjects() as Array<{ name?: unknown } | null>)
502+
.map((o) => o?.name)
503+
.filter((name): name is string => typeof name === 'string' && name.length > 0)
504+
.map((name) => StorageNameMapping.resolveTableName({ name })),
505+
);
494506
for (const table of tables) {
495507
if (!proposed.has(table)) {
496-
outcomes.push({ table, outcome: 'not-proposed', detail: 'the drift differ proposed no drop for it' });
508+
outcomes.push(
509+
declared.has(rotationBaseOf(table) ?? table)
510+
? { table, outcome: 'not-proposed', detail: 'the drift differ proposed no drop for it' }
511+
: { table, outcome: 'not-declared' },
512+
);
497513
}
498514
}
499515
if (entries.length === 0) return outcomes;

‎packages/cli/src/utils/organization-ownership-apply.test.ts‎

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -692,6 +692,44 @@ describe('ADR-0131 D10 --apply — idempotent, resumable, gated', () => {
692692
);
693693
});
694694

695+
it('a column-drop table the booted composition does not declare is not-declared, counted, kept and blocking — not blamed on the release', async () => {
696+
db.exec(`CREATE TABLE sys_flow_dispatch (id TEXT PRIMARY KEY, organization_id TEXT)`);
697+
const c = catalog();
698+
const l = ledger();
699+
const answering = async (outcome: 'not-declared' | 'not-proposed'): Promise<OrganizationOwnershipApplyReport> => {
700+
const real = columnDrops.drop;
701+
columnDrops.drop = async (tables) => [
702+
...(await real(tables.filter((t) => t !== 'sys_flow_dispatch'))),
703+
...(tables.includes('sys_flow_dispatch') ? [{ table: 'sys_flow_dispatch', outcome }] : []),
704+
];
705+
try {
706+
return await apply(c, l);
707+
} finally {
708+
columnDrops.drop = real;
709+
}
710+
};
711+
const flowDispatchBlocker = (report: OrganizationOwnershipApplyReport): string[] =>
712+
report.steps.find((s) => s.step === 'column-drops')!.blockers.filter((b) => b.includes('sys_flow_dispatch.organization_id'));
713+
714+
const notDeclared = await answering('not-declared');
715+
const drops = notDeclared.steps.find((s) => s.step === 'column-drops')!;
716+
expect(drops.status).toBe('blocked');
717+
expect(drops.summary).toEqual({ dropped: 1, due: 2, notDeclared: 1 });
718+
expect(flowDispatchBlocker(notDeclared)).toHaveLength(1);
719+
expect(rows(`SELECT name FROM pragma_table_info('sys_flow_dispatch') WHERE name = 'organization_id'`)).toHaveLength(1);
720+
expect(rows(`SELECT name FROM pragma_table_info('sys_job') WHERE name = 'organization_id'`)).toHaveLength(0);
721+
722+
// The control: the same table on the resumed run, answered `not-proposed`
723+
// (a declared object the differ proposed no drop for).
724+
const notProposed = await answering('not-proposed');
725+
expect(notProposed.resumedFrom).toEqual(['attribution', 'authored-conversion', 'template-promotion', 'settings-move', 'rewrite', 'mirror-deletion']);
726+
const control = notProposed.steps.find((s) => s.step === 'column-drops')!;
727+
expect(control.status).toBe('blocked');
728+
expect(control.summary).toEqual({ dropped: 0, due: 1, notDeclared: 0 });
729+
expect(flowDispatchBlocker(notProposed)).toHaveLength(1);
730+
expect(flowDispatchBlocker(notProposed)[0]).not.toBe(flowDispatchBlocker(notDeclared)[0]);
731+
});
732+
695733
it('holds a column whose rows another stage owns, and reports it', async () => {
696734
db.exec(`
697735
CREATE TABLE sys_metadata (id TEXT PRIMARY KEY, organization_id TEXT, type TEXT, name TEXT, state TEXT, package_id TEXT);

‎packages/cli/src/utils/organization-ownership-apply.ts‎

Lines changed: 28 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -129,10 +129,17 @@ export interface TemplatePort {
129129
find(name: string, locale: string): Promise<{ definition: Readonly<Record<string, unknown>>; environment: boolean } | undefined>;
130130
}
131131

132-
/** One table's column drop, as the schema-drift ceremony answered it. */
132+
/**
133+
* One table's column drop, as the schema-drift ceremony answered it.
134+
*
135+
* `not-declared`: the composition this run booted declares no object for the
136+
* table, so the ceremony has no declaration to drop the column against. That
137+
* is a fact about the composition the ceremony was run in, never about the
138+
* release. `not-proposed` is only for a table it DOES declare.
139+
*/
133140
export interface ColumnDropOutcome {
134141
table: string;
135-
outcome: 'dropped' | 'not-proposed' | 'failed';
142+
outcome: 'dropped' | 'not-proposed' | 'not-declared' | 'failed';
136143
detail?: string;
137144
}
138145

@@ -1102,12 +1109,24 @@ async function runColumnDrops(run: Run): Promise<StepReport> {
11021109
due.push(...table.tables);
11031110
}
11041111
let dropped = 0;
1112+
let notDeclared = 0;
11051113
if (due.length) {
11061114
for (const outcome of await run.opts.columnDrops.drop(due)) {
11071115
if (outcome.outcome === 'dropped') {
11081116
dropped++;
11091117
continue;
11101118
}
1119+
if (outcome.outcome === 'not-declared') {
1120+
notDeclared++;
1121+
blockers.push(
1122+
`${outcome.table}.${ORG} was not dropped: the composition this run booted declares no object for ` +
1123+
`${outcome.table}, so the schema-drift ceremony has no declaration to drop it against, and no release ` +
1124+
'changes that. Re-run the ceremony where the deployment\'s composition is declared: its project directory ' +
1125+
'and environment (the requires capability whose provider declares this object; OS_AUTH_SECRET for the ' +
1126+
'objects the auth family declares).',
1127+
);
1128+
continue;
1129+
}
11111130
blockers.push(
11121131
outcome.outcome === 'not-proposed'
11131132
? `${outcome.table}.${ORG} was not proposed for dropping by the schema-drift ceremony: the object this ` +
@@ -1117,7 +1136,13 @@ async function runColumnDrops(run: Run): Promise<StepReport> {
11171136
);
11181137
}
11191138
}
1120-
return { step: 'column-drops', status: blockers.length ? 'blocked' : 'done', summary: { dropped, due: due.length }, blockers, notes: [] };
1139+
return {
1140+
step: 'column-drops',
1141+
status: blockers.length ? 'blocked' : 'done',
1142+
summary: { dropped, due: due.length, notDeclared },
1143+
blockers,
1144+
notes: [],
1145+
};
11211146
}
11221147

11231148
// ── The run ──────────────────────────────────────────────────────────────────
Lines changed: 192 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,192 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* `os migrate organization-ownership --apply`, column drops: a table the
5+
* booted composition declares no object for is answered `not-declared`, never
6+
* blamed on the release (ADR-0131 D10, fate 1).
7+
*
8+
* The ceremony drops a column only through the schema-drift ceremony, so only
9+
* for an object its boot declares, and its boot declares the composition it is
10+
* run in. `sys_flow_dispatch`'s provider (`service-automation`) is composed
11+
* only when that composition's `requires` names `automation`. So run in a
12+
* composition that does not, the v17 column is kept and the step says why
13+
* (`summary.notDeclared`, a blocker naming the table). The control is the same
14+
* database run in a composition that does: the column is dropped.
15+
*
16+
* The fixture is a database a served boot of this tree left behind, shaped
17+
* like v17: `organization_id` added to `sys_flow_dispatch`, and to
18+
* `sys_migration_journal`, whose provider is composed on every boot, as the
19+
* in-run control.
20+
*/
21+
22+
import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest';
23+
import Database from 'better-sqlite3';
24+
import { copyFileSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs';
25+
import { randomBytes } from 'node:crypto';
26+
import { tmpdir } from 'node:os';
27+
import { dirname, join, resolve } from 'node:path';
28+
import { fileURLToPath } from 'node:url';
29+
import { isExitSignal } from './format.js';
30+
import MigrateOrganizationOwnership from '../commands/migrate/organization-ownership.js';
31+
32+
import '@objectstack/runtime';
33+
import '@objectstack/objectql';
34+
import { PlatformObjectsPlugin } from '@objectstack/platform-objects/plugin';
35+
import { AutomationServicePlugin } from '@objectstack/service-automation';
36+
37+
const HERE = dirname(fileURLToPath(import.meta.url));
38+
const CLI_ROOT = resolve(HERE, '..', '..');
39+
const CASE_TIMEOUT_MS = 120_000;
40+
41+
/** Env that would point the boot somewhere other than the fixture, or change what it composes. */
42+
const OVERRIDING_ENV = [
43+
'OS_DATABASE_URL', 'DATABASE_URL', 'TURSO_DATABASE_URL', 'OS_DATABASE_DRIVER', 'OS_HOME', 'OS_ARTIFACT_PATH',
44+
'OS_TELEMETRY_DB', 'OS_AUTH_SECRET',
45+
] as const;
46+
47+
const OBJECT = { name: 'osnd_note', label: 'Note', fields: { body: { type: 'text', label: 'Body' } } };
48+
const MANIFEST = { id: 'com.example.not-declared', name: 'Not Declared', version: '0.0.0', type: 'app' };
49+
50+
const FLOW_DISPATCH = 'sys_flow_dispatch';
51+
const CONTROL = 'sys_migration_journal';
52+
53+
let dir: string;
54+
let templateDb: string;
55+
const savedEnv: Record<string, string | undefined> = {};
56+
const savedCwd = process.cwd();
57+
58+
const hasOrganizationColumn = (dbFile: string, table: string): boolean => {
59+
const db = new Database(dbFile, { readonly: true });
60+
try {
61+
return (db.prepare(`PRAGMA table_info("${table}")`).all() as Array<{ name: string }>).some((c) => c.name === 'organization_id');
62+
} finally {
63+
db.close();
64+
}
65+
};
66+
67+
/** A served boot of this tree over `dbFile`: every table these plugins declare. */
68+
async function bootServed(projectDir: string, dbFile: string): Promise<void> {
69+
const { createStandaloneStack, Runtime } = await import('@objectstack/runtime');
70+
const stack = await createStandaloneStack({
71+
projectRoot: projectDir,
72+
databaseUrl: `file:${dbFile}`,
73+
skipSeedData: true,
74+
armLifecycleSweep: false,
75+
});
76+
const runtime = new Runtime({ cluster: false });
77+
const kernel = runtime.getKernel();
78+
for (const plugin of stack.plugins) await kernel.use(plugin as any);
79+
await kernel.use(new PlatformObjectsPlugin() as any);
80+
// The default store, so `sys_flow_dispatch` is declared and created.
81+
await kernel.use(new AutomationServicePlugin({ armRuntime: false }) as any);
82+
await runtime.start();
83+
await kernel.shutdown();
84+
}
85+
86+
/** A project directory whose config is `config` (no artifact). */
87+
function projectFor(name: string, config: Record<string, unknown>): string {
88+
const projectDir = join(dir, 'projects', name);
89+
mkdirSync(projectDir, { recursive: true });
90+
writeFileSync(join(projectDir, 'objectstack.config.mjs'), `export default ${JSON.stringify(config, null, 2)};\n`);
91+
return projectDir;
92+
}
93+
94+
/** The real command, `--apply`, from `projectDir`, over a fresh copy of the fixture. */
95+
async function applyIn(projectDir: string): Promise<{ dbFile: string; report: any; exitCode: number }> {
96+
const dbFile = join(projectDir, 'v17.db');
97+
copyFileSync(templateDb, dbFile);
98+
const savedExit = process.exitCode;
99+
const swallow = ((_chunk: unknown, ...rest: unknown[]) => {
100+
const cb = rest.find((a) => typeof a === 'function') as (() => void) | undefined;
101+
if (cb) cb();
102+
return true;
103+
}) as typeof process.stdout.write;
104+
const stdout = vi.spyOn(process.stdout, 'write').mockImplementation(swallow);
105+
vi.spyOn(process.stderr, 'write').mockImplementation(swallow);
106+
vi.spyOn(console, 'log').mockImplementation(() => {});
107+
vi.spyOn(console, 'warn').mockImplementation(() => {});
108+
vi.spyOn(console, 'error').mockImplementation(() => {});
109+
process.chdir(projectDir);
110+
let exitCode: number | undefined;
111+
try {
112+
try {
113+
await MigrateOrganizationOwnership.run(
114+
['--apply', '--backup-taken', '--out', join(projectDir, 'apply.json'), '--database-url', `file:${dbFile}`, '--json'],
115+
{ root: CLI_ROOT },
116+
);
117+
} catch (error) {
118+
if (!isExitSignal(error)) throw error;
119+
exitCode = (error as { oclif?: { exit?: number } }).oclif?.exit;
120+
}
121+
const payload = JSON.parse(stdout.mock.calls.map((c) => String(c[0])).join('').trim());
122+
expect(payload.error, JSON.stringify(payload).slice(0, 600)).toBeUndefined();
123+
return { dbFile, report: payload.report, exitCode: exitCode ?? (process.exitCode as number | undefined) ?? 0 };
124+
} finally {
125+
process.chdir(savedCwd);
126+
process.exitCode = savedExit;
127+
vi.restoreAllMocks();
128+
}
129+
}
130+
131+
const columnDrops = (report: any): { status: string; summary: Record<string, number>; blockers: string[] } =>
132+
report.steps.find((s: { step: string }) => s.step === 'column-drops');
133+
const blockersFor = (report: any, table: string): string[] =>
134+
columnDrops(report).blockers.filter((b) => b.includes(`${table}.organization_id`));
135+
136+
beforeAll(async () => {
137+
for (const key of [...OVERRIDING_ENV, 'NODE_ENV', 'OS_SECRET_KEY'] as const) savedEnv[key] = process.env[key];
138+
for (const key of OVERRIDING_ENV) delete process.env[key];
139+
// The deployed posture: no telemetry sibling, and the crypto provider's key.
140+
process.env.NODE_ENV = 'production';
141+
process.env.OS_SECRET_KEY = randomBytes(32).toString('hex');
142+
143+
dir = mkdtempSync(join(tmpdir(), 'os-ceremony-not-declared-'));
144+
const served = join(dir, 'served');
145+
mkdirSync(served, { recursive: true });
146+
templateDb = join(dir, 'template.db');
147+
await bootServed(served, templateDb);
148+
// The v17 shape: the injected tenant column both objects carried before
149+
// they declared `systemFields: { tenant: false }`.
150+
const db = new Database(templateDb);
151+
try {
152+
for (const table of [FLOW_DISPATCH, CONTROL]) db.exec(`ALTER TABLE "${table}" ADD COLUMN "organization_id" varchar(255)`);
153+
} finally {
154+
db.close();
155+
}
156+
}, CASE_TIMEOUT_MS);
157+
158+
afterAll(() => {
159+
process.chdir(savedCwd);
160+
for (const [key, value] of Object.entries(savedEnv)) {
161+
if (value === undefined) delete process.env[key];
162+
else process.env[key] = value;
163+
}
164+
rmSync(dir, { recursive: true, force: true });
165+
});
166+
167+
describe('organization-ownership --apply: a column-drop table the booted composition does not declare', () => {
168+
it('(i) a config that requests no automation: sys_flow_dispatch is not-declared, kept, and named', async () => {
169+
const { dbFile, report, exitCode } = await applyIn(projectFor('no-automation', { manifest: MANIFEST, objects: [OBJECT] }));
170+
171+
const step = columnDrops(report);
172+
expect(step.status).toBe('blocked');
173+
expect(step.summary.notDeclared).toBe(1);
174+
expect(blockersFor(report, FLOW_DISPATCH)).toHaveLength(1);
175+
expect(hasOrganizationColumn(dbFile, FLOW_DISPATCH)).toBe(true);
176+
// The in-run control: a declared column-drop table is dropped by the same run.
177+
expect(blockersFor(report, CONTROL)).toEqual([]);
178+
expect(hasOrganizationColumn(dbFile, CONTROL)).toBe(false);
179+
expect(exitCode).toBe(1);
180+
}, CASE_TIMEOUT_MS);
181+
182+
it('(ii) the control — a config with requires: [automation]: the same column is proposed and dropped', async () => {
183+
const { dbFile, report } = await applyIn(
184+
projectFor('automation', { manifest: MANIFEST, objects: [OBJECT], requires: ['automation'] }),
185+
);
186+
187+
expect(columnDrops(report).summary.notDeclared).toBe(0);
188+
expect(blockersFor(report, FLOW_DISPATCH)).toEqual([]);
189+
expect(report.tables.find((t: { object: string }) => t.object === FLOW_DISPATCH)).toMatchObject({ columnDropped: true });
190+
expect(hasOrganizationColumn(dbFile, FLOW_DISPATCH)).toBe(false);
191+
}, CASE_TIMEOUT_MS);
192+
});

0 commit comments

Comments
 (0)