Skip to content

fix(cli): os migrate apply on an empty database writes the organization-ownership birth marker, so the first os serve starts - #24875

Merged
objectstack-fleet[bot] merged 5 commits into
mainfrom
claude/issue-24827-migrate-apply-birth-marker
Oct 20, 2026
Merged

objectstack-fleet[bot] merged 5 commits into
mainfrom
claude/issue-24827-migrate-apply-birth-marker

Conversation

@objectstack-fleet

Copy link
Copy Markdown
Contributor

Fixes #24827
Clause-②: yes

Basis for the Clause-② line (the claim's): os serve admits a database that os migrate apply created from empty, which it refuses today. That widens the boot guard's admission set by one writer. The guard's rule and its refusal are unchanged, and no published schema or export moves. The review of record judges that no database the guard should refuse can gain a marker.

What changed

PR #24681 states the contract: "Every CLI boot writes the birth half too. … Without it, an install whose first command is os migrate apply … would be refused by its first os serve." For os migrate apply it did not hold. Its boot defers schema DDL, so each driver stores and skips every object before counting it. The birth plugin in its boot saw nothing created and wrote nothing. The tables were created later, at the confirmed flush, and nothing recorded the birth after that.

  • packages/cli/src/utils/schema-migrate.ts: SchemaStack.flushSchemaDdl performs the deferred DDL, then runs the runtime's own birth half, recordOrganizationOwnershipBirth (already exported by @objectstack/runtime). The new recordBirthOfFlushedStore resolves the engine and runs it through the kernel logger, which DeferSchemaDdlPlugin now keeps from its init(). A failed write throws: the error names the row, the cause and the ceremony, so os migrate apply exits 1 and never reports success. It runs only when the boot armed a deferral, because only a deferred boot can have created the store at its flush. This is the one shared seam. os migrate apply is its only caller (see "Other deferred boots" below).
  • The decision is not new. recordOrganizationOwnershipBirth decides exactly as it does at boot. The ledger's store must read as created from empty on its own driver's counts (existing is 0 and created is above 0), with no marker and no table in the v17 shape. The flush re-enters the driver's initObjects with every deferred object. Its hasTable probe counts each declared table as created or found, so after the flush the counts describe this run's own observation of the database.
  • ⛔ Not judged from the work the flush returns. That list names only tables the flush created. A table already in sync produces no entry. So "every entry is create_table" also describes a populated database that merely gained a table, and that is the database that must never receive a marker (measured below).
  • packages/runtime/src/organization-ownership-boot-guard.ts: docblock only. The birth plugin's note now says that the one deferred boot that creates tables records the birth after its flush, on the same counts.
  • packages/cli/src/commands/migrate/apply.ts: one comment at the flush.
  • Changeset: @objectstack/cli minor (Clause-②: yes). The runtime change is a comment, so it has no changeset.

Contract

The boot guard's verdict on each database state, before (main at 41646a578) and after (this head). "Apply" is os migrate apply --yes; "serve" is os serve over the same file.

Database before the apply What the apply writes, main os serve, main What the apply writes, this PR os serve, this PR Evidence
Fresh (the file does not exist), then apply the tables, no marker refused: "This database carries no organization-ownership marker: 17.x or an 18.0 pre-release (18.0.0-next.*) created it, …" the tables, then one verified birth row (details.birth.shapeGeneration 1) starts repro steps 1-3, both trees; pin (a), red on main
Never touched (no apply; the first command is os serve) (nothing) starts: its own boot writes the birth marker (nothing) starts (unchanged) repro step 4, both trees
17.x shape (no marker, a physical organization_id the object no longer declares) no marker refused, naming the ceremony and the table no marker, though the flush creates a table in it refused, naming the ceremony and the table (unchanged) pin (b) 17.x, green on both trees
18.0 pre-release shape (no marker, no retired column) no marker refused, naming the ceremony no marker, though the flush creates a table in it and creates nothing else refused, naming the ceremony (unchanged) pin (b) 18.0, green on both trees, red under the ablation; e2e over a real unmarked database below
Already born (a verified birth or ceremony marker) the row unchanged starts the row unchanged (byte-equal) starts (unchanged) pin (c, d)
Apply run twice on a fresh file no marker after either run refused one row after the first run, unchanged by the second starts pin (c, d), red on main; the second apply finds the database in sync and returns before any flush

Paths through the same seam, by construction (not separately pinned):

  • Confirmation declined, or --json without --yes: the command returns before the flush, so nothing is created and nothing is written. The first os serve creates the store and writes the birth marker, as before.
  • A Turso remote (libsql://) primary: the apply skips schema sync there (deferredDdlUnsupported: 'skip-schema-sync'). The deferral exempts the primary and the flush creates nothing on it, so its counts show nothing created and no marker is written. The first serving boot creates the tables, and the remote face's own counts decide its birth. The rule PR feat(cli,runtime)!: the upgrade ceremony opens — v17 and 18.0 databases take it, and a server serves only a database it created or one the ceremony certified (18.x steps 5 and 6) #24681's review settled in its B1 finding (comment 6279650792) is kept as it is.
  • A telemetry sibling or a declared datasource: judged on the ledger store's driver alone, as at boot.
  • The birth write fails: flushSchemaDdl throws, naming the row, the cause and the ceremony's two steps, and the apply exits 1. Pinned by schema-migrate.birth-after-flush.test.ts.

Zone 2 measurements (the PM's mechanism assumptions)

  1. The card's chain holds. Repro on main 41646a578, over a minimal project (one object, dist/objectstack.json; not examples/app-showcase):

    • Step 1: os migrate apply --yes --database-url file:RUN/mig.db exited 0 and printed Created/extended 74 table(s).
    • Step 2: sys_migration had 0 rows.
    • Step 3: os serve exited 1 with the refusal quoted in the table.
    • Step 4 (control): os serve over a never-touched file listened, and its birth row was written.
    • The ledger store's counts on the deferred boot were {"created":0,"existing":0}.
    • On this head, the same four steps give: exit 0; 1 verified birth row; the apply log line [organization-ownership] a datastore created from empty: birth marker written (shape generation 1, platform 18.0.0-next.5); os serve listening; the control unchanged.
  2. How "empty before" is known on a deferred boot. The ledger store's driver counts, read after the flush:

    file counts before the flush counts after the flush the flush's returned work birth rows
    empty {created: 0, existing: 0} {created: 36, existing: 0} 37 create_table 1
    populated, unmarked (the main-created file, one table dropped) {created: 0, existing: 0} {created: 1, existing: 35} 1 create_table 0

    The returned work reads the same in both rows ("only create_table"). The counts do not. So the reading is the counts, which name every table the flush found.

    • End to end over that populated, unmarked file: os migrate apply created repro_widget and wrote no marker. os serve then exited 1, refusing it as a database "17.x or an 18.0 pre-release" created.
  3. Other deferred boots. flushSchemaDdl has one caller, os migrate apply (git grep over packages/** sources). The other deferSchemaDdl callers are the no-write modes of 18 commands (readOnlyProbe, which never create a table) and the ceremony's two boots (skip-schema-sync, never flushed). None of them creates a table on a deferred boot, so none has this defect. The write modes (--apply, --run, --delete) boot without a deferral, and their own birth plugin decides at boot. Measured once: os migrate meta --stored --apply on an empty file wrote the birth row itself, and os serve then started. The fix is in the shared seam (flushSchemaDdl), so a future caller of the flush gets the birth half too.

  4. Drivers. The marker goes through the engine into the ledger the guard reads (engine.insert('sys_migration', …), the guard's own writer). The Turso remote path is covered under Contract above. Not measured here against a live libsql:// store.

Tests

Run on this branch, from the merged head (0be22e895, main abd5acdaf merged in), then again on the final head 51849a38a where noted. Exit codes were read from each run's own verdict line, never through a pipe.

  • New pins, apply.organization-ownership-birth.integration.test.ts: the real command, a real SQLite file, and the guard's verdict from assertOrganizationOwnershipMigrated on a boot that creates nothing. 4 passed.
    • (a) An empty file gets one verified birth row at the current generation, and the guard admits it.
    • (b) An 18.0 pre-release-shaped database and a 17.x-shaped one: the flush creates a table in each and creates nothing else, neither gets a marker, and the guard still refuses each, naming the ceremony (and, for 17.x, the table).
    • (c, d) A second apply over the database it created, with a table to create: one row, unchanged.
    • (b)'s refusal is the control that the verdict is not inert. A boot without the served platform registers no sys_migration, the guard is inert there and would admit every file, so the verdict boot composes the platform as the apply does.
  • Red on main: the pre-fix schema-migrate.ts restored from 41646a578 (blob f97922f7), then the test file run. (a) and (c, d) went red: expected [] to have a length of 1. Both (b) cases stayed green. The file was restored to HEAD (blob a951e927), and git diff HEAD was empty.
  • Ablation, emptiness check (scripts/ablation-replace.mjs --delete): if (!ledgerStoreCreatedFromEmpty(engine)) return null; was deleted from recordOrganizationOwnershipBirth, and the runtime was rebuilt.
    • ablation-dist-preflight --absent passed.
    • (b) 18.0 pre-release went red (expected [ { …(14) } ] to deeply equal []: a false birth row). The other 3 stayed green. The 17.x case held because the retired-column check also stops a birth there.
    • Restored to HEAD (blob cf2e9113, git diff HEAD empty), the runtime rebuilt, and the preflight found the anchor back in dist.
  • Unit pin, schema-migrate.birth-after-flush.test.ts: the failed write fails the flush, naming the row, the cause and the ceremony's two steps; with no engine, nothing is asked. 3 passed.
    • Ablation: the rethrow was replaced with return, and 1 went red. Restored to the HEAD blob 89c61790, git diff HEAD empty.
  • Fixture touched: apply-organization-ownership-gate.integration.test.ts builds its v17 stand-in with os migrate apply on an empty file. That apply now writes the birth marker, which reads as verified to the drop gate. The stand-in deletes the marker, as organization-ownership-platform-global-column-drop.integration.test.ts already does for its served boot.
  • @objectstack/cli unit project, in full: 313 files, 4,635 passed.
  • @objectstack/cli integration project, filtered to migrate and organization-ownership: 61 files (60 passed, 1 skipped), 579 passed, 9 skipped.
  • After the merge: the new pins, the drop-gate suite, sqlite-wasm.integration, schema-migrate.deferred-ddl.integration and the platform-global column-drop suite: 5 files, 24 passed.
  • Runtime guard: organization-ownership-boot-guard.test.ts and .kernel.test.ts, 2 files, 32 passed (before and after the merge).
  • On 51849a38a: the unit pin and test/vitest-tiers-partition.test.ts, 2 files, 25 passed.
  • typecheck: @objectstack/cli (with check:test-typecheck) and @objectstack/runtime, both exit 0. @objectstack/cli was re-run on 51849a38a, exit 0. The new test files are in the tsconfig.json program (--listFilesOnly: 2 of 2).

Gates

  • node scripts/pm/dispatch-gates.mjs --commands on 51849a38a derived 68 commands, and all 68 exited 0. --ran reconciliation: 68 derived, 68 run, 0 NOT-MEASURED, 0 UNRUN.
  • On 0be22e895, check:dual-build-cjs-loads and check:i18n-coverage had exited 3 (PREREQUISITE NOT MET: no dist for packages outside the CLI closure). Both exited 0 on the final head.
  • pnpm lint (eslint . --no-inline-config): exit 0 at 51849a38a.
  • Not measured locally, and left to CI on this PR: the four type-check lanes; Test Core / Build Core; the full @objectstack/runtime suite (its change is a docblock); the CLI integration tier outside the migrate / organization-ownership filter.

Acceptance notes

  • Files outside the claimed surface:
    • apply-organization-ownership-gate.integration.test.ts (its fixture, above).
    • The two new test files.
  • Not touched (the hold-offs): the guard's refusal rule and its text; the ceremony; os dev's recover branch and its "v17 shape" text (os dev's in-place upgrade says every refused database is "still in the v17 organization-ownership shape", after an ERROR-level stack dump of the boot refusal — an 18.0 pre-release database (and a database HEAD itself created) is told it is v17 #24833's, which can now follow this card); the [schema-drift] lines; packages/drivers/**; packages/spec/**; content/docs/releases/.
  • content/docs/deployment/cli.mdx is not edited. It says "the first boot that creates a datastore from empty writes a birth marker", which is now true of os migrate apply too. A line naming the apply would be a docs nicety, not a correction.
  • The repro used a one-object project, not examples/app-showcase. Same mechanism and the same refusal text; 74 tables against the card's 71.
  • Interrupted runs. If a confirmed apply fails partway through its flush, the next apply finds tables and writes no marker. The serving boot already behaves this way after a crash mid-sync. It fails closed (the database is refused and the ceremony certifies it), never open.

Generated by Claude Code

…rker after its flush

A deferred boot stores and skips every object before its driver counts it,
so the birth plugin of `os migrate apply`'s boot saw nothing created and
wrote no marker; the tables were created later, at the confirmed flush, and
nothing recorded the birth after it. The first `os serve` then refused the
fresh install as a database an earlier release created.

`SchemaStack.flushSchemaDdl` now runs the runtime's own birth half
(`recordOrganizationOwnershipBirth`) once the deferred DDL is performed,
judged on the ledger store's driver counts, which name every table the flush
found as existing: a database that was not empty before the apply never
receives a marker. A failed write fails the command.

Claude-Session: https://claude.ai/code/session_01LmjU2x1faLZ39DVTFyVca6
Co-authored-by: Claude <noreply@anthropic.com>
… a database it did not create

Four cases over a real SQLite file through the real command: an empty file
is born and admitted; an 18.0 pre-release-shaped and a 17.x-shaped
database gain no marker though the flush creates a table in each, and the
guard still refuses them; a second apply leaves the one row unchanged.

The drop-gate suite builds its v17 stand-in with `os migrate apply` on an
empty file, which now writes the birth marker; the stand-in drops it, as
the platform-global column-drop suite already does for its served boot.

Claude-Session: https://claude.ai/code/session_01LmjU2x1faLZ39DVTFyVca6
Co-authored-by: Claude <noreply@anthropic.com>
…ommand

The real-database pins reach the decision; this unit pin reaches the one
path they cannot: the tables exist, the marker write fails, and the flush
hands the failure to its caller naming the row, the cause and the ceremony
that certifies such a database.

Claude-Session: https://claude.ai/code/session_01LmjU2x1faLZ39DVTFyVca6
Co-authored-by: Claude <noreply@anthropic.com>
@github-actions github-actions Bot added size/m documentation Improvements or additions to documentation tests tooling labels Oct 20, 2026
@github-actions

github-actions Bot commented Oct 20, 2026 •

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 2 package(s): @objectstack/cli, @objectstack/runtime, touching 7 documentable anchor(s). ⚠️ 1 changed file(s) yielded no anchor (packages/runtime/src/organization-ownership-boot-guard.ts), so the pages documenting them are NOT COVERED by this run — this is not a clean bill of health for those files.

9 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:

  • content/docs/ai/skills-reference.mdx (via os migrate apply (command, read off packages/cli/src/commands/migrate/apply.ts))
  • content/docs/data-modeling/drivers.mdx (via os migrate apply (command, read off packages/cli/src/commands/migrate/apply.ts))
  • content/docs/data-modeling/queries.mdx (via os migrate apply (command, read off packages/cli/src/commands/migrate/apply.ts))
  • content/docs/deployment/cli.mdx (via os migrate apply (command, read off packages/cli/src/commands/migrate/apply.ts))
  • content/docs/deployment/index.mdx (via os migrate apply (command, read off packages/cli/src/commands/migrate/apply.ts))
  • content/docs/deployment/self-hosting.mdx (via os migrate apply (command, read off packages/cli/src/commands/migrate/apply.ts))
  • content/docs/kernel/services-checklist.mdx (via os migrate apply (command, read off packages/cli/src/commands/migrate/apply.ts))
  • content/docs/protocol/kernel/lifecycle.mdx (via os migrate apply (command, read off packages/cli/src/commands/migrate/apply.ts))
  • content/docs/upgrading.mdx (via os migrate apply (command, read off packages/cli/src/commands/migrate/apply.ts))

⛔ 4 release-owned page(s) also name something this change touched. These are read-only:

  • content/docs/releases/v17/17-3.mdx (via os migrate apply (command, read off packages/cli/src/commands/migrate/apply.ts))
  • content/docs/releases/v17/17-5.mdx (via os migrate apply (command, read off packages/cli/src/commands/migrate/apply.ts))
  • content/docs/releases/v17/17-6.mdx (via os migrate apply (command, read off packages/cli/src/commands/migrate/apply.ts))
  • content/docs/releases/v17/17-7.mdx (via os migrate apply (command, read off packages/cli/src/commands/migrate/apply.ts))

content/docs/releases/ is RELEASE-OWNED (AGENTS.md "Documentation Guardrails"): release
notes are written centrally at release time, and a code PR that edits them is the exact PR
that guardrail exists to stop. They are still audited — read-only. If one of them is actually
wrong, file an issue or open a dedicated docs-only PR; do not edit it here.

What this run could not see
  • 1 changed file(s) yielded no anchor (packages/runtime/src/organization-ownership-boot-guard.ts) — pages documenting those are invisible to this run
  • 2 name(s) were too generic to anchor anything (single lowercase words)
  • the SDK route bridge reached 54 of 206 client-bound route-ledger rows — the other 152 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 152: 0 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 55 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 97 are undecided (no in-repo declaration, on a ledger that has other in-repo registrars — absence and an unreadable spelling are not distinguishable here). The rows themselves: node scripts/docs-audit/affected-docs.mjs --bridge-coverage
  • a page that states a rule by its inputs shares no identifier with the emitter that implements the rule, so an emitter-only diff cannot list it — not on this run and not on any run. Measured on fix(driver-sql): emit varchar(maxLength) for a text field a declared index keys on #11430: content/docs/protocol/objectql/types.mdx documents the text-family column mapping by the ObjectQL type names it maps FROM (text / textarea / html) while the diff changed createColumn; it went unlisted, and it was the page that diff falsified, in four places. No shared token exists to detect this on, so a rule your change carries has to be re-read by hand in the pages that restate it.
  • a key NAME is not a key, so the hand re-read the line above prescribes can land on the wrong schema. The same spelling is authorable on one governed type and a [REMOVED] tombstone on another for each of active, aria, joins, objects, template, tools and version (censused on [finding] tools is a key on BOTH AgentSchema (tombstoned, dead) and SkillSchema (live, cloud-attested), so a name-based search attributes skill examples to the agent key — it produced a false stop-the-line alarm on PR #19059 #19093 over the liveness ledger's governed types, top-level keys); nothing in a search result distinguishes the two, so a grep hit on a LIVE example reads as evidence about the DEAD key. Measured on fix(spec): the agent.tools liveness row says dead — it claimed live on a key the schema tombstoned #19059: content/docs/ai/agents.mdx was reported as contradicting the agent.tools tombstone over its tools: example at :161, which is inside the defineSkill({ block opened at :155 — the page was already correct. Settle ownership by PARSING the value against both schemas, never by the name: that literal PASSES SkillSchema, and as an AgentSchema it FAILS at tools with the tombstone prescription. ⛔ These names are not the whole class — a key retired through a .strict() guidance map leaves no tombstone in the walked shape and none of them here (tool.category, live as AIToolDefinition.category).

Coarse fallback — 45 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): node scripts/docs-audit/affected-docs.mjs --json b1676441ba59cc32682ffd41e63e74474ad7fa42 → packageMentionDocs.

Which tree this was computed on

This run read content/docs from 8c542a89bea8eb6ddab11b600a6f3f2f86111bd4 — the merge of head 0da73a49d58e7b7b2b7c3f5fb0c8a6f4db81b13d into base b1676441ba59cc32682ffd41e63e74474ad7fa42, which is what actions/checkout gives a pull_request run. Not the PR head.

A worktree cut from an older main holds a different content/docs, so re-deriving there can legitimately return a different list — that is a different tree, not a wrong row. To answer on the same tree:

# while this PR is open — GitHub drops the merge commit once it closes
git fetch origin 8c542a89bea8eb6ddab11b600a6f3f2f86111bd4 && git checkout 8c542a89bea8eb6ddab11b600a6f3f2f86111bd4
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin b1676441ba59cc32682ffd41e63e74474ad7fa42 0da73a49d58e7b7b2b7c3f5fb0c8a6f4db81b13d && git checkout -B drift-repro b1676441ba59cc32682ffd41e63e74474ad7fa42 && git merge --no-ff 0da73a49d58e7b7b2b7c3f5fb0c8a6f4db81b13d

node scripts/docs-audit/affected-docs.mjs --json b1676441ba59cc32682ffd41e63e74474ad7fa42

⚠️ That checkout carried uncommitted changes, so the commit above does not fully identify what was read.

Advisory only, and a precision-first one (#9192): a page is listed because it names a
symbol, wire route or SDK method this diff touched — not because it mentions a changed
package. Each row says which anchor put it there, so a wrong row is reportable rather than
merely annoying. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs b1676441ba59cc32682ffd41e63e74474ad7fa42 → pass the list as
args.docs, on the commit named under Which tree this was computed on.

…r, with no error channel

`BirthLogger` declared an optional `error` and no `warn`, a sink whose
contract permits silence (`check:optional-error-sink`). Nothing on this path
logs a failure: `recordBirthOfFlushedStore` throws it to its caller, and the
runtime's birth half reads `info` alone. The type is now that parameter,
`Parameters<typeof recordOrganizationOwnershipBirth>[1]`, so no error member
exists to leave silent.

Claude-Session: https://claude.ai/code/session_01LmjU2x1faLZ39DVTFyVca6
Co-authored-by: Claude <noreply@anthropic.com>
@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

Contract review

Served-tier: TIER_DEFAULT
Head-sha: 0da73a49d58e7b7b2b7c3f5fb0c8a6f4db81b13d
Local-runs: none

Read: card #24827 (body; claim 6288623633; os-dev-report 6290825368). PR #24681's body (merged 12d14765c), its FAIL record 6279650792 (B1: the Turso remote face's {0, 0} counts refused a fresh remote install) and its PASS record 6284464125 (the rule settled: the remote face counts created versus found from its own sqlite_master probe, dceaa5646; "cannot say" is still no). PR #24875's body (unchanged across the two heads), its file list (7 files, +412 / -5) and the net diff against merge-base abd5acdaf at the head 0da73a49d, verified as the branch head on the remote; the delta 51849a38a..0da73a49d read in full (one commit, one file, packages/cli/src/utils/schema-migrate.ts +7 / -3, ①8); the head's check-runs (40 runs, 40 names, one read), and the prior head's for the red it carried. Cited code read at the head: organization-ownership-boot-guard.ts 195-262 and 330-600; sql-driver.ts 5626-5629, 12470-12560, 13496-13501, 13698-13718; engine.ts 12774-12789, 21535-21540; schema-migrate.ts 400-660, 810-819, 1280-1296, 1384, 1502-1504, 1594-1619; apply.ts 196-232, 296, 607-636, 711-721; turso-driver.ts 1059-1085, 4136-4158, 4209-4211; remote-transport.ts 1049-1050, 2097, 2101, 2269-2270, 2290-2297; service-package/src/index.ts 392-432; scripts/check-optional-error-sink-contract.mjs 1-250, 369-383, 496-518, 580-590 and its baseline; lint.yml 2926-2929.

① Derived judgments

0. Gate verdicts on the head 0da73a49d. 27 success: Lint & Repo Gates, TypeScript Type Check, Build Core, Check Changeset, Check PR Size, Governed Surface Queue Guard, every Lint lane (ESLint; CI, release and engine gates; docs and code guards; repo and tooling gates; PM dispatch-gates; scoped: query-options erasure, slot lookup and corpus, tooling self-tests), every Type Check lane (workspace, consumer gates, source gates, debt ledger), Dogfood Regression Gate, the four claim guards, Auto Label, Check Documentation Links, Flag docs affected by code changes, filter. 6 in_progress, NOT MEASURED: Test Core 1/6 to 6/6. 7 skipped: the opt-in and main-only lanes (18.x upgrade harness, Packed-tarball smoke, Build Docs, Console Pin Gate, the Dogfood shards, Dogfood Verify CLI, Temporal Conformance). The prior head 51849a38a was red on Lint · docs and code guards through pnpm check:optional-error-sink (lint.yml 2926-2929; its job 118625689260 step 63): the sink type ①8 removes. That red is this head's green.

1. The one question — can a database that was NOT empty before os migrate apply gain the birth marker from it? No, on every case constructed. The mechanism as read:

  • The apply boots deferSchemaDdl: true, deferredDdlUnsupported: 'skip-schema-sync' (apply.ts 216-219). In bootSchemaStack, skipSchemaSync is true only for a Turso remote primary (transportRefusesDeferredDdl 810-819: driver turso and detectMode remote); defer is deferSchemaDdl and not skipSchemaSync (1286-1289). The deferral plugin exists on both postures (1384), exemptPrimary on skip-sync.
  • On the deferred boot SqlDriver.initObjects stores each object in deferredSchemaObjects and continues BEFORE the hasTable probe (12528-12531 against 12539-12547), so tablesCreatedHere and tablesFoundExisting stay empty and getSchemaSyncStats answers {0, 0} (13500-13501); the boot's birth plugin writes nothing. The two sets are instance fields written at 12546-12547 only, never cleared or reassigned anywhere in the file, so nothing ages or freshens them between boot and flush. No other hasTable call in the driver (7715, 11988, 13558, 14023, 14095) touches them.
  • At the flush, flushDeferredSchemaDdl (13698-13718) takes EVERY deferred object — every object the boot synced, not the subset whose table is missing — clears deferredDdl, and re-enters initObjects with all of them; the re-entry probes each table (12539): absent → tablesCreatedHere, present and not created by this driver → tablesFoundExisting. After the flush the counts are this run's own observation of every declared non-rotation table on that driver (the dev measured {created 1, existing 35} on a populated file that gained one table).
  • recordBirthOfFlushedStore (schema-migrate.ts 633-659) runs after every armed driver has flushed and after the package table's ensure() (1603-1611), and hands the engine to the runtime's own recordOrganizationOwnershipBirth (boot-guard.ts 419-434): ledgerStoreCreatedFromEmpty (477-485) reads the stats of the driver serving sys_migration (ledgerStore 462-469) and needs existing equal to 0 and created above 0; then readMarker must be absent (442-459); then findV17ShapeTables must be empty (238-262); only then writeBirthMarker (491-515). The engine fallback wasDatastoreCreatedFromEmpty (engine.ts 12774-12789) is reached only when the ledger's driver has no getSchemaSyncStats at all — a {0, 0} answer is an object, used as such, never a fallback.

The cases:

  • (a) Half-finished earlier apply, some declared tables present: the flush re-probes all; the present ones land in tablesFoundExisting; existing above 0; no marker. Serve then refuses and names the ceremony. Fails closed; the body's "Interrupted runs" note matches. All present and no drift: the in-sync early return (apply.ts 296) precedes any flush; no call, no marker.
  • (b) Declared tables under different storage names: the probe and the deferral key both use StorageNameMapping.resolveTableName (12522, 12529, 12539), the identity on the canonical name (a legacy ns__name prefix stripped; system-names.ts 287-290; Prime Directive Migrate from npm to pnpm #6, object name = table name). There is no second name to probe. A database holding tables only under names no object declares reads as empty on every declared table — and the serving boot's own initObjects would count the same and its guard write the same birth (551-570): equal to serve's verdict, not an admission serve would withhold. Every 17.x or 18.0 database holds the platform's sys_* tables under their declared names, the ledger sys_migration among them, so existing is above 0 there.
  • (c) Ledger table present but empty: sys_migration is a declared object; hasTable true; found; existing above 0; no marker. Serve: absent marker, existing above 0, refused. Fails closed.
  • (d) Multi-driver stack, ledger on a non-default driver: ledgerStore resolves the driver that serves sys_migration and reads that driver's counts alone. Every armed driver — the default, every driver.* service, every driver the engine holds or is handed through the registerDriver shadow (schema-migrate.ts 497-514, 541-560) — is flushed in the loop that precedes the birth call (1603-1605 before 1617). A telemetry sibling or a declared datasource neither ages a fresh primary nor freshens an old one: PR feat(cli,runtime)!: the upgrade ceremony opens — v17 and 18.0 databases take it, and a server serves only a database it created or one the ceremony certified (18.x steps 5 and 6) #24681's ruled rule ("judged on the driver of the store that holds sys_migration"), reproduced, not widened. A ledger on a non-SQL store: no stats, the fallback may read true, but readMarker is inert (no dialect, 446), not absent; return null, no write.
  • (e) Turso remote primary: skipSchemaSync true, defer false; the primary is exempt from arming (exemptPrimary 480-485, 499, 522) so it is never flushed; the package table is planned only under defer (1503) so ensure() never runs there; the engine's boot sync is skipped so no remote schema door runs — syncSchema and syncSchemasBatch are the only two places the transport counts (remote-transport.ts 2097, 2101, 2269-2270) and the remote applyMigrationEntries calls neither. TursoDriver.getSchemaSyncStats on the remote face answers the transport's {0, 0} (4209-4211), an object, so no fallback; created above 0 fails; no marker. The first serving boot's remote face counts and births, the rule PR feat(cli,runtime)!: the upgrade ceremony opens — v17 and 18.0 databases take it, and a server serves only a database it created or one the ceremony certified (18.x steps 5 and 6) #24681's PASS record settled; unchanged here, and the body says exactly this. Not measured against a live libsql:// store in this PR (the dev says so); the reading is from the code paths. Accept.
  • (f) Apply run twice: over a marked database with nothing to do, the early return at 296 precedes the flush; with a table to create it flushes, re-probes all (existing above 0) and readMarker is verified; return null, row byte-equal. Pinned (c, d).
  • (g) An apply whose flush creates nothing: reached only with drift and no pending creates; flushDeferredSchemaDdl still re-enters initObjects with every deferred object, so existing is the full count; no marker. With an EMPTY deferred set (a boot where the engine skipped its sync — OS_SKIP_SCHEMA_SYNC=1 in the deploy environment, objectql/plugin.ts 431 — on SQLite, PG or MySQL), the flush returns [] and the only creation possible is sys_packages through ensurePackageTable → engine.execute raw DDL (service-package 423-428), which no counter sees; {0, 0}; no marker. Fails closed both ways.
  • (h) An engine where getDriverForObject is missing: recordBirthOfFlushedStore returns before asking (644); the serving guard returns early on the same test (344), so no marker is owed. Consistent.

2. The failed-write path. recordBirthOfFlushedStore rethrows with cause, naming the row, the cause and the ceremony's two steps (648-657); flushSchemaDdl rejects (1617); apply.ts 636 sits inside the try at 235, whose catch (711-718) emits { error } and this.exit(1) on --json, printError and this.exit(1) on the human face, with finally shutting the stack down. The command never reports success. Pinned by schema-migrate.birth-after-flush.test.ts (ablating the rethrow turned one case red, per the report). Not named in the text: the detected drift (applyMigrationEntries, 637) is left unapplied by the throw; a re-run re-detects and applies it, writing no marker since the tables now exist (③8).

3. The guard's refusal rule and text, and the ceremony, are unchanged. The runtime diff is the OrganizationOwnershipBirthMarkerPlugin docblock alone (366-374, +6 / -3; the hunk holds no code line). organizationOwnershipRefusalMessage, assertOrganizationOwnershipMigrated, recordOrganizationOwnershipBirth, ledgerStoreCreatedFromEmpty and writeBirthMarker are byte-unchanged. No organization-ownership* command or util is in the file list; packages/drivers/** and packages/spec/** are untouched. The hold-offs held.

4. The fixture outside the claim (apply-organization-ownership-gate.integration.test.ts, +4): a comment and one statement, DELETE FROM sys_migration WHERE id = ? with ORGANIZATION_OWNERSHIP_MIGRATION_ID (already imported there), after the beforeAll apply that builds v18Db. Only the marker this fix now writes, and necessary for both halves of that suite: cases 1, 1b and 1c read the marker's absence (marker: null), and case 3 inserts its own row under the same id, an INSERT the birth row would collide with. The same shape organization-ownership-platform-global-column-drop.integration.test.ts uses for its served boot.

5. The pins. apply.organization-ownership-birth.integration.test.ts runs the real command over a real file and reads the verdict through assertOrganizationOwnershipMigrated on a deferred read-only boot composed with the served platform, so sys_migration is registered and the guard is not inert — (b)'s refusal is the control (118-134). (b) drops sys_platform_setting_audit so the flush creates exactly one table in a populated unmarked database, asserts created is that one create_table (the trap the body names) and markerRows empty; its 17.x arm adds a physical organization_id on sys_platform_setting and asserts the refusal names that table. Red on main for (a) and (c, d) per the report; ablating the emptiness check turned (b) 18.0 red with a false birth row — the ablation that proves the decision is the counts, not the returned work. New files are in the tsconfig program (2 of 2, per the report).

6. Public surface. recordBirthOfFlushedStore (new export) and DeferSchemaDdlPlugin.logger (new public field) live in packages/cli/src/utils/schema-migrate.ts, which @objectstack/cli's exports (., ./console, ./hook-body) do not reach and src/index.ts does not re-export: CLI-internal, as declared. What @objectstack/cli publishes differently: os migrate apply writes one sys_migration row on a database it created from empty, and exits 1 when that write fails. @objectstack/runtime publishes nothing different; recordOrganizationOwnershipBirth was already its export (PR #24681). No spec, no schema, no governed path, no fork (head repo is the base repo).

7. A wording gap, no behaviour: the if (deferral) at 1617 and its comment "only a deferred boot" — the skip-sync boot's deferral is non-null too (1384), so the Turso remote apply enters the birth half and leaves on {0, 0} (①1e). Harmless; ③9.

8. The delta 51849a38a..0da73a49d is type-only, with no behaviour change. One commit, schema-migrate.ts 588-600: the alias type BirthLogger = { info?: ...; error?: ... } (an optional error beside no warn, the no-fallback shape check-optional-error-sink-contract.mjs reds at 509-515, unbaselined) is replaced by type BirthLogger equal to Parameters of RecordOrganizationOwnershipBirth at index 1 — the runtime's own parameter, { info?: ... } or undefined (boot-guard.ts 421) — with a docblock saying why: nothing on this path logs a failure, it is thrown (①2). Its two uses are unchanged in text and widen nothing: the field logger: BirthLogger | undefined (477, assigned ctx.logger at 498) and the parameter at 636, handed straight to recordBirth(engine, logger) (646), whose own parameter type it now is. No expression, statement, call or export moved; the three test files, apply.ts and the runtime are untouched by the delta. Under the gate's population rule (an interface, a type alias to a type LITERAL, or an inline type literal, 582-586) an indexed-access alias is none of the three, and the only literal left on the path — the runtime's parameter — declares no error, so it was never in the population. Measured: every Lint and Type Check lane success on this head (①0). The PR body still reports its runs on 51849a38a, which a type-only delta does not invalidate.

② Semver level

@objectstack/cli: minor with Clause-②: yes, no arm, on the changeset and the PR body alike. The level axis of check-changeset-no-major (its header, items 1 and 2): a PR declaring clause ② must grade at least one package whose published source it moves minor or above — the cli is that package — and no major is introduced (the launch-window guard; Check Changeset success on this head). yes fits the content: the boot guard's admission set gains one writer (a database os migrate apply created from empty is now served); nothing an author writes moves, no export is removed, so no arm and no ADR-0087 marker is owed. minor is the floor yes takes (AGENTS.md Post-Task Checklist 3) and is what the diff publishes: a behaviour a published command did not have. The runtime's docblock-only change takes no changeset, rightly; the delta changes no published surface and owes none. Tier: the committed tree carries no published schema, no governed path and no (narrowing) line, so this is the declaration face at TIER_DEFAULT by the template's rule, whatever depth the claim said the seat would read at; the reading above is the cited code in full.

③ Boundary flags

The dev's deviations (report 6290825368), each answered:

  1. A one-object scratch project, not examples/app-showcase (74 tables against 71): the mechanism is the deferral and the flush, not the object set, and the refusal text is the same. Accepted.
  2. Red-on-main and the runtime ablation ran on 495889a0e, before the main merge: between that head's base 41646a578 and the final base abd5acdaf, main's 7 commits touched none of schema-migrate.ts, apply.ts, organization-ownership-boot-guard.ts, sql-driver.ts, engine.ts or standalone-stack.ts (git log over those paths is empty), so both legs transfer; the later delta (①8) changes no code those legs ran. Accepted.
  3. The CLI integration tier filtered to migrate and organization-ownership; the full runtime suite not run: declared narrowings; CI's Test Core 1 to 6 are the measure and are NOT MEASURED at this read (in progress on 0da73a49d). The seat reads them before queueing. Carried.
  4. One locked run exit 99 (queue timeout) re-run; one foreground wait resumed with tail --pid: process hygiene, nothing redone. Accepted.
  5. Trailers: the model-free pair on the four authored commits (the delta's included) and the harness's model-named form not used — that is AGENTS.md's rule. The merge commit 0be22e895 carries no trailer pair, a gap against "an agent commit ends with the pair"; no hook refuses it and the PR body carries the session URL. Noted, not blocking.
  6. Not re-merged after opening (12 commits behind at this read; GitHub reports blocked, not dirty): Multi-agent discipline §10 merges only on a conflict, a main-caused red or a queue removal. Since the base, main's df8bb40a0 (feat(cli,driver-sql)!: a tenant table's organization-less row no longer reaches the __global__ counter, and a production server boot with no auth secret is refused (18.x step 7, part 7b-3) #24845) touched sql-driver.ts only at 94, 5703, 7773, 8136, 8397, 8431 and 12130 — not the counting seam (5626-5629, 12539-12547, 13500, 13698) — so ①1 holds on the queue's rebuilt tree. Accepted.
  7. recordBirthOfFlushedStore exported for the unit pin: CLI-internal (①6). Accepted.

Not in the report: the patch round itself (①8) carries no os-dev-report on the card and no PR comment at this read; its commit message states the reason, and the dispatch-gates note at the gate's line 232 (REFUSE-WIDE: no path derives check:optional-error-sink) explains why the dev's 68-gate sweep could not see the red on 51849a38a. Named here so the record of the round is on the card; not blocking.

Other flags, none blocking:
8. The failed-write text names the row, the cause and the ceremony; it does not say the detected drift was left unapplied. A re-run applies it. Wording; carried.
9. The "only a deferred boot" comment at 1617 (①7). Wording; carried.
10. The guard's own failed-write text (boot-guard.ts 403-406, "run this command again") predates this PR; the apply's names the ceremony only, which is the way on once the tables exist. Not this PR's.
11. content/docs/deployment/cli.mdx is not edited; its sentence "the first boot that creates a datastore from empty writes a birth marker" now covers os migrate apply. The Docs Drift Check names 9 hand-written pages and 4 release-owned (read-only) ones. A sentence naming the apply would be a nicety. Carried.
12. The Turso remote path rests on transportRefusesDeferredDdl → exemptPrimary → never flushed, read in code, with no live libsql:// measurement in this PR; PR #24681's kernel pin covers the serving birth there. Carried.
13. #24833 (os dev's recover text) follows this card, per the claim.
14. Draft PR; 412 changed lines; 0 governed paths; mergeable state blocked at this read. Test Core 1 to 6 are the only gates not yet measured; green means their conclusion reads success, and the seat arms nothing before that.

Implemented-by: claude/issue-24827-migrate-apply-birth-marker
Reviewed-by: session_01LmjU2x1faLZ39DVTFyVca6

VERDICT: PASS


Generated by Claude Code

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

席位核对 · ACCEPT

domain:cli seat 2 (#22648) · os-justin · session session_01LmjU2x1faLZ39DVTFyVca6 · 2026-10-20T12:50Z · head 0da73a49d58e7b7b2b7c3f5fb0c8a6f4db81b13d · claim 6288623633 → report 6290825368 → patch round 0da73a49d → review of record 6291220168 (TIER_DEFAULT, PASS). This seat adopted it after re-reading its cited lines at source:

  • schema-migrate.ts:599 (BirthLogger is the runtime's own parameter), :633–:659 (recordBirthOfFlushedStore rethrows with cause) and :1603–:1617 (every armed driver flushes, then ensure(), then the birth call);
  • organization-ownership-boot-guard.ts:419–:434 and :477–:485 (the counts decide: existing 0 and created above 0);
  • sql-driver.ts:12539–:12547 (the probe that fills the two sets) and :13698–:13718 (the flush re-enters initObjects with every deferred object);
  • apply.ts:216–:219 and :296.
Check Reading
Shape Draft, base main. First line Fixes #24827; Clause-②: yes line-initial. Assignee os-justin = the card's. One changeset, @objectstack/cli: minor, the floor yes takes.
Surface 7 files, +412 / −5: the birth half after the flush in schema-migrate.ts, its call site in apply.ts, a docblock in the runtime guard, two new pin files, one statement in an existing fixture, and the changeset. All are inside the claim, and no hold-off is touched: the refusal rule and text and the ceremony are byte-unchanged, and nothing under packages/drivers/** or packages/spec/** moves. check-governed-merges.mjs --pr 24875: NOT governed.
Mechanism After the deferred apply flushes, the run asks the runtime's own recordOrganizationOwnershipBirth on the driver that holds sys_migration. A database created from empty gets the marker. A database that held any declared table before the run gets none and is refused as before (review ①1, cases a–h). A failed write exits 1 and names the ceremony.
Patch round 51849a38a was red on Lint · docs and code guards (pnpm check:optional-error-sink). 0da73a49d re-types BirthLogger as the runtime's own parameter, with no error sink because the path throws. The change is type-only (review ①8). The dev reported the gate exiting 0 and every Declared WIDE population family run green. The family was not in dispatch-gates --commands, which names the WIDE families only on stderr.
Evidence (a) and (c, d) were red on main and green after. Ablating the counts check turned (b) 18.0 red with a false birth row. Ablating the rethrow turned the unit pin red. Each was restored hash-equal.
Gates Local at 51849a38a: 68/68 derived families exit 0, 0 UNRUN; pnpm lint exit 0. Check-runs on this head (40, latest per name), read at this act: 29 success, 7 skipped, 4 in_progress (Test Core 1, 3, 4, 6 /6). Landing waits on all of them.

Accepted deviations: all as the review's ③1–③7 answers them.

Out-of-scope dispositions (one line each):

Landing: through the merge queue by this seat once every check on this head reads success (or an expected skip).


Generated by Claude Code

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentation Improvements or additions to documentation size/m tests tooling

Projects

None yet

2 participants