diff --git a/.changeset/15212-8c0-unattributable-rows-block.md b/.changeset/15212-8c0-unattributable-rows-block.md new file mode 100644 index 00000000000..9b08ebbe542 --- /dev/null +++ b/.changeset/15212-8c0-unattributable-rows-block.md @@ -0,0 +1,28 @@ +--- +'@objectstack/cli': minor +'@objectstack/plugin-sharing': minor +--- + +feat(cli,plugin-sharing)!: in the 17 → 18 ownership ceremony, a row whose owner cannot be derived blocks certification, and a declared-rule mirror's grants are renamed only onto a rule plugin-sharing serves + +Clause-②: yes (narrowing) + + + +**BREAKING** (narrowings), shipped as `minor` under the launch-window convention for breaking changes. + +- **FROM** (`@objectstack/cli`): `os migrate organization-ownership --apply` on a database holding a row whose owner cannot be derived exited `0` with `blocking: 0`, counted the row in `advisory`, and verified the `sys_migration` marker. This covers a reported (fate 4) row, and a row whose anchor is missing under a wall (fate 3). **TO:** it exits `1`, counts the row in `blocking`, and leaves the marker unverified, so a v18 `os serve` / `os start` does not start on the database. **Fix:** give each row its organization (write its `organization_id`, or repair the anchor its reason names), or delete it by hand, then re-run `--apply` with the same plan file. +- **FROM** (`@objectstack/cli`): a database an earlier version of this command certified while it held such a row was certified again by the next `--apply`. **TO:** that run leaves its marker unverified, for the same rows. **Fix:** the same as above. +- **FROM** (`@objectstack/cli`): `os dev`'s in-place upgrade of a development database started the server again whenever every ceremony step finished. **TO:** it starts it only when the post-check also holds nothing (`blocking` 0); otherwise it prints each table that holds, with its count and remedy, and the command that resumes the run. **Fix:** resolve the rows it names, then run the printed command. +- **FROM** (`@objectstack/cli`): the `mirror-deletion` step renamed the grants of a declared-rule mirror onto its rule's name, and deleted the mirror, even when plugin-sharing does not serve that rule (a condition that does not translate, an unmappable recipient, a retired owner-based rule); the next boot's orphaned-grant sweep then revoked those grants. **TO:** while `sys_record_share` grants name such a mirror, the step blocks, naming the rule, its rows and the reason, and writes nothing. **Fix:** correct the rule's declaration in its package so it is served, or delete each mirror row and the grants that name it, then re-run `--apply`. + +`@objectstack/cli` — **unattributable rows hold certification (ADR-0131 D10 fate 4).** The ceremony still never guesses an owner for such a row and never deletes it. The post-check now holds each one against the verdict: + +- Each table that holds one carries `held: { rows, remedy }` in the apply report, and counts those rows in its `remaining`. The terminal output names each table, its row count and the remedy. +- `advisory` now counts only the rows of an advisory departure and the reported rows of a table whose `organization_id` is not the tenancy anchor (`sys_sso_provider`, the retired `sys_scim_provider`). A row there that is bound to no organization is legitimate, is reported, and does not block. +- So a verified marker means no organization-less row on any table that the tenancy anchor governs. +- The plan's summary line says that such rows will hold `--apply`'s certification until they are resolved. + +`@objectstack/cli` — **the ceremony asks plugin-sharing which declared sharing rules it serves.** The port the `mirror-deletion` step reads (`DeclaredSharingRulesPort.rules()`, formerly `names()`) judges each declared rule through plugin-sharing's own translation. A mirror's grants are renamed only onto a rule that is served. A mirror of a refused rule that no grant names is still deleted. + +`@objectstack/plugin-sharing` — **exports `translateDeclaredSharingRule`** with its `DeclaredSharingRule` and `DeclaredSharingRuleSkip` types. It is the one translation enforcement serves a declared rule through. It is pure, and it is unchanged. diff --git a/content/docs/deployment/cli.mdx b/content/docs/deployment/cli.mdx index a3a720e076b..dc780b3bcd6 100644 --- a/content/docs/deployment/cli.mdx +++ b/content/docs/deployment/cli.mdx @@ -210,8 +210,9 @@ SQLite file: beside the copy. 3. It prints the ceremony's warnings and starts the server again. -If a step blocks, `os dev` stops and prints the blocker, where the copy is, and -the command that resumes the run. It tries once per session. A database that is +If a step blocks, or the post-check still holds rows (a row whose owner cannot +be derived, named per table with its remedy), `os dev` stops and prints what +holds it, where the copy is, and the command that resumes the run. It tries once per session. A database that is not a local SQLite file (PostgreSQL, a remote libSQL, `:memory:`) is not upgraded in place: run the ceremony yourself. `os serve` and `os start` never run it; they refuse and name the command. @@ -1487,6 +1488,18 @@ os migrate organization-ownership --dev … # boot as `os ser - **`--apply` runs only the plan you reviewed**, and only after you confirm a backup (`--backup-taken`, or the prompt). The platform takes no backup. The run is resumable: re-running it continues from its recorded checkpoint. +- **A row whose owner cannot be derived blocks certification.** That covers a + reported row, and a row whose anchor is missing under a wall (for example an + upload session whose file is gone). The ceremony never guesses an owner + for such a row and never deletes it. The post-check names each table, its + row count and the remedy: give each row its organization (write its + `organization_id`, or repair the anchor its reason names), or delete it by + hand, then re-run `--apply`. Until then the run exits `1`, the marker stays + unverified, and a v18 server does not start on the database. A verified + marker therefore means no organization-less row on any table. The one + exception is a table whose `organization_id` is not the tenancy anchor + (`sys_sso_provider`, and the retired `sys_scim_provider`): a row there + bound to no organization is legitimate, is reported, and does not block. - **Warnings come before the first write.** The plan prints what the ceremony decides on your behalf, and `--apply` prints the same warnings again before it writes. An example: the shipped permission sets an install created before 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 index 0349eb14f24..e5d14c95370 100644 --- 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 @@ -241,6 +241,8 @@ describe('os migrate organization-ownership on a database whose packages declare const unattributable = report.tables.reduce((n: number, t: { unattributable: unknown[] }) => n + t.unattributable.length, 0); const blockers = report.steps.flatMap((s: { blockers: string[] }) => s.blockers); expect([apply.exitCode, report.complete, report.blocking, report.advisory], blockers.join('\n')).toEqual([0, true, 0, unattributable]); + // Ruling C: a certified database holds no unattributable row a table holds against it. + expect(report.tables.filter((t: { held?: unknown }) => t.held)).toEqual([]); expect(report.tables.find((t: { object: string }) => t.object === 'sys_sharing_rule')).toMatchObject({ remaining: 0, deleted: MIRRORS, diff --git a/packages/cli/src/commands/migrate/organization-ownership.ts b/packages/cli/src/commands/migrate/organization-ownership.ts index 8281af74873..77572edb789 100644 --- a/packages/cli/src/commands/migrate/organization-ownership.ts +++ b/packages/cli/src/commands/migrate/organization-ownership.ts @@ -43,6 +43,8 @@ import { checkpointFromDetails, MetadataChoiceRefusal, ORGANIZATION_OWNERSHIP_MIGRATION_ID, + judgeDeclaredSharingRules, + UNATTRIBUTABLE_REMEDY, ReviewedPlanRefusal, reviewedPlanFromFile, TemplateChoiceRefusal, @@ -262,7 +264,10 @@ class ReportFileReservation { * the ceremony's marker: `applied_at` once every step has finished, * `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 + * keeps seeding — is reported as advisory, not as a discrepancy; a row whose + * owner cannot be derived is one, named per table with its count and the + * remedy — assign it an organization or delete it by hand — so a database that + * keeps one does not certify and the v18 boot does not serve it). 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 @@ -617,7 +622,12 @@ export default class MigrateOrganizationOwnership extends Command { `${summary.notNull.willReceive.length} will receive NOT NULL, ${summary.notNull.willNotReceive.length} will not.`, ); if (summary.unattributableRows > 0) { - printWarning(`${summary.unattributableRows} row(s) whose owner cannot be derived are listed by id in the plan file.`); + printWarning( + `${summary.unattributableRows} row(s) whose owner cannot be derived are listed by id, with why, in the plan ` + + 'file. The ceremony neither guesses their owner nor deletes them, and --apply does not certify the database ' + + 'while one stays NULL (but on a table whose column is not the tenancy anchor): assign each its ' + + 'organization, or delete it by hand, before or after --apply, and re-run it.', + ); } // The occupancy probe answered `not_applicable` for a target that is no // SQLite file (PostgreSQL, MySQL, `:memory:`): no file header to switch. @@ -801,12 +811,10 @@ export default class MigrateOrganizationOwnership extends Command { catalog, declaredSharingRules: { // [ADR-0131 C3 stage 9] What plugin-sharing's enforcement reads: the - // registry's declared rules, else the metadata service's. - names: async () => { - const nameOf = (item: unknown): string | undefined => { - const name = (item as { name?: unknown } | null)?.name; - return typeof name === 'string' && name !== '' ? name : undefined; - }; + // registry's declared rules, else the metadata service's — each + // judged by the plugin's own translation, so a name it refuses to + // serve is never one a mirror's grants are renamed onto. + rules: async () => { const registry = engine.registry as { listItems?(type: string): unknown[] | undefined } | undefined; let items: unknown[] = registry?.listItems?.('sharing_rule') ?? []; if (items.length === 0) { @@ -814,7 +822,7 @@ export default class MigrateOrganizationOwnership extends Command { const listed = await metadata?.list?.('sharing_rule'); items = Array.isArray(listed) ? listed : []; } - return items.map(nameOf).filter((name): name is string => name !== undefined); + return judgeDeclaredSharingRules(items); }, }, door: { @@ -1020,6 +1028,12 @@ export default class MigrateOrganizationOwnership extends Command { `${table.unattributable.length ? chalk.yellow(`reported ${table.unattributable.length} `) : ''}` + `${table.remaining ? chalk.red(`remaining ${table.remaining}`) : ''}`, ); + if (table.held) { + console.log(chalk.red( + ` ${table.held.rows} row(s) whose owner cannot be derived hold certification (listed by id, with why, in ` + + `the report file) — ${table.held.remedy}`, + )); + } for (const departure of table.departures) { console.log(chalk.yellow( departure.advisory @@ -1036,11 +1050,24 @@ export default class MigrateOrganizationOwnership extends Command { } } console.log(''); - // `advisory` counts two populations; each is said in its own terms. - const unattributable = report.tables.reduce((n, t) => n + t.unattributable.length, 0); + // Unattributable rows hold certification (ruling C on ADR-0131 D10 fate + // 4) but on a table whose column is not the tenancy anchor; `advisory` + // counts those and the advisory departures, each said in its own terms. + const unattributableHeld = report.tables.reduce((n, t) => n + (t.held?.rows ?? 0), 0); + const unattributableReported = report.tables.reduce((n, t) => n + t.unattributable.length, 0) - unattributableHeld; 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 (unattributableHeld > 0) { + printError( + `${unattributableHeld} row(s) whose owner cannot be derived stay NULL and hold certification, on ` + + `${report.tables.filter((t) => t.held).map((t) => `${t.object} (${t.held!.rows})`).join(', ')}: ` + + `${UNATTRIBUTABLE_REMEDY}.`, + ); + } + if (unattributableReported > 0) { + printWarning( + `${unattributableReported} row(s) whose owner cannot be derived stay NULL on a table whose column is not the ` + + 'tenancy anchor; they are listed by id in the report file and do not hold certification.', + ); } if (held > 0) { printWarning( diff --git a/packages/cli/src/utils/dev-organization-ownership-upgrade.test.ts b/packages/cli/src/utils/dev-organization-ownership-upgrade.test.ts index 8f2dedeac2f..e1f18ce3d33 100644 --- a/packages/cli/src/utils/dev-organization-ownership-upgrade.test.ts +++ b/packages/cli/src/utils/dev-organization-ownership-upgrade.test.ts @@ -4,7 +4,8 @@ * `os dev`'s in-place upgrade of a v17-shape development database (ADR-0131 * D10, development exception): a copy of the database and its `telemetry` * sibling first, then the ceremony's plan and the apply of that plan, both - * with `--dev`; the server boots again only on the ceremony's own "complete". + * with `--dev`; the server boots again only on the ceremony's own verdict: + * "complete" with nothing left (`blocking` 0), the marker the boot guard reads. * The CLI runs are faked here — what they write is the ceremony's contract * (`--out` files), and the real runs are the command's own suites. */ @@ -68,7 +69,7 @@ afterEach(() => { describe('os dev — the in-place upgrade of a v17-shape development database', () => { it('⭐ copies the database and its telemetry sibling, plans, applies that plan, and boots on "complete"', () => { - const result = upgradeDevelopmentDatabase(input(fakeRun({ report: { complete: true, warnings: ['8 shipped permission set row(s) …'], steps: [] } }))); + const result = upgradeDevelopmentDatabase(input(fakeRun({ report: { complete: true, blocking: 0, warnings: ['8 shipped permission set row(s) …'], steps: [] } }))); expect(result).toMatchObject({ ok: true, backupDir: BACKUP(), warnings: ['8 shipped permission set row(s) …'] }); expect(readdirSync(BACKUP()).sort()).toEqual(['apply.json', 'ceremony.log', 'dev.db', 'dev.db-wal', 'dev.telemetry.db', 'plan.json']); expect(readFileSync(join(BACKUP(), 'dev.db-wal'), 'utf8')).toBe('primary-wal'); @@ -96,6 +97,27 @@ describe('os dev — the in-place upgrade of a v17-shape development database', expect(result.lines.at(-1)).toContain(`--apply --plan-file ${join(BACKUP(), 'plan.json')} --backup-taken`); }); + it('every step finished but the post-check holds an unattributable row: boots nothing, names the table, count and remedy', () => { + const remedy = 'assign each row its organization …, or delete it by hand, then re-run --apply'; + const result = upgradeDevelopmentDatabase(input(fakeRun({ + report: { + complete: true, + blocking: 2, + warnings: [], + steps: [{ step: 'attribution', status: 'done', blockers: [] }], + tables: [ + { object: 'sys_saved_report', remaining: 2, held: { rows: 2, remedy } }, + { object: 'sys_job', remaining: 0 }, + ], + }, + }))); + expect(result.ok).toBe(false); + expect(result.lines[0]).toBe(`sys_saved_report: 2 row(s) whose owner cannot be derived — ${remedy}`); + expect(result.lines).toHaveLength(2); + expect(result.lines.at(-1)).toContain('is not certified'); + expect(result.lines.at(-1)).toContain(`--apply --plan-file ${join(BACKUP(), 'plan.json')} --backup-taken`); + }); + it('a database that is not a local SQLite file is not upgraded in place: the ceremony is named instead', () => { const result = upgradeDevelopmentDatabase(input(fakeRun({}), { sqliteFile: null, databaseUrl: 'postgres://db/app' })); expect(result.ok).toBe(false); diff --git a/packages/cli/src/utils/dev-organization-ownership-upgrade.ts b/packages/cli/src/utils/dev-organization-ownership-upgrade.ts index 1956a11fd78..a6f71cde019 100644 --- a/packages/cli/src/utils/dev-organization-ownership-upgrade.ts +++ b/packages/cli/src/utils/dev-organization-ownership-upgrade.ts @@ -33,8 +33,11 @@ import { resolveTelemetryDbPath } from './telemetry-datasource.js'; * libSQL, `:memory:`) is not copied by a file copy, so it is not upgraded * here: the operator runs the ceremony, as in production. * - **The ceremony's own verdict.** The server boots again only when every - * step finished (`report.complete`). A blocked step is printed, with where - * the copy and the report are; nothing is guessed around it. + * step finished and the post-check found nothing left (`report.complete` + * and `report.blocking === 0` — the marker the boot guard reads). A blocked + * step is printed, and so is each table the post-check holds (a row whose + * owner cannot be derived among them, with its remedy), with where the copy + * and the report are; nothing is guessed around it. * - **Once per session.** A boot refused again after a finished upgrade ends * `os dev`, naming the refusal; it never loops. * - **The warnings.** What the ceremony decided on the developer's behalf @@ -160,10 +163,32 @@ export function upgradeDevelopmentDatabase(input: DevelopmentUpgradeInput): Deve } const report = JSON.parse(readFileSync(applyFile, 'utf8')) as { complete?: boolean; + blocking?: number; warnings?: string[]; steps?: Array<{ step: string; status: string; blockers?: string[] }>; + tables?: Array<{ object: string; remaining?: number; held?: { rows: number; remedy: string } }>; }; const warnings = report.warnings ?? []; + const again = `\`os migrate organization-ownership --dev --apply --plan-file ${planFile} --backup-taken\``; + if (report.complete && report.blocking !== 0) { + // Every step finished and the post-check still holds rows: the marker is + // not verified, so the guard would refuse the boot again. + const holds = (report.tables ?? []).filter((t) => (t.remaining ?? 0) > 0); + return { + ok: false, + backupDir, + warnings, + lines: [ + ...holds.map((t) => + t.held + ? `${t.object}: ${t.held.rows} row(s) whose owner cannot be derived — ${t.held.remedy}` + : `${t.object}: ${t.remaining} row(s) remaining (the report names each)`, + ), + `Every step finished, and the post-check found ${report.blocking ?? 'some'} row(s) left (above), so the ` + + `database is not certified. ${kept} Resolve them, then run ${again} — or restore the copy.`, + ], + }; + } if (report.complete) { return { ok: true, @@ -180,7 +205,7 @@ export function upgradeDevelopmentDatabase(input: DevelopmentUpgradeInput): Deve lines: [ ...blocked.flatMap((s) => (s.blockers ?? []).map((b) => `${s.step}: ${b}`)), `The upgrade stopped at a blocker (above); every finished step is checkpointed. ${kept} Resolve it, then run ` + - `\`os migrate organization-ownership --dev --apply --plan-file ${planFile} --backup-taken\` — or restore the copy.`, + `${again} — or restore the copy.`, ], }; } 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 index 4818f02f1ed..b2f5abbf81e 100644 --- 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 @@ -20,9 +20,11 @@ * - A database whose ceremony completed while a seeding release kept the * mirrors (advisory then) has them deleted by a re-run: the step runs * again on a checkpointed run while a mirror stands. - * - A mirror whose rule no package declares any more blocks with nothing + * - A mirror whose rule no package declares any more, or whose declaration + * plugin-sharing refuses to serve (the reason named), blocks with nothing * written while a grant still names it (one no grant names goes); so does a - * run with no registry to ask. + * run with no registry to ask. `judgeDeclaredSharingRules` asks the + * plugin's own translation which declared names are served. * - While a release's boot still seeds them (`advisoryDepartureReason`), they * stay advisory and nothing deletes them. * - The rule is keyed on the table AND the departure id: a host fate @@ -42,6 +44,7 @@ import { buildOrganizationOwnershipPlan, inventoryDigest } from './organization- import { applyOrganizationOwnership, CEREMONY_STEPS, + judgeDeclaredSharingRules, reviewedConflictsOf, type ApplyExecutor, type CeremonyCheckpoint, @@ -51,6 +54,8 @@ import { type TableOutcome, } from './organization-ownership-apply.js'; import { mergeHostFateSupplement, type InventoryEntry } from './organization-ownership-inventory.js'; +// Loaded at module top: the plugin's first load is paid here, not inside a clocked test. +import { translateDeclaredSharingRule } from '@objectstack/plugin-sharing'; /** * The seeding-release case answers as a release whose boot still seeds them @@ -121,11 +126,16 @@ function ledger(): CeremonyLedgerPort & { verdicts: Verdict[] } { /** Every rule the fixture's packages declare: the eleven mirrors' names and the customized one's. */ const DECLARED = [...Array.from({ length: MIRRORS }, (_, i) => `share_declared_${i + 1}`), 'share_custom']; -/** Plan, then apply that reviewed plan, under `single`, as the command does. */ +/** + * Plan, then apply that reviewed plan, under `single`, as the command does. + * `declared` are the rules plugin-sharing serves; `refused` are declared ones + * its translation refuses, each with the reason. + */ async function apply( l: ReturnType, inventory?: readonly InventoryEntry[], declared: readonly string[] | null = DECLARED, + refused: ReadonlyArray<{ name: string; reason: string }> = [], ): Promise { const database = join(dir, 'fixture.db'); const reviewed = await buildOrganizationOwnershipPlan({ @@ -148,7 +158,15 @@ async function apply( conflicts: reviewedConflictsOf(reviewed), }, catalog: { async resolve() { return undefined; } }, - ...(declared ? { declaredSharingRules: { async names() { return declared; } } } : {}), + ...(declared + ? { + declaredSharingRules: { + async rules() { + return [...declared.map((name) => ({ name, refused: null })), ...refused.map((r) => ({ name: r.name, refused: r.reason }))]; + }, + }, + } + : {}), door: { async save(type, name) { throw new Error(`no environment save is expected here (${type} ${name})`); @@ -296,6 +314,29 @@ describe('the declared sharing-rule mirrors, once their seeder has retired', () expect(writes.filter((w) => /^(UPDATE "sys_record_share"|DELETE FROM "sys_sharing_rule")/i.test(w))).toEqual([]); }); + it('a mirror whose rule its package declares but plugin-sharing refuses to serve, and that a grant names, blocks with the reason; nothing is renamed or deleted', async () => { + const reason = 'its condition does not translate (unsupported-function: matches() is not supported)'; + const l = ledger(); + const report = await apply(l, undefined, DECLARED.filter((n) => n !== 'share_declared_5'), [{ name: 'share_declared_5', reason }]); + expect(report.complete).toBe(false); + expect(l.verdicts.at(-1)).toMatchObject({ complete: false }); + const step = report.steps.find((s) => s.step === 'mirror-deletion')!; + expect(step.status).toBe('blocked'); + expect(step.blockers).toHaveLength(1); + expect(step.blockers[0]).toMatch(/^1 declared-rule mirror\(s\) on sys_sharing_rule name the sharing rule share_declared_5 \(rows rule_declared_05\), which its package declares but plugin-sharing does not serve: /); + expect(step.blockers[0]).toContain(reason); + expect(rows(`SELECT COUNT(*) AS n FROM sys_sharing_rule`)).toEqual([{ n: MIRRORS + 2 }]); + expect(writes.filter((w) => /^(UPDATE "sys_record_share"|DELETE FROM "sys_sharing_rule")/i.test(w))).toEqual([]); + expect(rows(`SELECT source_id FROM sys_record_share WHERE id = 'share_declared_grant_5'`)).toEqual([{ source_id: 'rule_declared_05' }]); + }); + + it('a refused declaration no grant names is a copy of nothing that grants nothing: its mirror goes', async () => { + db.exec(`DELETE FROM sys_record_share WHERE source_id = 'rule_declared_05'`); + const report = await apply(ledger(), undefined, DECLARED.filter((n) => n !== 'share_declared_5'), [{ name: 'share_declared_5', reason: 'refused' }]); + expect([report.complete, report.blocking]).toEqual([true, 0]); + expect(rows(`SELECT COUNT(*) AS n FROM sys_sharing_rule`)).toEqual([{ n: 2 }]); + }); + it('a mirror whose rule no package declares, and that no grant names, goes like every fate-2 mirror', async () => { db.exec(`DELETE FROM sys_record_share WHERE source_id = 'rule_declared_03'`); const report = await apply(ledger(), undefined, DECLARED.filter((n) => n !== 'share_declared_3')); @@ -376,3 +417,22 @@ describe('the declared sharing-rule mirrors, once their seeder has retired', () expect(tableOf(report, 'sys_sharing_rule').remaining).toBe(0); }); }); + +describe('judgeDeclaredSharingRules — which declared names plugin-sharing serves', () => { + it('asks the plugin\'s own translation: a served rule answers null, a refused one its reason; a nameless item is left out', async () => { + const base = { object: 'crm_deal', sharedWith: { type: 'user', value: 'alice' }, accessLevel: 'read' }; + const judged = await judgeDeclaredSharingRules([ + { ...base, name: 'served', condition: 'record.amount >= 100000' }, + { ...base, name: 'untranslatable', condition: 'size(record.tags) > 0' }, + { ...base, name: 'dotted', sharedWith: { type: 'field', value: 'owner.manager_id' }, condition: 'record.amount > 1' }, + { ...base, name: 'match_all', condition: 'true' }, + { ...base, condition: 'record.amount > 1' }, + ]); + expect(judged.map((r) => r.name)).toEqual(['served', 'untranslatable', 'dotted', 'match_all']); + expect(judged[0]!.refused).toBeNull(); + expect(translateDeclaredSharingRule({ ...base, name: 'served', condition: 'record.amount >= 100000' }).rule?.name).toBe('served'); + expect(judged[1]!.refused).toMatch(/^its condition does not translate \(/); + expect(judged[2]!.refused).toMatch(/^its recipient \(sharedWith type "field"\)/); + expect(judged[3]!.refused).toMatch(/match-all-criteria/); + }); +}); diff --git a/packages/cli/src/utils/organization-ownership-apply.test.ts b/packages/cli/src/utils/organization-ownership-apply.test.ts index 8aa6712ff63..82ee81fae79 100644 --- a/packages/cli/src/utils/organization-ownership-apply.test.ts +++ b/packages/cli/src/utils/organization-ownership-apply.test.ts @@ -5,7 +5,8 @@ * fate comes through `--apply` with effective access IDENTICAL (and a positive * control: a grant the rewrite would lose reddens the comparison), mirrors * gone, attributed rows carrying their organization, the unattributable row - * still present and reported, and row counts reconciling. Plus the ceremony's + * still present, reported, and holding certification (ruling C on the C8 + * decision) until the operator resolves it by hand, and row counts reconciling. Plus the ceremony's * guarantees: idempotent, resumable from the recorded checkpoint, gated * (nothing deleted while a reference does not resolve by name), and no * deletion outside fate 2. @@ -32,6 +33,7 @@ import { reviewedConflictsOf, reviewedPlanFromFile, TemplateChoiceRefusal, + UNATTRIBUTABLE_REMEDY, type ApplyExecutor, type CatalogPort, type CeremonyCheckpoint, @@ -452,7 +454,9 @@ const apply = ( ...(opts.metadataChoices ? { metadataChoices: opts.metadataChoices } : {}), ...(opts.securityPlugin ? { securityPlugin: opts.securityPlugin } : {}), ...(opts.defaultOrganization ? { defaultOrganization: opts.defaultOrganization } : {}), - ...(opts.declaredSharingRules ? { declaredSharingRules: { names: async () => opts.declaredSharingRules! } } : {}), + ...(opts.declaredSharingRules + ? { declaredSharingRules: { rules: async () => opts.declaredSharingRules!.map((name) => ({ name, refused: null })) } } + : {}), posture: opts.posture ?? 'single', database: file, catalog: c, @@ -512,7 +516,7 @@ afterEach(() => { }); describe('ADR-0131 D10 --apply — the acceptance fixture', () => { - it('comes through with effective access identical, mirrors gone, owners written, the orphan reported, counts reconciling', async () => { + it('comes through with effective access identical, mirrors gone, owners written, the orphan reported and holding certification, counts reconciling', async () => { const before = accessBefore(); const countsBefore = counts(); const c = catalog(); @@ -532,8 +536,10 @@ describe('ADR-0131 D10 --apply — the acceptance fixture', () => { ['column-drops', 'done'], ]); expect(report.complete).toBe(true); - expect(report.blocking).toBe(0); - expect(l.verdicts.at(-1)).toEqual({ complete: true, blocking: 0, advisory: 1 }); + // Ruling C: the fate-4 orphan is the one discrepancy — the marker is not verified. + expect(report.blocking).toBe(1); + expect(l.verdicts.at(-1)).toEqual({ complete: true, blocking: 1, advisory: 0 }); + expect(report.tables.filter((t) => t.remaining > 0).map((t) => [t.object, t.remaining])).toEqual([['sys_saved_report', 1]]); // Effective access: identical, read the 17.x way before and the v18 way after. expect(before.length).toBeGreaterThan(0); @@ -582,9 +588,11 @@ describe('ADR-0131 D10 --apply — the acceptance fixture', () => { expect(rows(`SELECT organization_id FROM crm_deal WHERE id = 'deal_2'`)).toEqual([{ organization_id: 'org_default' }]); expect(rows(`SELECT organization_id FROM sys_user_permission_set WHERE id = 'grant_2'`)).toEqual([{ organization_id: 'org_default' }]); - // The unattributable row is still present, still NULL, and reported by id. + // The unattributable row is still present, still NULL — never guessed, + // never deleted — reported by id, and held with its count and remedy. expect(rows(`SELECT organization_id FROM sys_saved_report`)).toEqual([{ organization_id: null }]); expect(tableOf(report, 'sys_saved_report').unattributable.map((u) => u.id)).toEqual(['report_orphan']); + expect(tableOf(report, 'sys_saved_report').held).toEqual({ rows: 1, remedy: UNATTRIBUTABLE_REMEDY }); // Fate 1: the column is gone through the column-drop port. expect(tableOf(report, 'sys_job').columnDropped).toBe(true); @@ -606,6 +614,31 @@ describe('ADR-0131 D10 --apply — the acceptance fixture', () => { /sys_permission_set|sys_position|sys_position_permission_set|sys_capability|sys_email_template|"sys_setting" WHERE \(\("scope" = \?\) AND "organization_id" IS NULL\)/.test(w))).toBe(true); }); + it('ruling C: the operator resolves the orphan by hand — deleted, or given its organization — and the re-run certifies', async () => { + for (const remedy of [`DELETE FROM sys_saved_report WHERE id = 'report_orphan'`, `UPDATE sys_saved_report SET organization_id = 'org_b'`]) { + db.close(); + rmSync(dir, { recursive: true, force: true }); + dir = mkdtempSync(join(tmpdir(), 'os-c7b-apply-')); + file = join(dir, 'fixture.db'); + db = new Database(file); + seed(); + const c = catalog(); + const l = ledger(); + const first = await apply(c, l); + expect([first.complete, first.blocking], remedy).toEqual([true, 1]); + // The ceremony wrote nothing to the row: still there, still NULL. + expect(rows(`SELECT id, organization_id FROM sys_saved_report`)).toEqual([{ id: 'report_orphan', organization_id: null }]); + + db.exec(remedy); + writes = []; + const again = await apply(c, l); + expect(again.resumedFrom, remedy).toEqual([...CEREMONY_STEPS]); + expect([again.complete, again.blocking, again.advisory], remedy).toEqual([true, 0, 0]); + expect(l.verdicts.at(-1), remedy).toEqual({ complete: true, blocking: 0, advisory: 0 }); + expect(tableOf(again, 'sys_saved_report').held, remedy).toBeUndefined(); + } + }); + it('positive control: a grant the rewrite would lose reddens the effective-access pin', async () => { const before = accessBefore(); const c = catalog(); @@ -802,7 +835,9 @@ describe('ADR-0131 D10 --apply — idempotent, resumable, gated', () => { expect(c.templates.get('crm.late|en-US')).toMatchObject({ subject: 'Edited late' }); expect(rows(`SELECT id FROM sys_email_template`)).toEqual([]); expect(resumed.complete).toBe(true); - expect(resumed.blocking).toBe(0); + // Nothing remains but the fixture's fate-4 orphan, which holds certification (ruling C). + expect(resumed.tables.filter((t) => t.remaining > 0).map((t) => t.object)).toEqual(['sys_saved_report']); + expect(resumed.blocking).toBe(1); }); it('a customized template with no name blocks: kept, named, and verified_at stays null', async () => { @@ -904,12 +939,12 @@ describe('ADR-0131 D10 --apply — idempotent, resumable, gated', () => { // is a copy of a declaration the registry serves (ADR-0131 D2), so the // mirror-deletion step removes it — after renaming its grant — and nothing // of it remains (the blocking and seeding legs: - // `organization-ownership-apply.declared-rule-mirrors.test.ts`). - const unattributable = report.tables.reduce((n, t) => n + t.unattributable.length, 0); + // `organization-ownership-apply.declared-rule-mirrors.test.ts`). The one + // discrepancy left is the fixture's fate-4 orphan (ruling C). expect(report.complete).toBe(true); - expect(report.blocking).toBe(0); - expect(report.advisory).toBe(unattributable); - expect(l.verdicts.at(-1)).toEqual({ complete: true, blocking: 0, advisory: unattributable }); + expect(report.tables.filter((t) => t.remaining > 0).map((t) => t.object)).toEqual(['sys_saved_report']); + expect([report.blocking, report.advisory]).toEqual([1, 0]); + expect(l.verdicts.at(-1)).toEqual({ complete: true, blocking: 1, advisory: 0 }); expect(tableOf(report, 'sys_setting').departures).toEqual([]); const rule = tableOf(report, 'sys_sharing_rule'); expect(rule.remaining).toBe(0); @@ -2484,6 +2519,81 @@ describe('ADR-0131 D10 --apply — a single-posture database that holds no organ }); }); +describe('ruling C on the C8 decision — an unattributable row holds certification (ADR-0131 D10 fate 4: never guessed, never deleted)', () => { + /** A walled database: one fate-3 row whose anchor names a file that is gone, beside an attributable one. */ + const WALLED = ` + CREATE TABLE sys_organization (id TEXT PRIMARY KEY, slug TEXT, name TEXT); + INSERT INTO sys_organization VALUES ('org_a', 'acme', 'Acme'), ('org_b', 'beta', 'Beta'); + CREATE TABLE sys_file (id TEXT PRIMARY KEY, organization_id TEXT, ref_object TEXT, ref_id TEXT); + INSERT INTO sys_file VALUES ('file_a', 'org_a', NULL, NULL); + CREATE TABLE sys_upload_session (id TEXT PRIMARY KEY, organization_id TEXT, file_id TEXT); + INSERT INTO sys_upload_session VALUES ('us_ok', NULL, 'file_a'), ('us_orphan', NULL, 'file_gone'); + CREATE TABLE sys_job (id TEXT PRIMARY KEY, organization_id TEXT); + INSERT INTO sys_job VALUES ('job_1', NULL); + `; + + it('⭐ under a wall, a fate-3 row whose anchor is missing blocks: named with its table, count and remedy; the marker is not verified; the row is untouched', async () => { + await withDatabase(WALLED, async () => { + const l = ledger(); + const report = await apply(catalog(), l, { posture: 'isolated' }); + + expect(report.complete).toBe(true); + expect([report.blocking, report.advisory]).toEqual([1, 0]); + expect(l.verdicts.at(-1)).toEqual({ complete: true, blocking: 1, advisory: 0 }); + const sessions = tableOf(report, 'sys_upload_session'); + expect(sessions).toMatchObject({ attributed: 1, remaining: 1, held: { rows: 1, remedy: UNATTRIBUTABLE_REMEDY } }); + expect(sessions.unattributable).toEqual([{ id: 'us_orphan', reason: 'sys_file row file_gone not found' }]); + expect(report.tables.filter((t) => t.held).map((t) => t.object)).toEqual(['sys_upload_session']); + // Never guessed, never deleted. + expect(rows('SELECT id, organization_id FROM sys_upload_session ORDER BY id')).toEqual([ + { id: 'us_ok', organization_id: 'org_a' }, + { id: 'us_orphan', organization_id: null }, + ]); + expect(writes.filter((w) => /^DELETE/i.test(w) && /sys_upload_session/.test(w))).toEqual([]); + }); + }); + + it('the remedy the report names clears it: the operator assigns the organization by hand, and the re-run verifies', async () => { + await withDatabase(WALLED, async () => { + const l = ledger(); + await apply(catalog(), l, { posture: 'isolated' }); + db.exec(`UPDATE sys_upload_session SET organization_id = 'org_b' WHERE id = 'us_orphan'`); + const again = await apply(catalog(), l, { posture: 'isolated' }); + expect([again.complete, again.blocking, again.advisory]).toEqual([true, 0, 0]); + expect(l.verdicts.at(-1)).toEqual({ complete: true, blocking: 0, advisory: 0 }); + expect(rows(`SELECT organization_id FROM sys_upload_session WHERE id = 'us_orphan'`)).toEqual([{ organization_id: 'org_b' }]); + }); + }); + + it('control — a clean database still certifies: every row attributed, nothing held, nothing advisory', async () => { + await withDatabase(`${WALLED} UPDATE sys_upload_session SET file_id = 'file_a';`, async () => { + const l = ledger(); + const report = await apply(catalog(), l, { posture: 'isolated' }); + expect([report.complete, report.blocking, report.advisory]).toEqual([true, 0, 0]); + expect(l.verdicts.at(-1)).toEqual({ complete: true, blocking: 0, advisory: 0 }); + expect(report.tables.filter((t) => t.held || t.unattributable.length)).toEqual([]); + }); + }); + + it('control — on a table whose column is not the tenancy anchor (notNullExempt), an organization-less row is legitimate: reported, advisory, and the marker verifies', async () => { + await withDatabase(` + CREATE TABLE sys_organization (id TEXT PRIMARY KEY, slug TEXT, name TEXT); + INSERT INTO sys_organization VALUES ('org_a', 'default', 'Default'); + CREATE TABLE sys_sso_provider (id TEXT PRIMARY KEY, organization_id TEXT, provider_id TEXT); + INSERT INTO sys_sso_provider VALUES ('sso_global', NULL, 'okta'); + `, async () => { + const l = ledger(); + const report = await apply(catalog(), l); + const sso = tableOf(report, 'sys_sso_provider'); + expect(sso.unattributable.map((u) => u.id)).toEqual(['sso_global']); + expect(sso.held).toBeUndefined(); + expect(sso.remaining).toBe(0); + expect([report.complete, report.blocking, report.advisory]).toEqual([true, 0, 1]); + expect(l.verdicts.at(-1)).toEqual({ complete: true, blocking: 0, advisory: 1 }); + }); + }); +}); + describe('one definition of the Default Organization: the plan names the organization the boot invariant names (ADR-0131 D3, ADR-0093 D3)', () => { /** The same fixture for both readers: `sys_organization` as the invariant writes it, and rows no anchor names. */ const fixture = (organizations: string): string => ` diff --git a/packages/cli/src/utils/organization-ownership-apply.ts b/packages/cli/src/utils/organization-ownership-apply.ts index c21b42a2fa9..dcb8dd2b6a0 100644 --- a/packages/cli/src/utils/organization-ownership-apply.ts +++ b/packages/cli/src/utils/organization-ownership-apply.ts @@ -44,6 +44,7 @@ import { type UnnamedReason, } from './organization-ownership-grants.js'; import { describeSecurityPluginComposition, type SchemaMigrationComposition } from './schema-migration-plugins.js'; +import type { DeclaredSharingRuleSkip } from '@objectstack/plugin-sharing'; /** * The ADR-0131 D10 ceremony's apply step — ceremony items 2 to 4: the backup @@ -137,7 +138,15 @@ import { describeSecurityPluginComposition, type SchemaMigrationComposition } fr * mirrors while a release's own boot still seeded them * (`DECLARED_SHARING_RULE_SEEDER_RUNS`, the inventory; `false` since the * seeder retired) — is reported as advisory: named and counted, its rows still - * NULL, never deleted, and not a discrepancy. Every other remaining row is one. + * NULL, never deleted, and not a discrepancy. Every other remaining row is one, + * and so is every unattributable row (ruling C on the C8 decision: fate 4's + * reported rows, and a fate-3 row no anchor names under a wall): named per + * table with its count and the remedy — assign it an organization, or delete + * it by hand — and held against certification until it is gone, so a verified + * marker means zero NULL rows on every table. ⛔ The ceremony neither guesses + * its owner nor deletes it (D10 fate 4). The one exception is a fate-4 table + * whose column is not the tenancy anchor (`InventoryEntry.notNullExempt`): its + * organization-less rows are legitimate, reported, and advisory. * * ## Idempotent and resumable * @@ -246,11 +255,48 @@ export interface MetadataLockPort { /** * [ADR-0131 C3 stage 9] The sharing rules the deployment's packages DECLARE, * by name — the registry the plugin's enforcement reads them from (the - * command: the booted engine's registry). A declared-rule mirror is deleted - * only once its name resolves here. + * command: the booted engine's registry) — each with whether that enforcement + * SERVES it: `refused` is `null` when plugin-sharing's own translation + * (`translateDeclaredSharingRule`) accepts the declaration, else its reason in + * the operator's terms. A declared-rule mirror's grants are renamed only onto a + * served name: a grant naming a refused one would be revoked by the next + * boot's orphan sweep, so such a mirror blocks instead, naming the rule and the + * reason. */ export interface DeclaredSharingRulesPort { - names(): Promise; + rules(): Promise>; +} + +/** + * Judge each declared `sharing_rule` item as plugin-sharing's enforcement does + * — through its own `translateDeclaredSharingRule`, never a copy of it — for + * {@link DeclaredSharingRulesPort}: a nameless item is no rule to rename onto + * and is left out. + */ +export async function judgeDeclaredSharingRules(items: readonly unknown[]): Promise> { + const { translateDeclaredSharingRule } = await import('@objectstack/plugin-sharing'); + const out: Array<{ name: string; refused: string | null }> = []; + for (const item of items) { + const name = (item as { name?: unknown } | null)?.name; + if (typeof name !== 'string' || name === '') continue; + const { skip } = translateDeclaredSharingRule(item); + out.push({ name, refused: skip ? describeDeclaredRuleSkip(skip) : null }); + } + return out; +} + +/** Why plugin-sharing does not serve a declared rule, in the operator's terms: the translation's own refusal. */ +function describeDeclaredRuleSkip(skip: DeclaredSharingRuleSkip): string { + switch (skip.kind) { + case 'unnamed': + return 'the declaration names no object'; + case 'unmappable-recipient': + return `its recipient (sharedWith type ${JSON.stringify(skip.sharedWith ?? null)}) maps to no recipient the runtime grants to`; + case 'owner-rule': + return 'it is an owner-based rule, a retired authoring shape (use a criteria rule)'; + case 'condition': + return `its condition does not translate (${skip.cause.reason}: ${skip.cause.detail})`; + } } /** The `email_template` item the registry serves for one `(name, locale)` bundle member. */ @@ -467,6 +513,12 @@ export interface TableOutcome { columnDropped: boolean; /** Rows still NULL and listed by id — reported, never guessed. */ unattributable: UnattributableRow[]; + /** + * Present when the {@link unattributable} rows hold certification (every + * table but a `notNullExempt` one): their count, which `remaining` includes, + * and the remedy in the operator's terms ({@link UNATTRIBUTABLE_REMEDY}). + */ + held?: { rows: number; remedy: string }; /** * 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 @@ -478,7 +530,10 @@ export interface TableOutcome { 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; advisory departures are not in it. */ + /** + * Post-check: what is left for this table's fate, `0` when the fate is done — + * its {@link held} unattributable rows included; advisory departures are not in it. + */ remaining: number; notNull: TablePlan['notNull']; } @@ -506,12 +561,16 @@ export interface OrganizationOwnershipApplyReport { tables: TableOutcome[]; /** Every step finished. */ complete: boolean; - /** Blockers (an unfinished ceremony) or post-check discrepancies (a finished one). Zero is the verdict. */ + /** + * Blockers (an unfinished ceremony) or post-check discrepancies (a finished + * one), unattributable rows among them (`TableOutcome.held`). Zero is the verdict. + */ blocking: number; /** - * 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`). + * Rows reported, never guessed and never held against the verdict: the rows + * of an advisory departure, held for the release that can remove them + * (`TableOutcome.departures`), and the unattributable rows of a table whose + * column is not the tenancy anchor (`InventoryEntry.notNullExempt`). */ advisory: number; } @@ -2542,12 +2601,15 @@ async function countRuleGrants(run: Run, tables: readonly string[], sourceId: st * copies nothing needs. In the ruled order: * * 1. Every grant naming a mirror must be able to name its rule instead: the - * mirror's name must resolve to a rule a package declares (the registry, + * mirror's name must resolve to a rule a package declares and the plugin + * serves (the registry through the plugin's own translation, * {@link DeclaredSharingRulesPort}). A mirror whose rule no package - * declares any more, and that grants still name, blocks — named, with its + * declares any more, or whose declaration the plugin refuses to serve, and + * that grants still name, blocks — named, with the refusal's reason and the * remedy, nothing written: deleting it would withdraw that access on the - * operator's behalf. One no grant names is a copy of nothing that grants - * nothing, and goes like every fate-2 mirror. + * operator's behalf, and renaming its grants onto a refused rule would have + * the next boot's sweep withdraw it. One no grant names is a copy of + * nothing that grants nothing, and goes like every fate-2 mirror. * 2. Item 5 (c): every grant naming a mirror row by id * (`sys_record_share.source_id`, `source = 'rule'`) is renamed to the * rule's NAME — the id a declared rule's grants carry — BEFORE any mirror @@ -2573,7 +2635,12 @@ async function runDeclaredRuleMirrorDeletion(run: Run): Promise { 'command against the deployment\'s own composition, then re-run.', ]); } - const declared = new Set(await run.opts.declaredSharingRules.names()); + const declaredRules = await run.opts.declaredSharingRules.rules(); + const served = new Set(declaredRules.filter((r) => r.refused === null).map((r) => r.name)); + const refusedFor = new Map(); + for (const rule of declaredRules) { + if (rule.refused !== null && !served.has(rule.name) && !refusedFor.has(rule.name)) refusedFor.set(rule.name, rule.refused); + } const shares = run.plan.tables.find((t) => t.object === RECORD_SHARES); const grantTables: string[] = []; @@ -2583,16 +2650,34 @@ async function runDeclaredRuleMirrorDeletion(run: Run): Promise { } const grantsOf = new Map(); for (const mirror of mirrors) grantsOf.set(mirror.id, await countRuleGrants(run, grantTables, mirror.id)); - const unnameable = mirrors.filter((m) => !declared.has(m.name) && grantsOf.get(m.id)! > 0); + const unnameable = mirrors.filter((m) => !served.has(m.name) && grantsOf.get(m.id)! > 0); if (unnameable.length > 0) { - const names = [...new Set(unnameable.map((m) => m.name || '(no name)'))].sort(); - return blocked([ - `${unnameable.length} declared-rule mirror(s) on ${SHARING_RULES} name a sharing rule no installed package ` + - `declares (${names.join(', ')}; rows ${unnameable.map((m) => m.id).sort().join(', ')}), and ${RECORD_SHARES} ` + - 'grants still name them. Their grants cannot be renamed to a rule the registry serves, and deleting the ' + - 'mirrors would withdraw that access, so they stay and nothing was written. Declare the rule in its package ' + - `again, or delete each row and the ${RECORD_SHARES} rows whose source_id names it, then re-run.`, - ]); + const rowsOf = (list: readonly DeclaredRuleMirror[]): string => list.map((m) => m.id).sort().join(', '); + const undeclared = unnameable.filter((m) => !refusedFor.has(m.name)); + const refused = unnameable.filter((m) => refusedFor.has(m.name)); + const blockers: string[] = []; + if (undeclared.length > 0) { + const names = [...new Set(undeclared.map((m) => m.name || '(no name)'))].sort(); + blockers.push( + `${undeclared.length} declared-rule mirror(s) on ${SHARING_RULES} name a sharing rule no installed package ` + + `declares (${names.join(', ')}; rows ${rowsOf(undeclared)}), and ${RECORD_SHARES} ` + + 'grants still name them. Their grants cannot be renamed to a rule the registry serves, and deleting the ' + + 'mirrors would withdraw that access, so they stay and nothing was written. Declare the rule in its package ' + + `again, or delete each row and the ${RECORD_SHARES} rows whose source_id names it, then re-run.`, + ); + } + for (const name of [...new Set(refused.map((m) => m.name))].sort()) { + const of = refused.filter((m) => m.name === name); + blockers.push( + `${of.length} declared-rule mirror(s) on ${SHARING_RULES} name the sharing rule ${name} (rows ${rowsOf(of)}), ` + + `which its package declares but plugin-sharing does not serve: ${refusedFor.get(name)}. ${RECORD_SHARES} ` + + 'grants still name them; renamed onto the rule, the next boot\'s sweep would withdraw them as grants of a ' + + 'rule nobody serves, and deleting the mirrors would withdraw them now, so they stay and nothing was ' + + `written. Correct the rule's declaration in its package so it is served, or delete each row and the ` + + `${RECORD_SHARES} rows whose source_id names it, then re-run.`, + ); + } + return blocked(blockers); } // (c) — the grants first. @@ -2795,14 +2880,32 @@ const departingRows = (table: TablePlan): number => table.departures.reduce((n, /** 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 the operator does about a row whose owner cannot be derived (ADR-0131 + * D10 fate 4; ruling C on the C8 decision). The ceremony does neither itself. + */ +export const UNATTRIBUTABLE_REMEDY = + 'assign each row its organization (write its organization_id, or repair the anchor its reason names), or delete ' + + 'it by hand, then re-run --apply; the ceremony never guesses an owner and never deletes such a row, and the ' + + 'database does not certify while one stays NULL'; + +/** + * Unattributable rows the post-check holds against certification: every one, + * but on a fate-4 table whose column is not the tenancy anchor + * (`InventoryEntry.notNullExempt`), where an organization-less row is a + * legitimate one and is reported only. + */ +const heldUnattributable = (run: Run, table: TablePlan): number => + mirrorEntry(run, table.object)?.notNullExempt ? 0 : table.unattributable.length; + /** What a table still owes its fate, read from the post-check plan. */ async function remainingFor(run: Run, table: TablePlan, complete: boolean): Promise { if (table.physical !== 'present' || !table.rows) return 0; switch (table.fate) { case 'attribution': - return Number(table.fateCounts.rowsToAttribute ?? 0) + departingRows(table); + return Number(table.fateCounts.rowsToAttribute ?? 0) + departingRows(table) + heldUnattributable(run, table); case 'report': - return departingRows(table); + return departingRows(table) + heldUnattributable(run, table); case 'mirror-deletion': { const entry = mirrorEntry(run, table.object); if (!entry) return 0; @@ -3075,6 +3178,7 @@ export async function applyOrganizationOwnership(opts: ApplyOptions): Promise !d.advisory); const waiting = pending.reduce((n, d) => n + d.rows, 0); + const held = a ? heldUnattributable(run, a) : 0; tables.push({ object, fate: ref.fate, @@ -3086,6 +3190,7 @@ export async function applyOrganizationOwnership(opts: ApplyOptions): Promise 0 ? { held: { rows: held, remedy: UNATTRIBUTABLE_REMEDY } } : {}), departures, ...(a?.templateConflicts?.length ? { templateConflicts: a.templateConflicts } : {}), remaining, @@ -3094,7 +3199,7 @@ export async function applyOrganizationOwnership(opts: ApplyOptions): Promise n + advisoryRows(t), 0); + const advisory = after.tables.reduce((n, t) => n + advisoryRows(t) + t.unattributable.length - heldUnattributable(run, 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/vitest.config.ts b/packages/cli/vitest.config.ts index 2fe9e0180b0..f7245834057 100644 --- a/packages/cli/vitest.config.ts +++ b/packages/cli/vitest.config.ts @@ -823,6 +823,18 @@ export default defineConfig({ find: /^@objectstack\/plugin-audit$/, replacement: path.resolve(__dirname, '../plugins/plugin-audit/src/index.ts'), }, + // [ADR-0131 C3 stage 9] `utils/organization-ownership-apply.ts` asks + // `@objectstack/plugin-sharing`'s own `translateDeclaredSharingRule` + // which declared sharing rules are served before the ceremony renames a + // mirror's grants onto one, and `organization-ownership-apply.declared-rule-mirrors.test.ts` + // pins that judgement. Resolved to source for the reason its neighbours + // give. Anchored (plugin-sharing publishes only `"."`). + // `check:test-source-alias` names this import and is the gate that fails + // without the entry. + { + find: /^@objectstack\/plugin-sharing$/, + replacement: path.resolve(__dirname, '../plugins/plugin-sharing/src/index.ts'), + }, ], }, test: { diff --git a/packages/plugins/plugin-sharing/src/index.ts b/packages/plugins/plugin-sharing/src/index.ts index 90866e752f1..162e5eb847e 100644 --- a/packages/plugins/plugin-sharing/src/index.ts +++ b/packages/plugins/plugin-sharing/src/index.ts @@ -100,6 +100,14 @@ export { unbindRuleProvenanceStamp, SHARING_RULE_PROVENANCE_PACKAGE, } from './sharing-rule-provenance.js'; +export { + // [ADR-0131 C3 stage 9] The one translation enforcement serves a declared + // rule through: the ownership ceremony asks it which declared names are + // SERVED before it renames a mirror's grants onto one. + translateDeclaredSharingRule, + type DeclaredSharingRule, + type DeclaredSharingRuleSkip, +} from './declared-sharing-rules.js'; export { SharingServicePlugin, buildSharingMiddleware,