Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 28 additions & 0 deletions .changeset/15212-8c0-unattributable-rows-block.md
Original file line number Diff line number Diff line change
@@ -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)

<!-- adr-0087: not-required (no-migration-prescription) No spec key, option, error code or stored shape is added, removed or renamed, and nothing exported is removed: the ceremony's report gains one field (`TableOutcome.held`), its verdict counts change, and plugin-sharing gains one export. The only stored state that moves is the operator's own: a row whose owner cannot be derived is given its organization or deleted by hand, which `objectstack migrate meta` has nothing to rewrite for. The other categories are closed on facts: the packages publish (not unpublished); no ADR-0087 id is named or touched (not registered or already-registered); no Zod schema in packages/spec changes (not runtime-interface-only or type-surface-only in the spec sense); and nothing exported is retired (not runtime-export-retired). -->

**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.
17 changes: 15 additions & 2 deletions content/docs/deployment/cli.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
53 changes: 40 additions & 13 deletions packages/cli/src/commands/migrate/organization-ownership.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,8 @@ import {
checkpointFromDetails,
MetadataChoiceRefusal,
ORGANIZATION_OWNERSHIP_MIGRATION_ID,
judgeDeclaredSharingRules,
UNATTRIBUTABLE_REMEDY,
ReviewedPlanRefusal,
reviewedPlanFromFile,
TemplateChoiceRefusal,
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -801,20 +811,18 @@ 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) {
const metadata = kernel.getService('metadata') as { list?(type: string): unknown } | undefined;
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: {
Expand Down Expand Up @@ -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
Expand All @@ -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(
Expand Down
26 changes: 24 additions & 2 deletions packages/cli/src/utils/dev-organization-ownership-upgrade.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
*/
Expand Down Expand Up @@ -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');
Expand Down Expand Up @@ -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);
Expand Down
31 changes: 28 additions & 3 deletions packages/cli/src/utils/dev-organization-ownership-upgrade.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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,
Expand All @@ -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.`,
],
};
}
Loading
Loading