Skip to content

Commit 4b114ed

Browse files
fix(metadata-core, metadata-protocol): two refusals say what 18.0 supports, not the ownership command 18.0 refuses (#24726)
Fixes #24710 Clause-②: no (text only: no refusal code, outcome, accept set or served shape moves) ## What changed In 18.0, `os migrate organization-ownership` refuses plan and apply on every database. Two refusals still told the operator to run it. Each now closes on PR #24635's release sentence: "18.0 supports fresh installs only and does not upgrade a v17 database: keep a v17 database on a 17.x release until the 18.x release that carries the upgrade path." The text is only made true for the readers who can actually meet each refusal on 18.0 (below). | refusal | old closing text | new closing text | |:--|:--|:--| | `LEGACY_ORGANIZATION_ROWS_UNREADABLE` (`packages/metadata-core/src/legacy-organization-row.ts`) | "Nothing was listed or written. Run `os migrate organization-ownership` on this database first." | "Nothing was listed or written. A fresh 18.0 install holds no legacy organization rows: there, the cause is the one named above, and the fix is in the SQL driver or the caller that met it. A database that does hold them is a v17 one. 18.0 supports fresh installs only and does not upgrade a v17 database: keep a v17 database on a 17.x release until the 18.x release that carries the upgrade path." | | seed refusal for a natural key held by a row with no organization (`packages/metadata-protocol/src/seed-loader.ts`) | "Attribute the unowned rows first (`os migrate organization-ownership`, then the same command with --apply), then re-run this seed: the row is this organization's own by then, and the upsert matches it." | "A row with no organization predates organization stamping, so it is a v17 database's. 18.0 supports fresh installs only and does not upgrade a v17 database: keep a v17 database on a 17.x release until the 18.x release that carries the upgrade path." | Unchanged: both codes, "Nothing was listed or written.", the seed refusal's opening sentences (what was refused, and why), its `errored` count, and the dropped record. There is no new export, error code or constructor parameter. ## Who meets each refusal on 18.0 (read before the edit) **`readLegacyOrganizationRows`.** It throws in seven places. Each is listed with whether a fresh 18.0 install reaches it. | throw (`detail`) | shape | fresh 18.0 install? | |:--|:--|:--| | not a sys_metadata-family table | caller defect | yes, if a caller passes a wrong table (every in-tree caller passes a family constant) | | the SQL driver names no dialect this read can compile a column list for | driver | **yes**: checked before the column question | | the SQL driver has no raw-SQL seam | driver | **yes** | | the driver's column list is not a list | driver | **yes** | | the raw-SQL seam returned no result set (column read) | driver | **yes** | | its column list came back empty for a table that exists | driver/catalog | **yes** | | the raw-SQL seam returned no result set (legacy row read) | database: the column is on disk | no: only a database that still carries `organization_id` | The callers reach it on a fresh install. `protocol.ts`'s `findEnvironmentRows` / `findEnvironmentRow` run it on every family read. Hydration in `ObjectQLPlugin.start()` asks it even when no row came back ("Asked even when no row came back"), so `os serve` meets it on every SQL boot. `sys-metadata-repository.ts` `legacyOrganizationRowIds` runs it on every addressed write. `cli`'s `security-catalog-overlays.ts` runs it on the `os migrate` step. The old remedy was already wrong for the five driver-shaped throws: the ceremony itself refuses an unresolved dialect (`dialectOfDriver`'s doc). So the new text does not tell a fresh-install operator to hold a v17 database on 17.x. It says a fresh install has no legacy rows, so there the named cause is the fix, and it gives the release sentence to the database that does hold them. This is one message for both readers, and the constructor is unchanged. Following the detail per throw would need a new parameter: the shared `SEAM_NO_ANSWER_DETAIL` is passed by both a driver-shaped throw and the database-shaped one. **The seed refusal.** It fires only on an organization-scoped `upsert`/`ignore` load (`config.organizationId`) whose natural key is held by an `organization_id IS NULL` row. Under ADR-0131 D9, as `Engine.resolveSystemInsertOrganization` implements it on 18.0, a system insert on a tenant-column object is stamped with the Default Organization under `single`, or refused. 18.0 refuses a walled posture on the operator's boot path. The single-posture inline seed is a system insert, so it is stamped too. The one 18.0 shape that leaves `organization_id` NULL is the `no-organization-object` composition, which registers no `sys_organization`. That composition has no organization insert, so no per-organization replay runs. The marketplace scoped install and the reseed pass an organization only when walled. So no in-tree producer runs a scoped load there. The reader who meets this refusal holds rows that predate stamping: a v17 database. `seed-loader-null-organization-holder-real-driver.integration.test.ts` measures the same population: "a single-posture 17.x install, every business row `organization_id` NULL". The release sentence is true for that reader. The old remedy's bare `--apply` was refused even before 18.0. Comments beside the two texts only describe the ceremony (the module header, `dialectOfDriver`'s doc, the `#24301` note in `seed-loader.ts`). None of them prescribes it as an 18.0 remedy, so none moved. ## Pins - `packages/metadata-core/src/legacy-organization-row.test.ts`: a new `it.each` in "fails closed" covers a driver-shaped throw (no raw seam) and the database-shaped one (column on disk, legacy select answered nothing). It asserts `code === 'LEGACY_ORGANIZATION_ROWS_UNREADABLE'`, "Nothing was listed or written.", the fresh-install sentence, the message ending on the release sentence, and `os migrate organization-ownership` absent. - `packages/metadata-protocol/src/seed-loader-null-organization-holder.test.ts`: the existing upsert/ignore refusal test, which already asserts `totalErrored === 1` and the dropped record. It now also asserts that the opening sentences stay, that the message ends on the new remedy, and that `os migrate organization-ownership` and `--apply` are absent. - **A pin moved outside the claim's file surface:** `packages/runtime/src/seed-loader-null-organization-holder-real-driver.integration.test.ts` asserted `toContain('os migrate organization-ownership')`. It now asserts the release sentence and the command's absence. Without that change, `main` would go red. Pin sweep (`LEGACY_ORGANIZATION_ROWS_UNREADABLE`, "on this database first", "Attribute the unowned rows first", "then the same command with --apply", plus the messages' other sentences) over `packages`, `qa`, `docs/qa/platform-checklist` and `content/docs`. It found the runtime pin above and no other pin of either text. `protocol.stored-migration.test.ts`'s `/legacy organization rows/` reads the unchanged first sentence. The hits in the generated `content/docs/references/api/*.mdx` ledgers and in released `CHANGELOG.md` entries name only the code and do not move. ## Verification - Build: the `@objectstack/runtime^...` closure plus `@objectstack/metadata-protocol`, VERDICT command-exit 0. After merging `origin/main` (`54159fd74`, which brought PR #24696's `seed-loader.ts` change; the merge was clean), full `pnpm build --concurrency=2` ran: 76/76 tasks, VERDICT command-exit 0. - Typecheck on the merged head: `@objectstack/metadata-core` exit 0, `@objectstack/metadata-protocol` exit 0. - Full suites on the merged head: `@objectstack/metadata-core` 18 files / 776 tests passed. `@objectstack/metadata-protocol` 250 files passed, 3 skipped / 20802 tests passed, 19 skipped. - `seed-loader-null-organization-holder-real-driver.integration.test.ts` (`@objectstack/runtime`): 6/6 passed. - `pnpm --filter @objectstack/core test:repo`: 6 files / 82 tests passed. - `@objectstack/cli`: no existing pin reads either text, and none was added, so its suite is not owed. - **Ablation:** with `scripts/ablation-replace.mjs --hold`, both texts were reverted to the `main` spelling. The on-disk counts moved (old1=1 new1=0, old2=1 new2=0). Both packages were rebuilt, and `ablation-dist-preflight` found the old markers in `dist/`. The legacy-row pin went red (2 failed), the seed pin went red (2 failed), and the runtime pin went red (1 failed). Restored with `git checkout HEAD --`: `git diff HEAD` was empty and `git status --porcelain` was clean. Blobs equal `HEAD` (`e19b19399a05` / `67a3560d3c2c`). Rebuilt, `--absent` preflight passed for both packages, and the runtime pin was green again. - Dispatch gates (`node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack`, no paths, run on the final head `396fcb9c0`): every one of the 65 derived lines exited 0. `check:dual-build-cjs-loads` first exited 3 (PREREQUISITE NOT MET, no `dist/`) on the pre-merge head. It was built for and re-run, and exited 0. `--ran` with `COMMAND :: exit CODE` records reported: "65 derived, 65 run, 0 NOT-MEASURED, 0 UNRUN" (a derived zero). `check-nul-bytes` exited 0. ## Acceptance notes - The seed text says such a row "is a v17 database's". On 18.0 the only NULL-organization producer is the `no-organization-object` composition, and no in-tree caller runs a scoped load there. An embedder that calls `SeedLoaderService.load` with an `organizationId` against such a composition would read the v17 sentence for a row its own composition wrote. That reader is speculative (no producer), so it is not filed. - Per the card's order note, PR #24681 (18.x steps 5–6) reopens the ceremony and restates these two texts when it lands. --- _Generated by [Claude Code](https://claude.ai/code/session_01KQiKEtTxcLqE3TeLATbWFb)_ Co-authored-by: Claude <noreply@anthropic.com>
1 parent 7e7e605 commit 4b114ed

6 files changed

Lines changed: 57 additions & 6 deletions
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
---
2+
'@objectstack/metadata-core': patch
3+
'@objectstack/metadata-protocol': patch
4+
---
5+
6+
fix(metadata-core, metadata-protocol): two refusals say what 18.0 supports for a v17 database, not `os migrate organization-ownership`, which 18.0 refuses on every database
7+
8+
Clause-②: no (text only: no refusal code, outcome, accept set or served shape moves)
9+
10+
In 18.0, `os migrate organization-ownership` refuses plan and apply on every database (`NOT_IMPLEMENTED`): 18.0 supports fresh installs only. Two refusals still told the operator to run it, so following them led to a second refusal.
11+
12+
- **`LEGACY_ORGANIZATION_ROWS_UNREADABLE`** (`LegacyOrganizationRowsUnreadable`, `@objectstack/metadata-core`). It used to close with "Run `os migrate organization-ownership` on this database first." This read runs on every SQL database, a fresh 18.0 one included, and several of its refusals are the driver's (a dialect it cannot name, no raw-SQL seam, a column list that is not a list or comes back empty). For those, the command was never the remedy. The message now closes: "A fresh 18.0 install holds no legacy organization rows: there, the cause is the one named above, and the fix is in the SQL driver or the caller that met it. A database that does hold them is a v17 one. 18.0 supports fresh installs only and does not upgrade a v17 database: keep a v17 database on a 17.x release until the 18.x release that carries the upgrade path."
13+
- **The seed refusal for a natural key held by a row with no organization** (`SeedLoaderService`, `@objectstack/metadata-protocol`). It used to close with "Attribute the unowned rows first (`os migrate organization-ownership`, then the same command with --apply), then re-run this seed". Even before 18.0, that bare `--apply` was refused, since the command requires `--plan-file`. The message now closes: "A row with no organization predates organization stamping, so it is a v17 database's. 18.0 supports fresh installs only and does not upgrade a v17 database: keep a v17 database on a 17.x release until the 18.x release that carries the upgrade path."
14+
- **Unchanged:** both refusal codes, "Nothing was listed or written.", the seed refusal's opening sentences (what was refused and why), its `errored` count, and the dropped record.

‎packages/metadata-core/src/legacy-organization-row.test.ts‎

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -115,6 +115,28 @@ describe('readLegacyOrganizationRows — fails closed', () => {
115115
await expect(readLegacyOrganizationRows({ dialectName: 'sqlite' })).rejects.toThrow(/no raw-SQL seam/);
116116
});
117117

118+
// What 18.0 supports, said to both readers: a driver-shaped refusal, which a
119+
// fresh install meets too, and one only a database still carrying the
120+
// column reaches. The ceremony 18.0 refuses is never the remedy.
121+
it.each([
122+
['driver-shaped: no raw seam (any database, a fresh one included)', { dialectName: 'sqlite' }],
123+
['database-shaped: the column is on disk, the legacy select answered nothing', sqlDriver({ dialectName: 'sqlite', columns: V17_COLUMNS, rows: null })],
124+
])('the refusal says what 18.0 supports, never the ceremony (%s)', async (_label, driver) => {
125+
const error = await readLegacyOrganizationRows(driver).then(() => null, (e: unknown) => e);
126+
expect(error).toBeInstanceOf(LegacyOrganizationRowsUnreadable);
127+
const refusal = error as LegacyOrganizationRowsUnreadable;
128+
expect(refusal.code).toBe('LEGACY_ORGANIZATION_ROWS_UNREADABLE');
129+
expect(refusal.message).toContain('Nothing was listed or written.');
130+
expect(refusal.message).toContain(
131+
'A fresh 18.0 install holds no legacy organization rows: there, the cause is the one named above, ' +
132+
'and the fix is in the SQL driver or the caller that met it.',
133+
);
134+
expect(refusal.message).toMatch(
135+
/A database that does hold them is a v17 one\. 18\.0 supports fresh installs only and does not upgrade a v17 database: keep a v17 database on a 17\.x release until the 18\.x release that carries the upgrade path\.$/,
136+
);
137+
expect(refusal.message).not.toContain('os migrate organization-ownership');
138+
});
139+
118140
it('a table outside the family is refused before anything runs', async () => {
119141
const driver = sqlDriver({ dialectName: 'sqlite', columns: V17_COLUMNS, rows: [] });
120142
await expect(readLegacyOrganizationRows(driver, 'sys_user')).rejects.toThrow(/not a sys_metadata-family table/);

‎packages/metadata-core/src/legacy-organization-row.ts‎

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -75,7 +75,10 @@ export class LegacyOrganizationRowsUnreadable extends Error {
7575
constructor(readonly table: string, detail: string) {
7676
super(
7777
`Could not tell ${table}'s legacy organization rows from the environment's: ${detail}. ` +
78-
'Nothing was listed or written. Run `os migrate organization-ownership` on this database first.',
78+
'Nothing was listed or written. A fresh 18.0 install holds no legacy organization rows: there, the ' +
79+
'cause is the one named above, and the fix is in the SQL driver or the caller that met it. A database ' +
80+
'that does hold them is a v17 one. 18.0 supports fresh installs only and does not upgrade a v17 ' +
81+
'database: keep a v17 database on a 17.x release until the 18.x release that carries the upgrade path.',
7982
);
8083
this.name = 'LegacyOrganizationRowsUnreadable';
8184
}

‎packages/metadata-protocol/src/seed-loader-null-organization-holder.test.ts‎

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -135,6 +135,15 @@ describe('[#24301] a scoped load over a NULL-organization holder of a natural ke
135135
const [refusal] = refusalsOf(result);
136136
expect(refusal).toMatchObject({ sourceObject: 'tw_widget', targetField: 'sku', attemptedValue: 'W-HELD', recordIndex: 0 });
137137
expect(refusal.message).toContain("'w_unowned'");
138+
// What 18.0 supports, never the ceremony it refuses; the first sentences
139+
// (what was refused, and why) stay.
140+
expect(refusal.message).toContain('which has no organization (organization_id is NULL)');
141+
expect(refusal.message).toContain('decided by the organization-ownership ceremony, not by a seed load.');
142+
expect(refusal.message).toMatch(
143+
/A row with no organization predates organization stamping, so it is a v17 database's\. 18\.0 supports fresh installs only and does not upgrade a v17 database: keep a v17 database on a 17\.x release until the 18\.x release that carries the upgrade path\.$/,
144+
);
145+
expect(refusal.message).not.toContain('os migrate organization-ownership');
146+
expect(refusal.message).not.toContain('--apply');
138147
});
139148

140149
it("the organization's own row wins: a key held both by it and by an unowned row is matched, not refused", async () => {

‎packages/metadata-protocol/src/seed-loader.ts‎

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1848,9 +1848,10 @@ export class SeedLoaderService implements ISeedLoaderService {
18481848
`which has no organization (organization_id is NULL), and this seed load writes for organization ` +
18491849
`${config.organizationId}. Inserting the record would put a twin beside that row, and updating the ` +
18501850
`row would hand it to this organization: which organization an unowned row belongs to is decided by ` +
1851-
`the organization-ownership ceremony, not by a seed load. Attribute the unowned rows first ` +
1852-
`(\`os migrate organization-ownership\`, then the same command with --apply), then re-run this seed: ` +
1853-
`the row is this organization's own by then, and the upsert matches it.`,
1851+
`the organization-ownership ceremony, not by a seed load. A row with no organization predates ` +
1852+
`organization stamping, so it is a v17 database's. 18.0 supports fresh installs only and does not ` +
1853+
`upgrade a v17 database: keep a v17 database on a 17.x release until the 18.x release that carries ` +
1854+
`the upgrade path.`,
18541855
};
18551856
errors.push(error);
18561857
allErrors.push(error);

‎packages/runtime/src/seed-loader-null-organization-holder-real-driver.integration.test.ts‎

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -209,10 +209,12 @@ describe('scoped seed load over NULL-organization holders (sqlite-file)', () =>
209209
expect(kickoff.targetObject).toBe('tw_event');
210210
expect(kickoff.targetField).toBe('subject');
211211
expect(kickoff.attemptedValue).toBe('Kickoff');
212-
// The message names the key, the unowned holder and the remedy's command.
212+
// The message names the key, the unowned holder and what 18.0 supports:
213+
// never the ceremony 18.0 refuses.
213214
expect(kickoff.message).toContain('subject=Kickoff');
214215
expect(kickoff.message).toContain("'ev_kickoff'");
215-
expect(kickoff.message).toContain('os migrate organization-ownership');
216+
expect(kickoff.message).toContain('18.0 supports fresh installs only and does not upgrade a v17 database');
217+
expect(kickoff.message).not.toContain('os migrate organization-ownership');
216218
expect(result.success).toBe(false);
217219
// Loud: said at `error`, once per dataset.
218220
const lines = logger.error.mock.calls.map((c) => String(c[0])).filter((m) => refusals.some((e) => m.includes(e.message)));

0 commit comments

Comments
 (0)