Skip to content

Commit feb09db

Browse files
fix(cli): os dev's in-place upgrade line names no origin for a refused development database (#24935)
Fixes #24833 Clause-②: no Basis for the Clause-② line (the claim's, comment 6294842538): printed text of `os dev` only. No accept set, exit code, refusal, recovered boot or IPC message moves. The seat ruled option A on the measurement in comment 6294804267: the line `os dev` prints before it upgrades a refused development database names no origin and points at the guard's own refusal. The stack half is an acceptance note (below). ## What changed - **`packages/cli/src/commands/dev.ts`**. The coordinator's `recover` printed "This development database is still in the v17 organization-ownership shape — upgrading it in place (a copy is kept first)…" for every refusal. It now prints: ```text ⟳ The organization-ownership boot guard refused this development database (the refusal above), so os dev upgrades it in place, keeping a copy first… ``` The line names no origin. `recover` runs for every `OrganizationOwnershipBootRefusal`, and the guard throws one at six places (`packages/runtime/src/organization-ownership-boot-guard.ts`: the shape read failed, the birth write failed on an empty store, and a marker that is absent, unverified, unreadable or from an older shape generation). Only the absent marker means "17.x or an 18.0 pre-release created it". The guard's own text, printed just above by the failed boot, names what it found, so it stays the one source of that wording. - The printing moved out of the inline closure into an exported seam, `upgradeRefusedDevelopmentDatabase({ print, upgrade })`, so a unit test can drive the real line. `recover` calls it with `console.log` and the same upgrade as before. The order is unchanged: the line, then the runtime import and the upgrade, then the warnings and the verdict lines. The verdict line still carries the copy's path, from `upgradeDevelopmentDatabase`'s own result. - One comment beside the coordinator no longer says "the v17 organization-ownership shape". - **Changeset**: `@objectstack/cli` patch, `Clause-②: no`. Not touched (hold-offs): the guard, its refusal text and rule; the ceremony; `utils/dev-organization-ownership-upgrade.ts` (its behaviour, its IPC message shape and its "v18" verdict line); `serve.ts`; `packages/core/**`; `packages/spec/**`; the `[schema-drift]` line (#24638's); which boots `os dev` recovers; exit codes; `content/docs/releases/`. ## End to end, on the built CLI Fixture: a one-object define-stack scaffold; `os dev --no-watch -d file:A.db -p 0` once; the `adr-0131-organization-ownership` row deleted from `sys_migration` (tables, no marker: the guard's "absent" refusal); then `os dev` again. `NODE_ENV` unset, `bin/run.js`, `OS_TELEMETRY_DB=0`. - `main` `19484af53` (the card's base; `packages/cli` is byte-identical at `b42c621c0`): ```text TIMESTAMP ERROR Plugin startup failed: com.objectstack.organization-ownership-boot-guard {"error":{"message":"This database carries no organization-ownership marker: 17.x or an 18.0 pre-release (18.0.0-next.*) created it, … [Kernel] Plugin startup failed: com.objectstack.organization-ownership-boot-guard This database carries no organization-ownership marker: … (plus 10 stack frames) ⟳ This development database is still in the v17 organization-ownership shape — upgrading it in place (a copy is kept first)… ✓ Upgraded the development database to the v18 organization ownership. The database as it was before the upgrade is copied to backups/organization-ownership-TIMESTAMP, beside the ceremony's log. The plan and the report are there too. ``` - This head (`0f9e4ca00d`): the same two kernel lines and frames, then ```text ⟳ The organization-ownership boot guard refused this development database (the refusal above), so os dev upgrades it in place, keeping a copy first… ✓ Upgraded the development database to the v18 organization ownership. The database as it was before the upgrade is copied to backups/organization-ownership-TIMESTAMP, beside the ceremony's log. The plan and the report are there too. ``` The server then reached "Server is ready" and printed "✓ server started on the upgraded database", as on `main`. ## Tests - **New pin**, `packages/cli/src/commands/dev-organization-ownership-recover.test.ts` (unit tier): drives `upgradeRefusedDevelopmentDatabase` with a fake upgrade and records each printed line. 4 cases: - the first line carries no "v17" and names no origin (no 17.x, 18.0, pre-release or "created"); - it names the refusal above, `os dev`, "in place" and "copy first", and prints before the upgrade runs; - the warnings, then the verdict line with the copy's path, and the server boots again; - an upgrade that did not finish prints its `✗` lines and boots nothing. The sentence is not pinned verbatim. - **Red on `main`**: the test file run against `main`'s `dev.ts` (blob `17fc74e9`, restored into the tree from `19484af53` with `git restore --source`, so not staged): 4 of 4 red, `TypeError: upgradeRefusedDevelopmentDatabase is not a function`, because `main` has no seam. Restored with `git checkout HEAD`; blob back to `3485d18c`, `git diff HEAD` empty. - **Red with `main`'s sentence in the seam** (`node scripts/ablation-replace.mjs`, the anchor hit once; the replacement is `main`'s two source lines byte for byte): the anchor went from 1 to 0 and the blob from `3485d18c` to `4e8a1f86`. 2 of 4 red: `expected ' ⟳ This development database is stil…' not to match /v17/i` and `… to match /refus/i`. The other 2 stayed green. Restore: blob equals `HEAD` (`3485d18c`), `git diff HEAD` empty. - **Green on this head**: the new pin plus `src/utils/dev-organization-ownership-upgrade.test.ts`, 2 files, 10 passed. - **Unit subset**: every unit test that imports `commands/dev`, plus the `dev-*` utilities, `port-contract-single-source`, `console-route-ledger.conformance` and `test/vitest-tiers-partition.test.ts`: 18 files, 246 passed. - The full `@objectstack/cli` unit project did not finish inside the 10-minute foreground cap here; it is left to CI's `Test Core`. The integration tier is left to CI as well: the diff touches no integration file and no spawn entry. - `pnpm --filter @objectstack/cli typecheck` (`tsc --noEmit` and `check:test-typecheck`): exit 0. The new test is in the `tsconfig.json` program (`--listFilesOnly`: 1 hit). ## Control: a bare `os serve` refusal is unchanged `os serve objectstack.config.ts -p 0` over the same unmarked file, `OS_DATABASE_URL=file:A.db`, `NODE_ENV` unset. It exited 1 on both trees. Its whole output on `19484af53` and on this head, with timestamps and the temp directory normalised, is byte-identical: 23 lines, `diff` exit 0. That output is the kernel's ERROR line, the `[Kernel]` line with its frames, the CLI's `✗` refusal line and the boot diagnostics. `serve.ts` does not import `dev.ts`. ## Gates All on the final head `0f9e4ca00d`, each exit code captured before any pipe. - `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack` derived 67 commands from the 3 changed paths (merge base `b42c621c0`). All 67 exited 0. Two of them, `pnpm check:dual-build-cjs-loads` and `pnpm check:i18n-coverage`, first exited 3 (`PREREQUISITE NOT MET`: no `dist` for packages outside the CLI closure). After a full `turbo run build`, both exited 0 (`check-i18n-coverage: OK (14 config(s), 621 baselined untranslated string(s), none new)`). - The 11 families listed under "Declared WIDE population" all exited 0: `check:init-service-contract`, `check:live-db-isolation`, `check:meta-type-normalized`, `check:optional-error-sink`, `check:resume-authority-declared`, `check:route-envelope`, `check:runner-env-posture`, `check:settings-bind-window`, `check:startup-registry-verdict`, `check:verify-stand-in` and `check:wildcard-fallthrough`. - `--ran`: `✓ dispatch-gates --ran: 67 derived famil(ies) accounted for — 67 run, 0 NOT-MEASURED (a DERIVED zero — all 67 recorded an exit code and none of them is 3).` - `pnpm lint` (`eslint . --no-inline-config`): exit 0. - Left to CI, not run here: the four type-check lanes, `Test Core` (the full `@objectstack/cli` unit project and the integration tier included) and `Build Core`. ## Acceptance notes - **The stack half stays, by the seat's ruling (option A).** On an `os dev` over a refused development database, two lines still precede the new one, both from the kernel: `packages/core/src/kernel.ts:568` (`this.logger.error('Plugin startup failed: …', result.error)`; the logger writes error level straight to `process.stderr`, `packages/core/src/logger.ts:351` and `:386`) and `:571` (`console.error('[Kernel] Plugin startup failed: …', origMsg, origStack)`, with no level gate). Measured with stdout and stderr captured apart, the two lines and the 10 frames are all on stderr. `serve.ts`'s boot-quiet window replaces only `process.stdout.write`, `console.log` and `console.debug`, and its catch runs after the kernel has printed. A developer meets this once per development database: after the upgrade the marker is verified. If a puller appears, the shape is option D of comment 6294804267: a host-gated kernel change that only the `os dev` child sets, so the bare `os serve` output stays as Zone 1 ruled. - **Bare `os serve` prints the guard's refusal three times**: the kernel's ERROR line (its JSON carries the message and the stack), the kernel's `[Kernel]` line with 10 frames, and then the CLI's own `✗` line (case C in comment 6294804267, re-measured above). Zone 1 keeps this output as it is, and the same two kernel lines are the ones in the note above. - **The card's "#24827" case no longer reaches this path** since `19484af53`. Measured: `os migrate apply --yes` on an empty file writes the birth marker, and `os dev` over that file reaches "Server is ready" with no refusal and no upgrade. - **Not edited, outside the surface**: the docblock of `utils/dev-organization-ownership-upgrade.ts` and a comment in `serve.ts` still say "the v17 organization-ownership shape". Neither is printed. - **Repro fixture**: a one-object scaffold with the marker row deleted, not the card's 18.0.0-next.5 `examples/app-showcase` database. It takes the same "absent" refusal and the same `recover` branch. It has no retired column, so no `[schema-drift]` lines appear. --- _Generated by [Claude Code](https://claude.ai/code/session_01LmjU2x1faLZ39DVTFyVca6)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 7169612 commit feb09db

3 files changed

Lines changed: 172 additions & 25 deletions

File tree

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
---
2+
'@objectstack/cli': patch
3+
---
4+
5+
fix(cli): `os dev`'s in-place upgrade line no longer says every refused database is "still in the v17 organization-ownership shape"
6+
7+
Clause-②: no (printed text of `os dev` only: no exit code, refusal, recovered boot or IPC message moves)
8+
9+
When the organization-ownership boot guard refuses the server `os dev` starts, `os dev` copies the development database and upgrades it in place. The line it printed first said the database was "still in the v17 organization-ownership shape", for every refusal. That was wrong for a database an 18.0 pre-release created, and for every refusal that is not about the database's origin at all: a marker that is unverified, unreadable or from an older shape generation, or a read or write the guard could not make.
10+
11+
The line now names no origin. It points at the guard's own refusal, printed just above it, which says what the guard found, and it says that `os dev` upgrades the database in place, keeping a copy first. The verdict line printed after the upgrade still gives the copy's path. Which databases `os dev` upgrades, how it upgrades them, and what a bare `os serve` prints are unchanged.
Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,96 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
import { describe, it, expect } from 'vitest';
4+
import { stripVTControlCharacters } from 'node:util';
5+
import { upgradeRefusedDevelopmentDatabase } from './dev.js';
6+
import type { DevelopmentUpgradeResult } from '../utils/dev-organization-ownership-upgrade.js';
7+
8+
/**
9+
* #24833 — what `os dev` prints when the organization-ownership boot guard
10+
* refused its `serve` child and it upgrades the development database in place.
11+
*
12+
* The coordinator's `recover` in `dev.ts` calls `upgradeRefusedDevelopmentDatabase`
13+
* with `console.log` and the real upgrade. Here the upgrade is faked: what it
14+
* does is `utils/dev-organization-ownership-upgrade.test.ts`'s subject, and the
15+
* line it prints is this file's.
16+
*
17+
* ## The defect
18+
*
19+
* The first line used to say the database was "still in the v17
20+
* organization-ownership shape" for every refusal. The guard refuses a database
21+
* whose marker is absent, unverified, unreadable or from an older shape
22+
* generation, or whose shape or birth marker it could not read or write, and
23+
* only the absent marker means "17.x or an 18.0 pre-release created it". So the
24+
* line names no origin at all: the guard's own refusal, printed just above it
25+
* by the failed boot, does.
26+
*
27+
* ⛔ The sentence is not pinned verbatim. The pins are what it must not say (an
28+
* origin) and the things it must name: the refusal above, the in-place
29+
* upgrade, the copy. The copy's path is the verdict line's, from the result.
30+
*/
31+
32+
const COPY = '.objectstack/data/backups/organization-ownership-2026-10-20T15-33-45-773Z';
33+
34+
/** Drive the seam with a fake upgrade, recording every printed line (colour stripped) and when the upgrade ran. */
35+
async function drive(result: DevelopmentUpgradeResult): Promise<{ lines: string[]; printedBeforeUpgrade: number; ok: boolean }> {
36+
const lines: string[] = [];
37+
let printedBeforeUpgrade = -1;
38+
const ok = await upgradeRefusedDevelopmentDatabase({
39+
print: (line) => lines.push(stripVTControlCharacters(line)),
40+
upgrade: async () => {
41+
printedBeforeUpgrade = lines.length;
42+
return result;
43+
},
44+
});
45+
return { lines, printedBeforeUpgrade, ok };
46+
}
47+
48+
const upgraded: DevelopmentUpgradeResult = {
49+
ok: true,
50+
backupDir: COPY,
51+
warnings: ['8 shipped permission set row(s) …'],
52+
lines: [`Upgraded the development database to the v18 organization ownership. The database as it was before the upgrade is copied to ${COPY}, beside the ceremony's log. The plan and the report are there too.`],
53+
};
54+
55+
describe('#24833 os dev — the line before the in-place upgrade of a refused development database', () => {
56+
it('names no origin for the database: no "v17", no 17.x, no 18.0 pre-release', async () => {
57+
const { lines } = await drive(upgraded);
58+
const notice = lines[0]!;
59+
expect(notice, notice).not.toMatch(/v17/i);
60+
expect(notice, notice).not.toMatch(/\b17\.x\b|\b18\.0\b|pre-?release|next\.\*|created/i);
61+
});
62+
63+
it('points at the refusal above, and says os dev upgrades the database in place keeping a copy first', async () => {
64+
const { lines, printedBeforeUpgrade } = await drive(upgraded);
65+
const notice = lines[0]!;
66+
expect(notice, notice).toMatch(/refus/i);
67+
expect(notice, notice).toMatch(/above/i);
68+
expect(notice, notice).toMatch(/os dev/);
69+
expect(notice, notice).toMatch(/in place/);
70+
expect(notice, notice).toMatch(/copy first/);
71+
// Printed BEFORE the copy and the ceremony run, which take seconds.
72+
expect(printedBeforeUpgrade).toBe(1);
73+
});
74+
75+
it('the copy\'s path stays on the verdict line, after the warnings, and the server boots again', async () => {
76+
const { lines, ok } = await drive(upgraded);
77+
expect(ok).toBe(true);
78+
expect(lines.slice(1)).toEqual([
79+
' ⚠ 8 shipped permission set row(s) …',
80+
` ✓ ${upgraded.lines[0]}`,
81+
]);
82+
expect(lines[2]).toContain(COPY);
83+
});
84+
85+
it('an upgrade that did not finish prints its refusal lines and boots nothing', async () => {
86+
const { lines, ok } = await drive({
87+
ok: false,
88+
backupDir: COPY,
89+
warnings: [],
90+
lines: ['Refused (sys_mystery): …', `The upgrade plan was refused, and nothing was changed. The copy is at ${COPY}.`],
91+
});
92+
expect(ok).toBe(false);
93+
expect(lines.slice(1).every((l) => l.startsWith(' ✗ '))).toBe(true);
94+
expect(lines).toHaveLength(3);
95+
});
96+
});

‎packages/cli/src/commands/dev.ts‎

Lines changed: 65 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,11 @@ import {
1616
assessArtifactStaleness,
1717
formatMtimeGap,
1818
} from '../utils/dev-restart.js';
19-
import { isOrganizationOwnershipRequiredMessage, upgradeDevelopmentDatabase } from '../utils/dev-organization-ownership-upgrade.js';
19+
import {
20+
isOrganizationOwnershipRequiredMessage,
21+
upgradeDevelopmentDatabase,
22+
type DevelopmentUpgradeResult,
23+
} from '../utils/dev-organization-ownership-upgrade.js';
2024
import { childEnvWithResolvedArtifact } from '../utils/internal-artifact-channel.js';
2125
import { makeDirectorySync } from '../utils/make-directory.js';
2226
// THE artifact precedence, written once (#21501) — shared with `start`, and
@@ -247,6 +251,42 @@ export function devWatchActive(opts: { watch: boolean; artifact?: string; config
247251
return opts.watch && !opts.artifact && opts.configExists;
248252
}
249253

254+
/**
255+
* What `os dev` prints when the organization-ownership boot guard refused its
256+
* `serve` child, and the upgrade it then runs (ADR-0131 D10, development
257+
* exception; `utils/dev-organization-ownership-upgrade.ts`). The coordinator's
258+
* `recover` calls this once per session; `upgrade` copies the development
259+
* database, runs the ceremony on it and answers its verdict.
260+
*
261+
* ⛔ The first line names no origin for the database. Every refusal the guard
262+
* throws reaches this path — a marker absent, unverified, unreadable or from an
263+
* older shape generation, a shape read that failed, a birth write that failed —
264+
* and only the absent marker means "17.x or an 18.0 pre-release created it". The
265+
* guard's own refusal, printed just above this line by the failed boot, already
266+
* names what it found, so this line points at it rather than carrying a second
267+
* copy of that wording, which would be wrong for the other five.
268+
*
269+
* The copy's path is printed by the verdict lines below, from the upgrade's own
270+
* result.
271+
*
272+
* @returns whether the upgrade finished and the server may boot again.
273+
*/
274+
export async function upgradeRefusedDevelopmentDatabase(io: {
275+
/** Writes one line for the developer. */
276+
print: (line: string) => void;
277+
/** Copies the development database, then plans and applies the ceremony on it. */
278+
upgrade: () => Promise<DevelopmentUpgradeResult>;
279+
}): Promise<boolean> {
280+
io.print(chalk.yellow(
281+
' ⟳ The organization-ownership boot guard refused this development database (the refusal above), so '
282+
+ 'os dev upgrades it in place, keeping a copy first…',
283+
));
284+
const result = await io.upgrade();
285+
for (const warning of result.warnings) io.print(chalk.yellow(` ⚠ ${warning}`));
286+
for (const line of result.lines) io.print(result.ok ? chalk.green(` ✓ ${line}`) : chalk.red(` ✗ ${line}`));
287+
return result.ok;
288+
}
289+
250290
export default class Dev extends Command {
251291
static override description =
252292
'Start development mode — watch sources, rebuild the artifact, and restart the server on change';
@@ -725,11 +765,12 @@ export default class Dev extends Command {
725765
// load is the one path that applies the whole artifact, so the
726766
// coordinator restarts the serve child after each successful rebuild
727767
// (nodemon-style, default-on; opt out with --no-restart).
728-
// [ADR-0131 D10, development exception] A serve child refused for the
729-
// v17 organization-ownership shape says so before it exits; the
768+
// [ADR-0131 D10, development exception] A serve child the
769+
// organization-ownership boot guard refused says so before it exits; the
730770
// coordinator then asks `recover` below, which copies the development
731771
// database, runs the ceremony on it and boots the server again — once
732-
// per session (`utils/dev-organization-ownership-upgrade.ts`).
772+
// per session (`utils/dev-organization-ownership-upgrade.ts`,
773+
// `upgradeRefusedDevelopmentDatabase` above for what it prints).
733774
let ownershipUpgrade: 'none' | 'pending' | 'attempted' = 'none';
734775
let upgradedBoot = false;
735776
const spawnServeChild = (info: { restartIndex: number }) => {
@@ -818,28 +859,27 @@ export default class Dev extends Command {
818859
recover: async () => {
819860
if (ownershipUpgrade !== 'pending') return false;
820861
ownershipUpgrade = 'attempted';
821-
console.log(chalk.yellow(
822-
' ⟳ This development database is still in the v17 organization-ownership shape — upgrading it in place '
823-
+ '(a copy is kept first)…',
824-
));
825-
const { resolveStandaloneDatabase } = await import('@objectstack/runtime');
826-
let sqliteFile: string | null = null;
827-
try {
828-
sqliteFile = resolveStandaloneDatabase({ projectRoot: process.cwd(), databaseUrl: effectiveDb }).sqliteFile;
829-
} catch {
830-
sqliteFile = null;
831-
}
832-
const result = upgradeDevelopmentDatabase({
833-
binPath,
834-
databaseUrl: effectiveDb,
835-
sqliteFile,
836-
env: localEnv,
837-
cwd: process.cwd(),
862+
const ok = await upgradeRefusedDevelopmentDatabase({
863+
print: (line) => console.log(line),
864+
upgrade: async () => {
865+
const { resolveStandaloneDatabase } = await import('@objectstack/runtime');
866+
let sqliteFile: string | null = null;
867+
try {
868+
sqliteFile = resolveStandaloneDatabase({ projectRoot: process.cwd(), databaseUrl: effectiveDb }).sqliteFile;
869+
} catch {
870+
sqliteFile = null;
871+
}
872+
return upgradeDevelopmentDatabase({
873+
binPath,
874+
databaseUrl: effectiveDb,
875+
sqliteFile,
876+
env: localEnv,
877+
cwd: process.cwd(),
878+
});
879+
},
838880
});
839-
for (const warning of result.warnings) console.log(chalk.yellow(` ⚠ ${warning}`));
840-
for (const line of result.lines) console.log(result.ok ? chalk.green(` ✓ ${line}`) : chalk.red(` ✗ ${line}`));
841-
if (result.ok) upgradedBoot = true;
842-
return result.ok;
881+
if (ok) upgradedBoot = true;
882+
return ok;
843883
},
844884
log: (line) => {
845885
const trimmed = line.trimStart();

0 commit comments

Comments
 (0)