Skip to content

Commit fa06169

Browse files
fix(cli): os migrate apply names the birth marker, not a ceremony, when it lets an organization_id drop through (#25500)
<!-- claude-projects-attribution: {"github_login":"os-sales"} --> _Requested by **Sales** · [project thread](https://claude.ai/code/project/chan_01D5bWFY3rFzgJcytD5gD4Uu?thread=cmsg_01D5bWFY3rFzgJcytD5gD4UuDWJsBGQpadCH8JR2xZQ5re)_ Fixes #25466 Clause-②: no Metadata-protocol: none ## What changed `os migrate apply --allow-destructive`, when the organization-ownership drop gate lets an `organization_id` drop through, now says which row let it through: - **Birth marker** (`details.birth`, written by the boot guard on a datastore created from empty): `organization_id on acme_note: this database was created from empty (CREATED_AT, platform VERSION) and carries the organization-ownership birth marker, so it holds no legacy organization rows and the ceremony is not needed for it — the drop may proceed.` - **Ceremony run** (`details: { checkpoint }`): the same sentence as before, `the organization-ownership ceremony verified (VERIFIED_AT) — the drop may proceed.` The distinction is read from the `flag` the gate's one marker read (`gateReads.organizationOwnershipMarker`) already returned: no second database read. The new pure helper `organizationOwnershipBirthOf(flag)` in `packages/cli/src/utils/organization-ownership-drop-gate.ts` parses `details.birth` with the spec's existing `OrganizationOwnershipBirthMarkerSchema` (`@objectstack/spec/system`, already exported). No new export from `packages/runtime` or `packages/spec`. Unchanged: the `--json` document (it prints no such line), exit codes, the accept set (`isDataMigrationFlagVerified` still decides), and the refusal branch. `plan.ts` prints no such line on a verified database, so it is untouched. ## Discriminator: where it comes from The runtime boot guard does not tell the two apart. It decides on the shape generation alone (`organizationOwnershipShapeGenerationOf`, which reads `details.birth ?? details.checkpoint`), and no exported reader answers "birth or ceremony". The spec's docblock on `ORGANIZATION_OWNERSHIP_MIGRATION_ID` defines the birth marker as the row whose `details.birth` is an `OrganizationOwnershipBirthMarker`. The ceremony's ledger write (`organization-ownership.ts`, `details: { checkpoint }`) replaces the whole field, so a row carries one or the other. The helper reads that declared key with the declared schema. If `details.birth` is present but does not parse, the line still takes the birth arm and names the row's `verified_at`, which the guard writes from the same clock. ## Measured reproduction (real CLI, SQLite, harness copied from the earlier tenant:false run) Database created by `os migrate apply --yes` on an empty file. Its `sys_migration` row is `{"birth":{"shapeGeneration":1,"platformVersion":"18.0.0","createdAt":"…"}}`. Then `acme_note` declares `systemFields: { tenant: false }` and `os migrate apply --allow-destructive --yes` runs: | leg | tree | exit | success line | |---|---|---|---| | before | `apply.ts` from base `ab510f42e7`, cli rebuilt | 0 | `✓ organization_id on acme_note: the organization-ownership ceremony verified (2026-10-23T15:32:50.299Z) — the drop may proceed.` | | after | `b58c1ed845` | 0 | `✓ organization_id on acme_note: this database was created from empty (2026-10-23T15:32:05.034Z, platform 18.0.0) and carries the organization-ownership birth marker, so it holds no legacy organization rows and the ceremony is not needed for it — the drop may proceed.` | In both legs the column is dropped (`pragma_table_info` has no `organization_id` afterwards) and `Applied 1 change(s).` prints. With `--json`, both legs exit 0 with the same key set. After normalising timestamps and the database file name, the documents differ only in `duration` (959 vs 907 ms). Neither prints the line. The before leg was restored with `git checkout HEAD -- apply.ts`: `git diff HEAD` was empty, the hash matched the HEAD blob, and cli was rebuilt (the marker string is back in `dist/`). ## Tests `packages/cli/src/commands/migrate/apply.organization-ownership-refusal.test.ts` runs the real `MigrateApply.run` path, with the boot and the marker read replaced. It adds three cases: - a birth-marker row: exit 0, the drop is applied, and the line names "created from empty", the creation time and "birth marker", and does not contain "ceremony verified"; - a ceremony-run row (`details.checkpoint`): exit 0, and the line keeps `the organization-ownership ceremony verified (VERIFIED_AT)`; - `--json` for both rows: exit 0, no success line, and the two documents are equal. ## Local gates (head `b58c1ed845`) - `pnpm --workspace-concurrency=2 --filter '@objectstack/cli...' build`: VERDICT command-exit 0 - `pnpm --filter @objectstack/cli exec vitest run --project unit --maxWorkers=2`: VERDICT command-exit 0, Test Files 326 passed (326), Tests 4808 passed (4808) - `pnpm --filter @objectstack/cli typecheck`: VERDICT command-exit 0 - `pnpm lint`: VERDICT command-exit 0 - `dispatch-gates --commands`: 67 derived. 65 ran with exit 0. 2 are NOT MEASURED (exit 3, PREREQUISITE NOT MET): `check:dual-build-cjs-loads` and `check:i18n-coverage`, because both need a whole-repo `pnpm build` and only the cli closure was built here. CI runs them. - `--ran` reconciliation: 67 derived, 65 run, 2 NOT-MEASURED (derived from recorded exit 3), 0 UNRUN. - The cli integration tier is left to CI: the diff touches no integration-tier file and no spawn entry. ## Acceptance notes - The ceremony arm is pinned at the unit layer only. Running a real ceremony on SQLite to read its line was not part of this S card. - The `sys_comment.reactions` sibling success line was checked and left alone. Its marker (`sys-comment-reactions-to-records`) is written only by `os migrate comment-reactions --apply` (`comment-reactions-conversion.ts`) and is not creation-attested (`CREATION_ATTESTED_MIGRATION_IDS` holds only file-references and value-shapes), so a verified row there always comes from a real run. --- 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_01CxBKycaiUpA1yGx1XyoGqX --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 5188e7f commit fa06169

4 files changed

Lines changed: 132 additions & 4 deletions

File tree

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
---
2+
'@objectstack/cli': patch
3+
---
4+
5+
fix: `os migrate apply --allow-destructive` no longer says the organization-ownership ceremony verified on a database that was created from empty
6+
7+
Clause-②: no
8+
Metadata-protocol: none
9+
10+
On a database an 18.x release created from empty, the ceremony's `sys_migration` row is the birth marker the boot guard writes (`details.birth`): it records that no ceremony was owed, not that one ran. When such a database dropped an `organization_id` column, the success line still read "the organization-ownership ceremony verified (TIME)", with TIME being the database's creation. The line now says which row let the drop through: for a birth marker, that the database was created from empty (its creation time and the platform version that created it) and the ceremony is not needed for it; for a ceremony run, the same sentence as before. The line is read from the marker the gate already reads, with no second database read. `--json` output, exit codes and which drops are allowed are unchanged.

‎packages/cli/src/commands/migrate/apply.organization-ownership-refusal.test.ts‎

Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -182,6 +182,80 @@ describe('os migrate apply --allow-destructive: the organization-ownership refus
182182
}
183183
});
184184

185+
/**
186+
* The two rows that let the drop through, as each writer stores it: the boot
187+
* guard's birth marker (`details.birth`, on a datastore created from empty)
188+
* and the ceremony's verified run (`details: { checkpoint }`).
189+
*/
190+
const BIRTH_AT = '2026-10-23T15:07:36.127Z';
191+
const BIRTH_MARKER = {
192+
id: 'adr-0131-organization-ownership', last_run_at: BIRTH_AT, verified_at: BIRTH_AT, applied_at: null, blocking: 0, advisory: 0,
193+
details: JSON.stringify({ birth: { shapeGeneration: 1, platformVersion: '18.0.0', createdAt: BIRTH_AT } }),
194+
};
195+
const CEREMONY_RUN = {
196+
...VERIFIED,
197+
details: JSON.stringify({ checkpoint: { ceremony: 'ADR-0131 D10', inventoryDigest: 'd1', shapeGeneration: 1, completed: {} } }),
198+
};
199+
200+
/** The success line the human face printed for this gate. */
201+
function organizationSuccessLine(): string | undefined {
202+
return log.mock.calls.map((c: unknown[]) => c.map(String).join(' '))
203+
.find((line: string) => line.includes('organization_id on sys_platform_setting:'));
204+
}
205+
206+
describe('os migrate apply --allow-destructive: the success line names the row that let the drop through', () => {
207+
it('a birth marker: the database was created from empty, at its creation time, and no ceremony is claimed', async () => {
208+
bootWithOrganizationDrop();
209+
mocks.readOrganizationOwnershipMarker.mockResolvedValue({ verified: true, flag: BIRTH_MARKER });
210+
211+
const { exit } = await runApply(ARGV);
212+
213+
expect(exit).toBe(0);
214+
expect(applyMigrationEntries).toHaveBeenCalledTimes(1);
215+
const line = organizationSuccessLine();
216+
expect(line, 'the gate printed its success line').toBeDefined();
217+
expect(line).toContain('created from empty');
218+
expect(line).toContain(BIRTH_AT);
219+
expect(line).toContain('birth marker');
220+
expect(line).not.toContain('ceremony verified');
221+
}, RUN_TIMEOUT);
222+
223+
it('a ceremony run: the line keeps saying the ceremony verified, at its verified time', async () => {
224+
bootWithOrganizationDrop();
225+
mocks.readOrganizationOwnershipMarker.mockResolvedValue({ verified: true, flag: CEREMONY_RUN });
226+
227+
const { exit } = await runApply(ARGV);
228+
229+
expect(exit).toBe(0);
230+
expect(applyMigrationEntries).toHaveBeenCalledTimes(1);
231+
const line = organizationSuccessLine();
232+
expect(line).toContain(`the organization-ownership ceremony verified (${CEREMONY_RUN.verified_at})`);
233+
expect(line).not.toContain('birth marker');
234+
}, RUN_TIMEOUT);
235+
236+
it('a birth marker under --json: the document is the one a ceremony run gets, and no success line is printed', async () => {
237+
const payloads = [];
238+
for (const flag of [BIRTH_MARKER, CEREMONY_RUN]) {
239+
stdout.mockClear();
240+
log.mockClear();
241+
bootWithOrganizationDrop();
242+
mocks.readOrganizationOwnershipMarker.mockResolvedValue({ verified: true, flag });
243+
const { exit, payload } = await runApply([...ARGV, '--json']);
244+
expect(exit).toBe(0);
245+
expect(organizationSuccessLine()).toBeUndefined();
246+
payloads.push(payload);
247+
}
248+
// `duration` is each run's own wall time, so it is checked for shape and
249+
// normalised; every other field must be equal.
250+
for (const payload of payloads) {
251+
expect(typeof payload.duration).toBe('number');
252+
expect(payload.duration).toBeGreaterThanOrEqual(0);
253+
}
254+
const [birth, ceremony] = payloads.map((payload) => ({ ...payload, duration: 0 }));
255+
expect(birth).toEqual(ceremony);
256+
}, RUN_TIMEOUT);
257+
});
258+
185259
describe('control', () => {
186260
it('a verified marker lets the drop through and exits 0, so the harness can tell a refusal from a run', async () => {
187261
bootWithOrganizationDrop();

‎packages/cli/src/commands/migrate/apply.ts‎

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,7 @@ import {
3636
} from '../../utils/schema-migration-plugins.js';
3737
import { OCCUPANCY_HINT, probeMigrationTarget } from '../../utils/migrate-occupancy-gate.js';
3838
import { describeOccupancy } from '../../utils/sqlite-occupancy.js';
39-
import { ceremonyOrganizationColumnDrops } from '../../utils/organization-ownership-drop-gate.js';
39+
import { ceremonyOrganizationColumnDrops, organizationOwnershipBirthOf } from '../../utils/organization-ownership-drop-gate.js';
4040
import {
4141
DropGateReads,
4242
describeDriftEntries,
@@ -627,9 +627,17 @@ export default class MigrateApply extends Command {
627627
return;
628628
}
629629
if (!flags.json) {
630+
// Say which row let the drop through (#25466): a birth marker
631+
// records that no ceremony was owed, never that one ran.
632+
const birth = organizationOwnershipBirthOf(marker.flag);
630633
printSuccess(
631-
`organization_id on ${organizationDrops.join(', ')}: the organization-ownership ceremony verified `
632-
+ `(${marker.flag?.verified_at}) — the drop may proceed.`,
634+
birth
635+
? `organization_id on ${organizationDrops.join(', ')}: this database was created from empty `
636+
+ `(${birth.createdAt}${birth.platformVersion ? `, platform ${birth.platformVersion}` : ''}) and carries the `
637+
+ 'organization-ownership birth marker, so it holds no legacy organization rows and the ceremony is not '
638+
+ 'needed for it — the drop may proceed.'
639+
: `organization_id on ${organizationDrops.join(', ')}: the organization-ownership ceremony verified `
640+
+ `(${marker.flag?.verified_at}) — the drop may proceed.`,
633641
);
634642
}
635643
}

‎packages/cli/src/utils/organization-ownership-drop-gate.ts‎

Lines changed: 37 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -38,7 +38,7 @@
3838
*/
3939

4040
import type { MigrationFlagEngine } from '@objectstack/platform-objects/system';
41-
import type { DataMigrationFlag } from '@objectstack/spec/system';
41+
import { OrganizationOwnershipBirthMarkerSchema, type DataMigrationFlag } from '@objectstack/spec/system';
4242

4343
/** The column the ceremony owns the drop of. */
4444
export const ORGANIZATION_COLUMN = 'organization_id';
@@ -67,3 +67,39 @@ export async function readOrganizationOwnershipMarker(
6767
const flag = await readDataMigrationFlag(engine, ORGANIZATION_OWNERSHIP_MIGRATION_ID);
6868
return { verified: isDataMigrationFlagVerified(flag), flag };
6969
}
70+
71+
/**
72+
* Which row let the drop through, for the success line `os migrate apply`
73+
* prints: the BIRTH MARKER or a ceremony run. Both are the ceremony's
74+
* verified `sys_migration` row, and the gate treats them alike; only the text
75+
* differs. The boot guard writes the birth marker on a datastore it observed
76+
* being created from empty, with `details.birth`
77+
* (`OrganizationOwnershipBirthMarkerSchema`, `@objectstack/spec/system`); the
78+
* ceremony writes `details: { checkpoint }`, replacing the whole field, so a
79+
* row carries one or the other. It claims no ceremony ran, so the line must
80+
* not say one verified.
81+
*
82+
* Pure: it reads the `flag` the gate's one marker read returned, never the
83+
* database again. Answers `null` for a ceremony run, a row without details,
84+
* or no row; `createdAt` falls back to the row's `verified_at` (the guard
85+
* writes both from one clock) when the birth object does not parse.
86+
*/
87+
export function organizationOwnershipBirthOf(
88+
flag: Pick<DataMigrationFlag, 'details' | 'verified_at'> | null,
89+
): { createdAt: string | null; platformVersion: string | null } | null {
90+
if (!flag) return null;
91+
let details: unknown = flag.details;
92+
if (typeof details === 'string') {
93+
try {
94+
details = JSON.parse(details);
95+
} catch {
96+
return null;
97+
}
98+
}
99+
const birth = (details as { birth?: unknown } | null)?.birth;
100+
if (birth === null || typeof birth !== 'object') return null;
101+
const parsed = OrganizationOwnershipBirthMarkerSchema.safeParse(birth);
102+
return parsed.success
103+
? { createdAt: parsed.data.createdAt, platformVersion: parsed.data.platformVersion }
104+
: { createdAt: flag.verified_at ?? null, platformVersion: null };
105+
}

0 commit comments

Comments
 (0)