From 295436ff784dc3bbe09d767c6ec06c156b0f3468 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 16 Oct 2026 14:48:26 +0000 Subject: [PATCH 1/5] feat(cli): the organization-ownership ceremony reports the declared sharing-rule mirrors as advisory while this release's boot still seeds them A served boot of this release still writes the package-declared sharing rules into sys_sharing_rule (plugin-sharing's seeder), organization-less under single -- the population the declared-rule-mirrors departure waits for. On this release that departure cannot happen, so the ceremony never certified a database whose packages declare sharing rules. DECLARED_SHARING_RULE_SEEDER_RUNS (the inventory) states that the seeder still runs; advisoryDepartureReason keys the rule on the table and the departure id. The plan marks those departures advisory, keeps the table's NOT NULL reading false with the reason, and warns before the first write; the apply's post-check counts their rows in advisory, never in remaining or blocking. Every other departure and remaining row still blocks. Claude-Session: https://claude.ai/code/session_01PFB9ZGRWYvjhTXsd8i6KBp Co-authored-by: Claude --- .../migrate/organization-ownership.ts | 23 +++++-- .../src/utils/organization-ownership-apply.ts | 44 ++++++++++--- .../utils/organization-ownership-inventory.ts | 64 +++++++++++++++++++ .../src/utils/organization-ownership-plan.ts | 63 ++++++++++++++++-- 4 files changed, 174 insertions(+), 20 deletions(-) diff --git a/packages/cli/src/commands/migrate/organization-ownership.ts b/packages/cli/src/commands/migrate/organization-ownership.ts index 1fa89dac356..7e5b49d20b9 100644 --- a/packages/cli/src/commands/migrate/organization-ownership.ts +++ b/packages/cli/src/commands/migrate/organization-ownership.ts @@ -257,7 +257,9 @@ class ReportFileReservation { * row {@link ORGANIZATION_OWNERSHIP_MIGRATION_ID}) so an interrupted run * resumes where it stopped, and ends with the post-check. The ledger row is * the ceremony's marker: `applied_at` once every step has finished, - * `verified_at` when the post-check also reads zero discrepancies. A report + * `verified_at` when the post-check also reads zero discrepancies (a departure + * this release cannot perform — the declared sharing-rule mirrors its own boot + * keeps seeding — is reported as advisory, not as a discrepancy). A report * file is written beside the plan's, never over an existing one: it is reserved * (created exclusively) before the boot, so an `--out` that cannot be written * is refused before anything executes (`apply_refused`), and a report that @@ -972,7 +974,11 @@ export default class MigrateOrganizationOwnership extends Command { `${table.remaining ? chalk.red(`remaining ${table.remaining}`) : ''}`, ); for (const departure of table.departures) { - console.log(chalk.yellow(` ${departure.rows} row(s) still to leave by a step not yet run — ${departure.label}`)); + console.log(chalk.yellow( + departure.advisory + ? ` ${departure.rows} row(s) advisory, not held against certification — ${departure.label}: ${departure.advisory}` + : ` ${departure.rows} row(s) still to leave by a step not yet run — ${departure.label}`, + )); } for (const conflict of table.templateConflicts ?? []) { console.log(chalk.yellow( @@ -983,8 +989,17 @@ export default class MigrateOrganizationOwnership extends Command { } } console.log(''); - if (report.advisory > 0) { - printWarning(`${report.advisory} row(s) whose owner cannot be derived stay NULL; they are listed by id in the report file.`); + // `advisory` counts two populations; each is said in its own terms. + const unattributable = report.tables.reduce((n, t) => n + t.unattributable.length, 0); + const held = report.tables.reduce((n, t) => n + t.departures.reduce((m, d) => m + (d.advisory ? d.rows : 0), 0), 0); + if (unattributable > 0) { + printWarning(`${unattributable} row(s) whose owner cannot be derived stay NULL; they are listed by id in the report file.`); + } + if (held > 0) { + printWarning( + `${held} row(s) of a departure this release cannot perform stay NULL and are reported as advisory, not as ` + + 'discrepancies; each is named above with what removes it.', + ); } if (verdictCode === 0) { printSuccess(`The ceremony is complete; recorded as ${ORGANIZATION_OWNERSHIP_MIGRATION_ID}. Report: ${target}`); diff --git a/packages/cli/src/utils/organization-ownership-apply.ts b/packages/cli/src/utils/organization-ownership-apply.ts index 31d05723488..e3237c0dc62 100644 --- a/packages/cli/src/utils/organization-ownership-apply.ts +++ b/packages/cli/src/utils/organization-ownership-apply.ts @@ -124,6 +124,11 @@ import { describeSecurityPluginComposition, type SchemaMigrationComposition } fr * one would refuse them; they are reported, never deleted). * * Then the post-check re-runs the plan and reports, per table, what remains. + * A departure no step of this release can perform — the declared sharing-rule + * mirrors, which this release's own boot keeps seeding + * (`DECLARED_SHARING_RULE_SEEDER_RUNS`, the inventory) — is reported as + * advisory: named and counted, its rows still NULL, never deleted, and not a + * discrepancy. Every other remaining row is one. * * ## Idempotent and resumable * @@ -431,12 +436,15 @@ export interface TableOutcome { /** * Rows that leave this table by a step this ceremony has not yet performed * (a move, or a mirror its own stage removes): each counts as remaining - * until it is gone, and its label is the remedy. + * until it is gone, and its label is the remedy. One that carries `advisory` + * is a departure no step of this release can perform, because its own boot + * re-creates the rows (`PlannedDeparture.advisory`): its rows are named and + * counted here and in the run's `advisory`, never in `remaining`. */ - departures: Array<{ id: string; label: string; as: 'mirror-deletion' | 'moved'; rows: number }>; + departures: Array<{ id: string; label: string; as: 'mirror-deletion' | 'moved'; rows: number; advisory?: string }>; /** Templates: each `(name, locale)` whose rows still differ, with every candidate row and its organization. */ templateConflicts?: TemplateConflict[]; - /** Post-check: what is left for this table's fate, `0` when the fate is done. */ + /** Post-check: what is left for this table's fate, `0` when the fate is done; advisory departures are not in it. */ remaining: number; notNull: TablePlan['notNull']; } @@ -466,7 +474,11 @@ export interface OrganizationOwnershipApplyReport { complete: boolean; /** Blockers (an unfinished ceremony) or post-check discrepancies (a finished one). Zero is the verdict. */ blocking: number; - /** Rows reported, never guessed: unattributable rows and rows held for another stage. */ + /** + * Rows reported, never guessed and never held against the verdict: + * unattributable rows, and the rows of an advisory departure, held for the + * release that can remove them (`TableOutcome.departures`). + */ advisory: number; } @@ -2506,8 +2518,15 @@ const RUNNERS: Record Promise> = { 'column-drops': runColumnDrops, }; -/** NULL rows a departure still has to carry out of the table. */ -const departingRows = (table: TablePlan): number => table.departures.reduce((n, d) => n + d.rows, 0); +/** + * NULL rows a departure still has to carry out of the table. An advisory + * departure's rows are not among them: no step of this release removes them + * (`PlannedDeparture.advisory`), so they are counted as advisory instead. + */ +const departingRows = (table: TablePlan): number => table.departures.reduce((n, d) => n + (d.advisory ? 0 : d.rows), 0); + +/** NULL rows of the table's advisory departures: reported, never held against the verdict. */ +const advisoryRows = (table: TablePlan): number => table.departures.reduce((n, d) => n + (d.advisory ? d.rows : 0), 0); /** What a table still owes its fate, read from the post-check plan. */ async function remainingFor(run: Run, table: TablePlan, complete: boolean): Promise { @@ -2732,7 +2751,9 @@ export async function applyOrganizationOwnership(opts: ApplyOptions): Promise d.rows > 0).map((d) => ({ id: d.id, label: d.label, as: d.as, rows: d.rows })); + const departures: TableOutcome['departures'] = (a?.departures ?? []) + .filter((d) => d.rows > 0) + .map((d) => ({ id: d.id, label: d.label, as: d.as, rows: d.rows, ...(d.advisory ? { advisory: d.advisory } : {}) })); if (a?.physical === 'present' && object === TEMPLATES) { let unpromoted = 0; for (const physical of a.tables) { @@ -2747,7 +2768,10 @@ export async function applyOrganizationOwnership(opts: ApplyOptions): Promise n + d.rows, 0); + // Advisory rows stay NULL too, and the plan's own NOT NULL reading + // (`ref.notNull`) already says so; only a step not yet run overrides it. + const pending = departures.filter((d) => !d.advisory); + const waiting = pending.reduce((n, d) => n + d.rows, 0); tables.push({ object, fate: ref.fate, @@ -2763,11 +2787,11 @@ export async function applyOrganizationOwnership(opts: ApplyOptions): Promise 0 - ? { willReceive: false, reason: `${waiting} row(s) still wait for a step not yet run: ${departures.map((d) => d.label).join('; ')}` } + ? { willReceive: false, reason: `${waiting} row(s) still wait for a step not yet run: ${pending.map((d) => d.label).join('; ')}` } : ref.notNull, }); } - const advisory = after.summary.unattributableRows; + const advisory = after.summary.unattributableRows + after.tables.reduce((n, t) => n + advisoryRows(t), 0); const blocking = complete ? discrepancies : steps.reduce((n, s) => n + s.blockers.length, 0) || 1; await opts.ledger.write(checkpoint, { complete, blocking, advisory }); return { diff --git a/packages/cli/src/utils/organization-ownership-inventory.ts b/packages/cli/src/utils/organization-ownership-inventory.ts index 0898cad9b82..e15a504d8b8 100644 --- a/packages/cli/src/utils/organization-ownership-inventory.ts +++ b/packages/cli/src/utils/organization-ownership-inventory.ts @@ -509,6 +509,8 @@ export const ORGANIZATION_OWNERSHIP_INVENTORY: readonly InventoryEntry[] = [ fate: 'attribution', citation: `${D3_ORG_ROWS} (sharing rules an organization authors); ${D10_SINGLE}`, anchors: [], + // Advisory, not blocking, while this release's boot still seeds these rows: + // DECLARED_SHARING_RULE_SEEDER_RUNS below. departures: [ { id: 'declared-rule-mirrors', @@ -612,6 +614,68 @@ export function inventoryEntryFor(object: string): InventoryEntry | undefined { return BY_OBJECT.get(object); } +// ── A departure this release cannot perform: the declared sharing-rule mirrors ─ + +/** + * Does a served boot of this release still write the package-declared sharing + * rules into `sys_sharing_rule`? + * + * It does: `@objectstack/plugin-sharing`'s `start()` runs + * `bootstrapDeclaredSharingRules` on every `os serve` / `os dev` boot, and + * under `single` it writes each declared rule organization-less, as + * `managed_by 'package'`, uncustomized — exactly the population the + * `declared-rule-mirrors` departure on `sys_sharing_rule` names. That includes + * the first v18 boot, the one the organization-ownership refusal stops: its + * plugins start before `kernel:ready`, so it seeds before the ceremony has run. + * (The ceremony's own boot does not: it composes the sharing plugin for its + * declarations, its `start()` suppressed.) + * + * So on this release that departure cannot happen: the step that removes the + * mirrors is ADR-0131 D2's retirement of that seeder, and the boot keeps + * re-creating them until then. The ceremony therefore reports those rows as + * advisory — named, counted, never deleted, and still NULL, so their table + * does not receive NOT NULL — and does not hold its certification on them + * ({@link advisoryDepartureReason}). That is the ordering ruled for ADR-0131 + * C3: the D13/D14 zero-NULL gate is read as satisfied by a D10 report whose + * only remaining NULL rows are these mirrors, and any other NULL row keeps + * blocking. + * + * ⛔ Set it to `false` in the change that retires that seeder, and in no other: + * a release that seeds the rows must not hold certification on them, and a + * release that has stopped seeding them must hold it again until its ceremony + * deletes them. `commands/migrate/organization-ownership.declared-rule-mirrors.integration.test.ts` + * boots a served stack declaring one sharing rule and fails, naming this + * constant, while what that boot writes disagrees with it. + */ +export const DECLARED_SHARING_RULE_SEEDER_RUNS: boolean = true; + +/** The one departure {@link DECLARED_SHARING_RULE_SEEDER_RUNS} decides, by its table and its id. */ +const DECLARED_RULE_MIRRORS = { object: 'sys_sharing_rule', departure: 'declared-rule-mirrors' } as const; + +/** + * Why the rows of `departureId` on `object` stay on this release without + * holding the ceremony's certification, or `null` when they hold it — every + * departure but the declared sharing-rule mirrors, and those too once their + * seeder retires ({@link DECLARED_SHARING_RULE_SEEDER_RUNS}). + * + * Keyed on the table AND the id: a host fate supplement can declare a + * departure under any id on its own table, and that departure's rows block + * like every other. The answer is the ceremony's verdict rule, not inventory + * data, so it is not part of the plan's `inventoryDigest`: a plan reviewed + * under it executes the same writes either way. + */ +export function advisoryDepartureReason( + object: string, + departureId: string, + seederRuns: boolean = DECLARED_SHARING_RULE_SEEDER_RUNS, +): string | null { + if (!seederRuns || object !== DECLARED_RULE_MIRRORS.object || departureId !== DECLARED_RULE_MIRRORS.departure) return null; + return ( + 'this release\'s own boot still writes them (the declared-sharing-rule seeder runs on every served boot), so no ' + + 'step of this release can remove them; the release that retires that seeder (ADR-0131 D2) deletes them' + ); +} + // ── The host fate supplement ────────────────────────────────────────────────── /** Why a host fate supplement cannot join the inventory. */ diff --git a/packages/cli/src/utils/organization-ownership-plan.ts b/packages/cli/src/utils/organization-ownership-plan.ts index ba6386b1e9c..7d3fd9851c9 100644 --- a/packages/cli/src/utils/organization-ownership-plan.ts +++ b/packages/cli/src/utils/organization-ownership-plan.ts @@ -9,6 +9,7 @@ import { LEGACY_SHIPPED_SET_SEEDS, LEGACY_SHIPPED_SET_SEEDS_CATEGORY, ORGANIZATION_OWNERSHIP_INVENTORY, + advisoryDepartureReason, type Anchor, type InventoryCategory, type InventoryEntry, @@ -163,6 +164,15 @@ export interface PlannedDeparture { as: 'mirror-deletion' | 'moved'; /** NULL-organization rows this departure takes out of attribution. */ rows: number; + /** + * Present when no step of this release can carry these rows out, because its + * own boot re-creates them: why, and what removes them + * ({@link advisoryDepartureReason}). Such rows stay NULL — their table does + * not receive NOT NULL — and are reported as advisory, never held against + * the ceremony's certification. Absent, the rows leave by a ceremony step, + * and count as remaining until they have. + */ + advisory?: string; } export interface TablePlan { @@ -980,7 +990,10 @@ async function planPresentTable(ctx: PlanContext, entry: InventoryEntry, source: organizationColumn: hasOrg ? 'present' : 'absent', rows: { total: 0, organizationNull: 0, stamped: 0 }, fateCounts: {}, - departures: (entry.departures ?? []).map((d) => ({ id: d.id, label: d.label, citation: d.citation, as: d.as, rows: 0 })), + departures: (entry.departures ?? []).map((d) => { + const advisory = advisoryDepartureReason(entry.object, d.id); + return { id: d.id, label: d.label, citation: d.citation, as: d.as, rows: 0, ...(advisory ? { advisory } : {}) }; + }), categories: [], unattributable: [], notNull: { willReceive: false, reason: '' }, @@ -1048,6 +1061,9 @@ async function planPresentTable(ctx: PlanContext, entry: InventoryEntry, source: const rows = plan.rows!; const departing = plan.departures.reduce((n, d) => n + d.rows, 0); + // NULL rows no step of this release removes, beside the unattributable + // ones: they stay NULL, so the column stays nullable on this release. + const held = heldNullReason(plan); switch (entry.fate) { case 'column-drop': plan.fateCounts = { columnPresent: hasOrg, rowsLosingTheirStamp: rows.stamped, rowsWithNullOrganization: rows.organizationNull }; @@ -1061,27 +1077,50 @@ async function planPresentTable(ctx: PlanContext, entry: InventoryEntry, source: const attributed = Object.values(derived).reduce((n, v) => n + v, 0); plan.attribution = derived; plan.fateCounts = { rowsToAttribute: attributed, departingRows: departing, unattributableRows: plan.unattributable.length }; + const staying = [ + ...(plan.unattributable.length > 0 ? [`${plan.unattributable.length} row(s) whose owner cannot be derived stay NULL and are reported`] : []), + ...(held ? [held] : []), + ]; plan.notNull = !hasOrg ? { willReceive: false, reason: 'the table carries no organization_id column' } - : plan.unattributable.length === 0 + : staying.length === 0 ? { willReceive: true, reason: 'every NULL row is attributed or departs' } - : { willReceive: false, reason: `${plan.unattributable.length} row(s) whose owner cannot be derived stay NULL and are reported` }; + : { willReceive: false, reason: staying.join('; ') }; break; } - case 'report': + case 'report': { plan.fateCounts = { reportedRows: plan.unattributable.length, departingRows: departing }; + const staying = [ + ...(plan.unattributable.length > 0 ? [`${plan.unattributable.length} reported row(s) stay NULL`] : []), + ...(held ? [held] : []), + ]; plan.notNull = entry.notNullExempt ? { willReceive: false, reason: entry.notNullExempt } : !hasOrg ? { willReceive: false, reason: 'the table carries no organization_id column' } - : plan.unattributable.length === 0 + : staying.length === 0 ? { willReceive: true, reason: 'no NULL row remains' } - : { willReceive: false, reason: `${plan.unattributable.length} reported row(s) stay NULL` }; + : { willReceive: false, reason: staying.join('; ') }; break; + } } return plan; } +/** + * The NOT NULL reading's clause for a table's advisory departures — rows no + * step of this release carries out ({@link PlannedDeparture.advisory}), which + * therefore stay NULL — or `null` when it holds none. + */ +function heldNullReason(plan: TablePlan): string | null { + const held = plan.departures.filter((d) => d.advisory && d.rows > 0); + if (held.length === 0) return null; + return ( + `${held.reduce((n, d) => n + d.rows, 0)} row(s) stay NULL on this release, reported as advisory: ` + + held.map((d) => d.label).join('; ') + ); +} + /** Keyset-walk the NULL-organization rows of one physical table and classify each. */ async function walkNullRows( ctx: PlanContext, @@ -1333,6 +1372,18 @@ export async function buildOrganizationOwnershipPlan(opts: { 'longer needed.', ); } + // Certifying with NULL rows standing is decided on the operator's behalf, so + // it is said before the first write, once per departure that holds rows. + for (const table of tables) { + for (const departure of table.departures) { + if (!departure.advisory || departure.rows === 0) continue; + warnings.push( + `${table.object} holds ${departure.rows} organization-less row(s) of ${departure.label}, which stay: ` + + `${departure.advisory}. The ceremony reports them as advisory and does not hold its ` + + `certification on them; they keep organization_id NULL, so ${table.object} does not receive NOT NULL on this release.`, + ); + } + } const byFate = { 'column-drop': 0, 'mirror-deletion': 0, attribution: 0, report: 0 } as Record; for (const t of tables) if (t.physical === 'present') byFate[t.fate]++; From 3ac9994582db6fce4d35766e5f44b2d5e18687b5 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 16 Oct 2026 14:52:09 +0000 Subject: [PATCH 2/5] test(cli): the declared sharing-rule mirrors' advisory verdict, its restore and its keying The existing "departures have not left" case reads the new rule: the one rule mirror is advisory, named and counted, the verdict is blocking 0 and nothing is deleted. A HotCRM-shaped fixture (eleven mirrors) pins blocking 0, the restore once the seeder retires (blocking 11 again), and that a host departure under the same id on its own table still blocks. Claude-Session: https://claude.ai/code/session_01PFB9ZGRWYvjhTXsd8i6KBp Co-authored-by: Claude --- ...ership-apply.declared-rule-mirrors.test.ts | 256 ++++++++++++++++++ .../organization-ownership-apply.test.ts | 30 +- 2 files changed, 277 insertions(+), 9 deletions(-) create mode 100644 packages/cli/src/utils/organization-ownership-apply.declared-rule-mirrors.test.ts diff --git a/packages/cli/src/utils/organization-ownership-apply.declared-rule-mirrors.test.ts b/packages/cli/src/utils/organization-ownership-apply.declared-rule-mirrors.test.ts new file mode 100644 index 00000000000..75b1a208172 --- /dev/null +++ b/packages/cli/src/utils/organization-ownership-apply.declared-rule-mirrors.test.ts @@ -0,0 +1,256 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * The ADR-0131 D10 `--apply` on a database whose packages declare sharing + * rules, while this release's own boot still seeds them + * (`DECLARED_SHARING_RULE_SEEDER_RUNS`, the inventory). + * + * The fixture is HotCRM's measured shape: eleven uncustomized declared rules + * the seeder wrote organization-less (`managed_by 'package'`, `customized 0`), + * beside an organization-authored rule and a customized declared rule with no + * organization, plus a column-drop table still carrying `organization_id`. + * + * - On this release the eleven are the `declared-rule-mirrors` departure, and + * no step of it can remove them: the run reports them as advisory — named, + * counted, still NULL, never deleted — and the verdict is `blocking 0`. + * - The restore: the release that retires the seeder flips the constant, and + * the same fixture then holds certification on the same eleven rows again. + * - The rule is keyed on the table AND the departure id: a host fate + * supplement departure of the same id on its own table still blocks. + * + * The command face of the same verdict (the ledger marker, the boot guard, + * the served boot that seeds the rows) is + * `commands/migrate/organization-ownership.declared-rule-mirrors.integration.test.ts`. + */ + +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import Database from 'better-sqlite3'; +import { mkdtempSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { buildOrganizationOwnershipPlan, inventoryDigest } from './organization-ownership-plan.js'; +import { + applyOrganizationOwnership, + reviewedConflictsOf, + type ApplyExecutor, + type CeremonyCheckpoint, + type CeremonyLedgerPort, + type ColumnDropPort, + type OrganizationOwnershipApplyReport, + type TableOutcome, +} from './organization-ownership-apply.js'; +import { mergeHostFateSupplement, type InventoryEntry } from './organization-ownership-inventory.js'; + +/** + * The restore case answers as the release that retires the seeder will + * (`runs: false`); every other case reads the shipped constant, through the + * real function, untouched (`runs: undefined`). + */ +const seeder = vi.hoisted(() => ({ runs: undefined as boolean | undefined })); +vi.mock('./organization-ownership-inventory.js', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + advisoryDepartureReason: (object: string, departureId: string) => + seeder.runs === undefined + ? actual.advisoryDepartureReason(object, departureId) + : actual.advisoryDepartureReason(object, departureId, seeder.runs), + }; +}); + +const MIRRORS = 11; +const CITE = 'fixture: a host-declared fate (test data, not any host\'s declaration)'; + +let dir: string; +let db: Database.Database; +/** Every write statement the ceremony issued, in order. */ +let writes: string[]; + +function executor(): ApplyExecutor { + return { + dialect: 'sqlite', + client: 'better-sqlite3', + async query(sql, params = []) { + return db.prepare(sql).all(...(params as unknown[])) as Record[]; + }, + async execute(sql, params = []) { + writes.push(sql); + db.prepare(sql).run(...(params as unknown[])); + }, + }; +} + +const columnDrops: ColumnDropPort = { + async drop(columns) { + return columns.map(({ table, column }) => { + writes.push(`ALTER TABLE ${table} DROP COLUMN ${column}`); + db.exec(`ALTER TABLE "${table}" DROP COLUMN "${column}"`); + return { table, column, outcome: 'dropped' as const }; + }); + }, +}; + +type Verdict = { complete: boolean; blocking: number; advisory: number }; + +function ledger(): CeremonyLedgerPort & { verdicts: Verdict[] } { + let stored: CeremonyCheckpoint | null = null; + const verdicts: Verdict[] = []; + return { + verdicts, + async read() { + return stored ? (JSON.parse(JSON.stringify(stored)) as CeremonyCheckpoint) : null; + }, + async write(checkpoint, verdict) { + stored = JSON.parse(JSON.stringify(checkpoint)) as CeremonyCheckpoint; + verdicts.push(verdict); + }, + }; +} + +/** Plan, then apply that reviewed plan, under `single`, as the command does. */ +async function apply(l: ReturnType, inventory?: readonly InventoryEntry[]): Promise { + const database = join(dir, 'fixture.db'); + const reviewed = await buildOrganizationOwnershipPlan({ + reader: executor(), + posture: 'single', + database, + ...(inventory ? { inventory } : {}), + }); + return applyOrganizationOwnership({ + executor: executor(), + posture: 'single', + database, + ...(inventory ? { inventory } : {}), + reviewedPlan: { + file: 'plan.json', + generatedAt: reviewed.generatedAt, + database, + inventoryDigest: inventoryDigest(inventory), + posture: 'single', + conflicts: reviewedConflictsOf(reviewed), + }, + catalog: { async resolve() { return undefined; } }, + door: { + async save(type, name) { + throw new Error(`no environment save is expected here (${type} ${name})`); + }, + }, + templates: { async find() { return undefined; } }, + views: { async list() { return []; } }, + lock: { async verdict() { return { editable: true }; } }, + columnDrops, + ledger: l, + now: () => new Date('2026-10-16T00:00:00Z'), + }); +} + +const rows = (sql: string): Record[] => db.prepare(sql).all() as Record[]; +const tableOf = (report: OrganizationOwnershipApplyReport, object: string): TableOutcome => + report.tables.find((t) => t.object === object)!; + +beforeEach(() => { + dir = mkdtempSync(join(tmpdir(), 'os-d10-declared-rule-mirrors-')); + db = new Database(join(dir, 'fixture.db')); + writes = []; + db.exec(` + CREATE TABLE sys_organization (id TEXT PRIMARY KEY, slug TEXT); + INSERT INTO sys_organization VALUES ('org_default', 'default'); + CREATE TABLE sys_sharing_rule (id TEXT PRIMARY KEY, organization_id TEXT, name TEXT, object_name TEXT, + managed_by TEXT, customized INTEGER); + INSERT INTO sys_sharing_rule VALUES + ('rule_org', 'org_default', 'mine', 'crm_deal', 'admin', 0), + ('rule_customized', NULL, 'share_custom', 'crm_deal', 'package', 1); + CREATE TABLE sys_job (id TEXT PRIMARY KEY, organization_id TEXT, name TEXT); + INSERT INTO sys_job VALUES ('job_v17', NULL, 'nightly'); + `); + const mirror = db.prepare(`INSERT INTO sys_sharing_rule VALUES (?, NULL, ?, 'crm_deal', 'package', 0)`); + for (let i = 1; i <= MIRRORS; i++) mirror.run(`rule_declared_${String(i).padStart(2, '0')}`, `share_declared_${i}`); +}); + +afterEach(() => { + seeder.runs = undefined; + db.close(); + rmSync(dir, { recursive: true, force: true }); +}); + +describe('the declared sharing-rule mirrors while this release\'s boot still seeds them', () => { + it('blocking 0: the eleven are advisory — named, counted, still NULL, never deleted — and the verdict certifies', async () => { + const l = ledger(); + const report = await apply(l); + + expect([report.complete, report.blocking, report.advisory]).toEqual([true, 0, MIRRORS]); + expect(l.verdicts.at(-1)).toEqual({ complete: true, blocking: 0, advisory: MIRRORS }); + const rule = tableOf(report, 'sys_sharing_rule'); + expect(rule.departures).toEqual([ + { + id: 'declared-rule-mirrors', + label: expect.stringMatching(/^uncustomized declared sharing rules/), + as: 'mirror-deletion', + rows: MIRRORS, + advisory: expect.stringMatching(/^this release's own boot still writes them .* \(ADR-0131 D2\) deletes them$/), + }, + ]); + expect(rule.remaining).toBe(0); + expect(rule.notNull).toEqual({ + willReceive: false, + reason: `${MIRRORS} row(s) stay NULL on this release, reported as advisory: ${rule.departures[0]!.label}`, + }); + expect(report.warnings).toEqual([expect.stringMatching(new RegExp(`^sys_sharing_rule holds ${MIRRORS} organization-less row\\(s\\) of `))]); + // The customized rule is no mirror: it took the Default Organization. The + // mirrors kept NULL, and nothing of the table was deleted. + expect(rows(`SELECT COUNT(*) AS n, SUM(organization_id IS NULL) AS nulls FROM sys_sharing_rule`)).toEqual([{ n: MIRRORS + 2, nulls: MIRRORS }]); + expect(rows(`SELECT organization_id FROM sys_sharing_rule WHERE id = 'rule_customized'`)).toEqual([{ organization_id: 'org_default' }]); + expect(writes.filter((w) => /^DELETE/i.test(w))).toEqual([]); + expect(tableOf(report, 'sys_job').columnDropped).toBe(true); + }); + + it('the restore: once the seeder retires, the same fixture holds certification on the same eleven rows again', async () => { + seeder.runs = false; + const l = ledger(); + const report = await apply(l); + + expect([report.complete, report.blocking, report.advisory]).toEqual([true, MIRRORS, 0]); + expect(l.verdicts.at(-1)).toEqual({ complete: true, blocking: MIRRORS, advisory: 0 }); + const rule = tableOf(report, 'sys_sharing_rule'); + expect(rule.departures).toEqual([{ id: 'declared-rule-mirrors', label: expect.any(String), as: 'mirror-deletion', rows: MIRRORS }]); + expect(rule.remaining).toBe(MIRRORS); + expect(rule.notNull).toEqual({ willReceive: false, reason: expect.stringMatching(new RegExp(`^${MIRRORS} row\\(s\\) still wait for a step not yet run: `)) }); + expect(report.warnings).toEqual([]); + expect(rows(`SELECT COUNT(*) AS n FROM sys_sharing_rule`)).toEqual([{ n: MIRRORS + 2 }]); + }); + + it('keyed on the table: a host-declared departure under the same id on its own table still blocks, beside the advisory mirrors', async () => { + db.exec(` + CREATE TABLE sys_fixture_rule_copy (id TEXT PRIMARY KEY, organization_id TEXT, managed_by TEXT); + INSERT INTO sys_fixture_rule_copy VALUES ('copy_1', NULL, 'package'), ('copy_2', NULL, 'package'); + `); + const inventory = mergeHostFateSupplement( + [ + { + object: 'sys_fixture_rule_copy', + fate: 'report', + citation: CITE, + departures: [ + { + id: 'declared-rule-mirrors', + label: 'a host table\'s seeded copies, removed by a step no ceremony runs', + citation: CITE, + as: 'mirror-deletion', + where: [{ column: 'managed_by', equals: 'package' }], + }, + ], + }, + ], + 'host-fates.json', + ); + const l = ledger(); + const report = await apply(l, inventory); + + expect([report.complete, report.blocking, report.advisory]).toEqual([true, 2, MIRRORS]); + expect(l.verdicts.at(-1)).toEqual({ complete: true, blocking: 2, advisory: MIRRORS }); + const copy = tableOf(report, 'sys_fixture_rule_copy'); + expect(copy.remaining).toBe(2); + expect(copy.departures).toEqual([{ id: 'declared-rule-mirrors', label: expect.any(String), as: 'mirror-deletion', rows: 2 }]); + expect(tableOf(report, 'sys_sharing_rule').remaining).toBe(0); + }); +}); diff --git a/packages/cli/src/utils/organization-ownership-apply.test.ts b/packages/cli/src/utils/organization-ownership-apply.test.ts index a768930c8d0..b096ca4d4a6 100644 --- a/packages/cli/src/utils/organization-ownership-apply.test.ts +++ b/packages/cli/src/utils/organization-ownership-apply.test.ts @@ -882,7 +882,7 @@ describe('ADR-0131 D10 --apply — idempotent, resumable, gated', () => { expect(rows('SELECT COUNT(*) AS n FROM sys_email_template')).toEqual([{ n: 6 }]); }); - it('does not certify a ceremony whose departures have not left: the declared sharing-rule mirrors keep verified_at null', async () => { + it('certifies a ceremony whose only remaining NULL rows are the declared sharing-rule mirrors this release still seeds: advisory, named, kept', async () => { db.exec(` CREATE TABLE sys_sharing_rule (id TEXT PRIMARY KEY, organization_id TEXT, name TEXT, managed_by TEXT, customized INTEGER); INSERT INTO sys_sharing_rule VALUES ('rule_declared', NULL, 'share_deals', 'package', 0), ('rule_org', 'org_b', 'mine', 'admin', 0); @@ -890,17 +890,29 @@ describe('ADR-0131 D10 --apply — idempotent, resumable, gated', () => { const l = ledger(); const report = await apply(catalog(), l); - // Every step ran, and the moved settings rung reads zero; the rule mirror - // is D2's to remove, so it still counts and is named. + // Every step ran, and the moved settings rung reads zero. The rule mirror + // is D2's to remove, and this release's own boot keeps seeding it, so it + // is advisory: named and counted beside the unattributable rows, never a + // discrepancy (the restore and the negative legs: + // `organization-ownership-apply.declared-rule-mirrors.test.ts`). + const unattributable = report.tables.reduce((n, t) => n + t.unattributable.length, 0); expect(report.complete).toBe(true); - expect(report.blocking).toBe(1); - expect(l.verdicts.at(-1)).toMatchObject({ complete: true, blocking: 1 }); + expect(report.blocking).toBe(0); + expect(report.advisory).toBe(unattributable + 1); + expect(l.verdicts.at(-1)).toEqual({ complete: true, blocking: 0, advisory: unattributable + 1 }); expect(tableOf(report, 'sys_setting').departures).toEqual([]); const rule = tableOf(report, 'sys_sharing_rule'); - expect(rule.remaining).toBe(1); - expect(rule.departures).toEqual([expect.objectContaining({ id: 'declared-rule-mirrors', as: 'mirror-deletion', rows: 1 })]); - expect(rule.notNull.willReceive).toBe(false); - expect(rows(`SELECT id FROM sys_sharing_rule ORDER BY id`).map((r) => r.id)).toEqual(['rule_declared', 'rule_org']); + expect(rule.remaining).toBe(0); + expect(rule.departures).toEqual([ + { id: 'declared-rule-mirrors', label: expect.any(String), as: 'mirror-deletion', rows: 1, advisory: expect.stringMatching(/own boot still writes them/) }, + ]); + // Still NULL, so the column takes no NOT NULL; the operator was told before the first write. + expect(rule.notNull).toEqual({ willReceive: false, reason: expect.stringMatching(/^1 row\(s\) stay NULL on this release, reported as advisory: /) }); + expect(report.warnings.filter((w) => w.startsWith('sys_sharing_rule holds 1 organization-less row(s) of '))).toHaveLength(1); + expect(rows(`SELECT id, organization_id FROM sys_sharing_rule ORDER BY id`)).toEqual([ + { id: 'rule_declared', organization_id: null }, + { id: 'rule_org', organization_id: 'org_b' }, + ]); }); it('promotes under a wall too: a template with no conflict ends verified, its seeded mirror and its row gone', async () => { From 855369fa1ef1e0e8c6998123029feb835220f19a Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 16 Oct 2026 14:55:59 +0000 Subject: [PATCH 3/5] test(cli): the ceremony certifies a database whose packages declare sharing rules, at the door and on the remote face A served boot of this release (verify's bootStack) of a package declaring one rule writes it organization-less as package-managed, which is what DECLARED_SHARING_RULE_SEEDER_RUNS states; that case fails until the constant flips when the seeder retires. Plan and --apply --json then certify HotCRM's shape (eleven mirrors): blocking 0, advisory 11, the sys_migration marker verified, every rule kept, and a served boot with the v18 boot guard composed starts while a v17-shaped table stands. The sqld remote-face case now carries eleven mirrors too. Claude-Session: https://claude.ai/code/session_01PFB9ZGRWYvjhTXsd8i6KBp Co-authored-by: Claude --- ....declared-rule-mirrors.integration.test.ts | 238 ++++++++++++++++++ ...-ownership.remote-face.integration.test.ts | 22 +- 2 files changed, 258 insertions(+), 2 deletions(-) create mode 100644 packages/cli/src/commands/migrate/organization-ownership.declared-rule-mirrors.integration.test.ts diff --git a/packages/cli/src/commands/migrate/organization-ownership.declared-rule-mirrors.integration.test.ts b/packages/cli/src/commands/migrate/organization-ownership.declared-rule-mirrors.integration.test.ts new file mode 100644 index 00000000000..044680f94f8 --- /dev/null +++ b/packages/cli/src/commands/migrate/organization-ownership.declared-rule-mirrors.integration.test.ts @@ -0,0 +1,238 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * `os migrate organization-ownership --apply` on a v17-shaped database whose + * packages declare sharing rules, on a release whose served boot still seeds + * them — through the command, over a SQLite file. + * + * ## The database + * + * Built the way an environment's is. First a served boot of THIS release + * (`@objectstack/verify`'s `bootStack`: `os serve`'s composition, plugin starts + * included) of a package declaring one sharing rule, over the file. Then what + * a 17.x runtime left beside it: ten more declared rules its seeder wrote + * organization-less, an organization's own rule, a customized declared rule + * with no organization, and a column-drop table still carrying + * `organization_id`. That is HotCRM's measured shape: eleven declared rules, + * one of them seeded by the first v18 boot, before any ceremony ran. + * + * ## The cases + * + * 1. The served boot writes the declared rule as exactly the row the + * `declared-rule-mirrors` departure names (`managed_by 'package'`, + * uncustomized, no organization), and that is what + * `DECLARED_SHARING_RULE_SEEDER_RUNS` states. ⛔ When the seeder retires + * this case fails until the constant flips — in the same change. + * 2. Plan, then `--apply --json`, development boots like the boot that built + * the file: `blocking 0`, the eleven rows advisory and named, the + * `sys_migration` marker verified with `blocking 0`, every rule row kept. + * Then a served boot of the certified file with the v18 boot guard + * composed (`os serve`'s refusal) starts — with a table put back in the + * v17 shape, so the guard decides on the marker and not on columns — and + * `os migrate apply`'s drop gate reads the marker as verified. + * + * The same verdict without a boot — the restore once the seeder retires, and a + * host departure of the same id that still blocks — is + * `utils/organization-ownership-apply.declared-rule-mirrors.test.ts`. + */ + +import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest'; +import Database from 'better-sqlite3'; +import { mkdtempSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { ORGANIZATION_OWNERSHIP_MIGRATION_ID } from '@objectstack/spec/system'; +import { bootStack } from '@objectstack/verify'; +import { findV17ShapeTables, OrganizationOwnershipBootGuardPlugin } from '@objectstack/runtime'; +import { isExitSignal } from '../../utils/format.js'; +import { readOrganizationOwnershipMarker } from '../../utils/organization-ownership-drop-gate.js'; +import { DECLARED_SHARING_RULE_SEEDER_RUNS } from '../../utils/organization-ownership-inventory.js'; + +// Pay the first transform of the dist-resolved workspace deps the boots reach +// through dynamic `import()`s at MODULE LOAD, not inside a clocked case. +import '@objectstack/objectql'; +import '@objectstack/metadata-protocol'; + +type Command = (typeof import('./organization-ownership.js'))['default']; +let MigrateOrganizationOwnership: Command; + +const CLI_ROOT = fileURLToPath(new URL('../../..', import.meta.url)); +const CASE_TIMEOUT_MS = 120_000; +/** The rows a 17.x seeder left, beside the one this release's served boot writes. */ +const V17_MIRRORS = 10; +const MIRRORS = V17_MIRRORS + 1; + +/** Env that would point a boot somewhere other than the fixture, or change what it composes. */ +const OVERRIDING_ENV = [ + 'OS_DATABASE_URL', 'DATABASE_URL', 'TURSO_DATABASE_URL', 'TURSO_AUTH_TOKEN', 'OS_DATABASE_AUTH_TOKEN', + 'OS_DATABASE_DRIVER', 'OS_HOME', 'OS_ARTIFACT_PATH', 'OS_TELEMETRY_DB', 'OS_TENANCY_POSTURE', + 'OS_MULTI_ORG_ENABLED', 'OS_SKIP_SCHEMA_SYNC', 'NODE_ENV', +] as const; + +/** A package declaring one sharing rule; built anew per boot, as `bootStack` asks. */ +const stackConfig = () => ({ + manifest: { id: 'com.test.declared_rule_mirrors', name: 'Declared rule mirrors', type: 'app', version: '1.0.0' }, + objects: [ + { + name: 'crm_deal', + label: 'Deal', + fields: { name: { type: 'text', label: 'Name' }, status: { type: 'text', label: 'Status' } }, + }, + ], + sharingRules: [ + { + name: 'share_open_deals', + type: 'criteria', + object: 'crm_deal', + accessLevel: 'read', + condition: 'record.status == "open"', + sharedWith: { type: 'position', value: 'sales_rep' }, + }, + ], +}); + +let dir: string; +let file: string; +/** What the served boot left in `sys_sharing_rule`, read before the 17.x leftovers join it. */ +let seeded: Record[]; +const savedEnv: Record = {}; +const savedCwd = process.cwd(); + +/** One statement against the file, through a connection of its own. */ +function sql(statement: string, ...params: unknown[]): Record[] { + const db = new Database(file); + try { + const prepared = db.prepare(statement); + return prepared.reader ? (prepared.all(...params) as Record[]) : (prepared.run(...params), []); + } finally { + db.close(); + } +} + +/** The real command, its stdout parsed, and the exit it asked for. */ +async function run(args: string[]): Promise<{ payload: any; exitCode: number }> { + const savedExit = process.exitCode; + const swallow = ((_chunk: unknown, ...rest: unknown[]) => { + const cb = rest.find((a) => typeof a === 'function') as (() => void) | undefined; + if (cb) cb(); + return true; + }) as typeof process.stdout.write; + const stdout = vi.spyOn(process.stdout, 'write').mockImplementation(swallow); + vi.spyOn(process.stderr, 'write').mockImplementation(swallow); + vi.spyOn(console, 'log').mockImplementation(() => {}); + vi.spyOn(console, 'warn').mockImplementation(() => {}); + vi.spyOn(console, 'error').mockImplementation(() => {}); + process.chdir(dir); + let exitCode: number | undefined; + try { + try { + await MigrateOrganizationOwnership.run(args, { root: CLI_ROOT }); + } catch (error) { + if (!isExitSignal(error)) throw error; + exitCode = (error as { oclif?: { exit?: number } }).oclif?.exit; + } + const payload = JSON.parse(stdout.mock.calls.map((c) => String(c[0])).join('').trim()); + return { payload, exitCode: exitCode ?? (process.exitCode as number | undefined) ?? 0 }; + } finally { + process.chdir(savedCwd); + process.exitCode = savedExit; + vi.restoreAllMocks(); + } +} + +describe('os migrate organization-ownership on a database whose packages declare sharing rules', () => { + beforeAll(async () => { + for (const key of OVERRIDING_ENV) { + savedEnv[key] = process.env[key]; + delete process.env[key]; + } + ({ default: MigrateOrganizationOwnership } = await import('./organization-ownership.js')); + dir = mkdtempSync(join(tmpdir(), 'os-ceremony-declared-rules-')); + file = join(dir, 'fixture.db'); + + // The served boot of this release, over the file. + const stack = await bootStack(stackConfig(), { databaseFile: file }); + await stack.stop(); + seeded = sql('SELECT name, managed_by, customized, organization_id FROM sys_sharing_rule ORDER BY name'); + + // What a 17.x runtime left beside it. + const [{ id: organization }] = sql(`SELECT id FROM sys_organization WHERE slug = 'default'`) as Array<{ id: string }>; + const rule = (id: string, name: string, managedBy: string, customized: number, organizationId: string | null) => + sql( + 'INSERT INTO sys_sharing_rule (id, organization_id, name, label, object_name, criteria_json, recipient_type, ' + + `recipient_id, access_level, active, managed_by, customized) VALUES (?, ?, ?, ?, 'crm_deal', ?, 'position', ` + + `'sales_rep', 'read', 1, ?, ?)`, + id, organizationId, name, name, '{"status":"open"}', managedBy, customized, + ); + for (let i = 1; i <= V17_MIRRORS; i++) rule(`rule_v17_${i}`, `share_v17_${i}`, 'package', 0, null); + rule('rule_organization', 'organization_rule', 'admin', 0, organization!); + rule('rule_customized', 'customized_rule', 'package', 1, null); + sql('ALTER TABLE sys_job ADD COLUMN organization_id varchar(255)'); + sql(`INSERT INTO sys_job (id, name, organization_id) VALUES ('job_v17', 'nightly', NULL)`); + }, CASE_TIMEOUT_MS); + + afterAll(() => { + for (const key of OVERRIDING_ENV) { + if (savedEnv[key] === undefined) delete process.env[key]; + else process.env[key] = savedEnv[key]; + } + if (dir) rmSync(dir, { recursive: true, force: true }); + }); + + it('a served boot of this release writes the declared rule as the row the departure names, as DECLARED_SHARING_RULE_SEEDER_RUNS states', () => { + expect( + seeded, + `DECLARED_SHARING_RULE_SEEDER_RUNS (utils/organization-ownership-inventory.ts) is ${DECLARED_SHARING_RULE_SEEDER_RUNS}, and ` + + `a served boot of this release wrote ${seeded.length} declared sharing rule row(s): set the constant to what the ` + + 'boot does, in the change that changed the boot', + ).toEqual( + DECLARED_SHARING_RULE_SEEDER_RUNS + ? [{ name: 'share_open_deals', managed_by: 'package', customized: 0, organization_id: null }] + : [], + ); + }); + + it('--apply certifies: blocking 0, the eleven rows advisory, the marker verified; then the served boot with the v18 guard starts', async () => { + const planFile = join(dir, 'plan.json'); + const plan = await run(['--dev', '--database-url', `file:${file}`, '--json', '--out', planFile]); + expect(plan.payload.error, JSON.stringify(plan.payload).slice(0, 800)).toBeUndefined(); + expect(plan.exitCode).toBe(0); + // Said before the first write, and carried by the plan file. + expect(plan.payload.plan.warnings).toContainEqual(expect.stringMatching(new RegExp(`^sys_sharing_rule holds ${MIRRORS} organization-less row\\(s\\) of `))); + + const apply = await run([ + '--dev', '--apply', '--backup-taken', '--plan-file', planFile, '--database-url', `file:${file}`, '--json', + '--out', join(dir, 'apply.json'), + ]); + expect(apply.payload.error, JSON.stringify(apply.payload).slice(0, 800)).toBeUndefined(); + const { report } = apply.payload; + const unattributable = report.tables.reduce((n: number, t: { unattributable: unknown[] }) => n + t.unattributable.length, 0); + expect([apply.exitCode, report.complete, report.blocking, report.advisory]).toEqual([0, true, 0, unattributable + MIRRORS]); + expect(report.tables.find((t: { object: string }) => t.object === 'sys_sharing_rule')).toMatchObject({ + remaining: 0, + departures: [{ id: 'declared-rule-mirrors', as: 'mirror-deletion', rows: MIRRORS, advisory: expect.stringMatching(/own boot still writes them/) }], + notNull: { willReceive: false, reason: expect.stringMatching(new RegExp(`^${MIRRORS} row\\(s\\) stay NULL on this release, reported as advisory: `)) }, + }); + expect( + sql('SELECT verified_at IS NOT NULL AS verified, applied_at IS NOT NULL AS applied, blocking, advisory FROM sys_migration WHERE id = ?', ORGANIZATION_OWNERSHIP_MIGRATION_ID), + ).toEqual([{ verified: 1, applied: 1, blocking: 0, advisory: unattributable + MIRRORS }]); + // Nothing of the table was deleted; the eleven kept their NULL. + expect(sql('SELECT COUNT(*) AS n, SUM(organization_id IS NULL) AS nulls FROM sys_sharing_rule')).toEqual([{ n: MIRRORS + 2, nulls: MIRRORS }]); + + // A table back in the v17 shape, so the guard decides on the marker. + sql('ALTER TABLE sys_job ADD COLUMN organization_id varchar(255)'); + const served = await bootStack(stackConfig(), { + databaseFile: file, + extraPlugins: [new OrganizationOwnershipBootGuardPlugin({ dev: true })], + }); + try { + const engine = served.kernel.getService('objectql') as Parameters[0] & + Parameters[0]; + expect(await findV17ShapeTables(engine)).toEqual(['sys_job']); + expect((await readOrganizationOwnershipMarker(engine)).verified).toBe(true); + } finally { + await served.stop(); + } + }, CASE_TIMEOUT_MS); +}); diff --git a/packages/cli/src/commands/migrate/organization-ownership.remote-face.integration.test.ts b/packages/cli/src/commands/migrate/organization-ownership.remote-face.integration.test.ts index a38ebc28a9a..1c1a568acbb 100644 --- a/packages/cli/src/commands/migrate/organization-ownership.remote-face.integration.test.ts +++ b/packages/cli/src/commands/migrate/organization-ownership.remote-face.integration.test.ts @@ -27,7 +27,9 @@ * - the plan exits 0 and leaves the database byte-identical (a logical dump of * every catalog row and every table's rows, before and after), and names the * remote database, not the placeholder Knex client; - * - the apply completes with `blocking 0`; + * - the apply completes with `blocking 0`, the declared sharing-rule mirrors a + * 17.x seeder left reported as advisory (this release's boot still seeds + * them: `DECLARED_SHARING_RULE_SEEDER_RUNS`), not as discrepancies; * - each column and the index keyed on it are gone from the server, the rows * are not, and the `adr-0131-organization-ownership` marker is verified. * @@ -77,6 +79,8 @@ const OVERRIDING_ENV = [ /** `column-drop` tables the 17.x leftovers are put on, when the composition has them. */ const LEFTOVER_CANDIDATES = ['sys_job', 'sys_metadata_activation', 'sys_presence'] as const; +/** Declared sharing rules a 17.x seeder wrote organization-less (HotCRM declares eleven). */ +const RULE_MIRRORS = 11; let dir: string; let projectDir: string; @@ -204,6 +208,15 @@ describe.skipIf(!SQLD)('os migrate organization-ownership on a Turso REMOTE data if (leftovers.includes('sys_job')) { await exec(`INSERT INTO "sys_job" ("id", "organization_id") VALUES ('job_v17_a', NULL), ('job_v17_b', NULL)`); } + if (tables.has('sys_sharing_rule')) { + for (let i = 1; i <= RULE_MIRRORS; i++) { + await exec( + `INSERT INTO "sys_sharing_rule" ("id", "name", "object_name", "managed_by", "customized", "organization_id") ` + + `VALUES (?, ?, 'crm_deal', 'package', 0, NULL)`, + [`rule_v17_${i}`, `share_v17_${i}`], + ); + } + } }); expect(leftovers.length).toBeGreaterThan(0); }, CASE_TIMEOUT_MS); @@ -233,6 +246,10 @@ describe.skipIf(!SQLD)('os migrate organization-ownership on a Turso REMOTE data ]); expect(apply.payload.error, JSON.stringify(apply.payload).slice(0, 800)).toBeUndefined(); expect([apply.exitCode, apply.payload.report.complete, apply.payload.report.blocking]).toEqual([0, true, 0]); + expect(apply.payload.report.tables.find((t: { object: string }) => t.object === 'sys_sharing_rule')).toMatchObject({ + remaining: 0, + departures: [{ id: 'declared-rule-mirrors', rows: RULE_MIRRORS, advisory: expect.any(String) }], + }); for (const t of leftovers) expect(await hasOrganizationColumn(t)).toBe(false); const after = await secondConnection(async (exec) => ({ @@ -244,7 +261,8 @@ describe.skipIf(!SQLD)('os migrate organization-ownership on a Turso REMOTE data [ORGANIZATION_OWNERSHIP_MIGRATION_ID], )).map((r) => ({ applied: Number(r.applied), verified: Number(r.verified) })), jobs: leftovers.includes('sys_job') ? Number((await exec('SELECT count(*) AS n FROM "sys_job"'))[0]!.n) : 2, + rules: Number((await exec('SELECT count(*) AS n FROM "sys_sharing_rule" WHERE "organization_id" IS NULL'))[0]!.n), })); - expect(after).toEqual({ indexes: [], marker: [{ applied: 1, verified: 1 }], jobs: 2 }); + expect(after).toEqual({ indexes: [], marker: [{ applied: 1, verified: 1 }], jobs: 2, rules: RULE_MIRRORS }); }, CASE_TIMEOUT_MS); }); From 5c0aa5c752381d7bd12ad13134b4760c6d3a9408 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 16 Oct 2026 14:57:51 +0000 Subject: [PATCH 4/5] chore(changeset): the declared sharing-rule mirrors' advisory verdict (cli minor) Claude-Session: https://claude.ai/code/session_01PFB9ZGRWYvjhTXsd8i6KBp Co-authored-by: Claude --- .../23948-declared-rule-mirrors-advisory.md | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) create mode 100644 .changeset/23948-declared-rule-mirrors-advisory.md diff --git a/.changeset/23948-declared-rule-mirrors-advisory.md b/.changeset/23948-declared-rule-mirrors-advisory.md new file mode 100644 index 00000000000..7066437e5b4 --- /dev/null +++ b/.changeset/23948-declared-rule-mirrors-advisory.md @@ -0,0 +1,19 @@ +--- +"@objectstack/cli": minor +--- + +feat(cli): `os migrate organization-ownership --apply` certifies a database whose packages declare sharing rules — the declared sharing-rule mirrors this release's boot still seeds are reported as advisory, not as discrepancies + +Clause-②: yes (widening — the apply report file, the plan file and both `--json` payloads gain an `advisory` field on a departure entry, and the run's `advisory` count includes that departure's rows) + +- **What an operator saw.** On a v17 database whose packages declare sharing rules (HotCRM declares eleven), `--apply` ended with `complete: true` and `blocking: 11`, and never certified: the `adr-0131-organization-ownership` row of `sys_migration` kept `verified_at` null. The post-check waited for the `declared-rule-mirrors` departure on `sys_sharing_rule` to leave. That departure is the uncustomized `managed_by` `package` / `platform` rows with no organization. This release's own served boot writes those rows on every start, including the first v18 boot that the organization-ownership refusal stops, so they could never leave on this release. +- **What it sees now.** `--apply` reports `blocking: 0` and certifies: `verified_at` is set, with `blocking` 0 in the marker every reader checks. The mirror rows are named, counted and kept: + - the plan and the apply print a warning before the first write: `sys_sharing_rule holds N organization-less row(s) of … which stay: …`. The plan file's and the report's `warnings` carry it too; + - in the plan file and in the apply report (`--json` and the `--out` file), the `declared-rule-mirrors` entry of `tables[].departures[]` keeps `id`, `label`, `as` and `rows`, and gains `advisory`: why the rows stay, and what removes them. A departure without `advisory` still blocks; + - `tables[].remaining` no longer counts these rows. The run's `advisory` and the ledger's `advisory` count them, beside the unattributable rows; + - the rows keep `organization_id` NULL, so `sys_sharing_rule`'s `notNull` reads `willReceive: false` with `N row(s) stay NULL on this release, reported as advisory: …`. The plan file used to read `willReceive: true` for that table; + - the human summary prints `N row(s) advisory, not held against certification — …` under the table. Its closing warnings name the unattributable rows and the advisory rows separately; + - nothing is deleted. Every rule row stays as it was. +- **What still blocks.** Every other departure: `global-rung-move`, `template-promotion`, and a host fate supplement's own departures, even one named `declared-rule-mirrors`. Every other remaining row blocks too. +- **Already ran `--apply` and stopped at `blocking: 11`?** Run `--apply` again on this release, with the same reviewed plan file: the inventory digest is unchanged. Every step is checkpointed, so only the post-check runs, and it certifies. +- **When blocking returns.** The release that retires the declared sharing-rule seeder (ADR-0131 D2) sets `DECLARED_SHARING_RULE_SEEDER_RUNS` to `false` in the same change. From that release on, these rows hold certification again until its ceremony deletes them. From 98f87644e16e6bd5894cfa3ae956ac640dc5eee9 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 16 Oct 2026 16:13:39 +0000 Subject: [PATCH 5/5] chore(changeset): an environment that already stopped at blocking 11 plans again; resume or restart both certify Claude-Session: https://claude.ai/code/session_01PFB9ZGRWYvjhTXsd8i6KBp Co-authored-by: Claude --- .changeset/23948-declared-rule-mirrors-advisory.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.changeset/23948-declared-rule-mirrors-advisory.md b/.changeset/23948-declared-rule-mirrors-advisory.md index 7066437e5b4..068d58b1c14 100644 --- a/.changeset/23948-declared-rule-mirrors-advisory.md +++ b/.changeset/23948-declared-rule-mirrors-advisory.md @@ -15,5 +15,5 @@ Clause-②: yes (widening — the apply report file, the plan file and both `--j - the human summary prints `N row(s) advisory, not held against certification — …` under the table. Its closing warnings name the unattributable rows and the advisory rows separately; - nothing is deleted. Every rule row stays as it was. - **What still blocks.** Every other departure: `global-rung-move`, `template-promotion`, and a host fate supplement's own departures, even one named `declared-rule-mirrors`. Every other remaining row blocks too. -- **Already ran `--apply` and stopped at `blocking: 11`?** Run `--apply` again on this release, with the same reviewed plan file: the inventory digest is unchanged. Every step is checkpointed, so only the post-check runs, and it certifies. +- **Already ran `--apply` and stopped at `blocking: 11`?** Plan again on this release, review the plan, and run `--apply` with it. This change leaves the inventory digest as it was, so a checkpoint recorded under this release's inventory resumes: only the post-check runs, and it certifies. A checkpoint recorded by a release whose inventory differed is superseded: every step runs again, each one idempotent, and the post-check certifies. - **When blocking returns.** The release that retires the declared sharing-rule seeder (ADR-0131 D2) sets `DECLARED_SHARING_RULE_SEEDER_RUNS` to `false` in the same change. From that release on, these rows hold certification again until its ceremony deletes them.