Skip to content

feat(runtime,cli)!: os serve refuses a v17-shape database until the organization-ownership ceremony has verified it (ADR-0131 D10 item 5, C7b S5) - #23461

Closed
objectstack-fleet[bot] wants to merge 11 commits into
mainfrom
claude/issue-23429-boot-refusal
Closed

objectstack-fleet[bot] wants to merge 11 commits into
mainfrom
claude/issue-23429-boot-refusal

Conversation

@objectstack-fleet

@objectstack-fleet objectstack-fleet Bot commented Oct 14, 2026 •

Copy link
Copy Markdown
Contributor

Fixes #23429 · Part of #15211
Clause-②: yes (narrowing)

What changed

The v18 boot refusal (ADR-0131 D10 item 5; ruling 6163114098, Q3 → A).

  • OrganizationOwnershipBootGuardPlugin (packages/runtime/src/organization-ownership-boot-guard.ts). At kernel:ready it asks one question: does a table whose object declares no organization column (systemFields.tenant: false, no declared organization_id) still carry a physical organization_id? Schema sync only adds columns, so on a database a v17 runtime created the answer stays yes until the ceremony's column-drops step runs. If the answer is yes, the guard reads the ceremony's sys_migration marker adr-0131-organization-ownership. Unless that marker is verified, it throws OrganizationOwnershipBootRefusal (code ORGANIZATION_OWNERSHIP_MIGRATION_REQUIRED). The refusal names the tables and os migrate organization-ownership, and it stops bootstrap() before kernel:listening opens the socket.
    • A fresh database boots: no table it creates carries a retired column.
    • A database whose ceremony verified boots, whatever its columns.
    • A column read that fails is refused, never taken for a migrated database.
    • ⛔ There is no escape hatch: no variable and no flag skips the check (D10 verbatim).
    • A driver with no physical columns (memory, MongoDB) is not checked.
  • Composed by os serve only (packages/cli/src/commands/serve.ts), beside MigrationRecoveryPlugin. It is never in createStandaloneStack or data-migration-plugins.ts: those are the CLI data and schema boots, the ceremony's own boot among them, and a guard there would refuse the one command that clears it.
  • ORGANIZATION_OWNERSHIP_MIGRATION_ID moves to @objectstack/spec/system (migration.zod.ts) so that the runtime and the ceremony read one constant. The ceremony re-exports it.
  • The error code is registered in ERROR_CODE_LEDGER under @objectstack/runtime. No door answers it: the refusal is thrown before any request is served. The generated api surface, export origins and error-code docs are regenerated.
  • The changeset: BREAKING. Its ADR-0087 marker is already-registered, naming the step-18 column-retired entries of the tables it reads. The metadata family's four entries land with S2.

Where the refusal sits, and why it is its own plugin

The card names the kernel:ready reader shape of migration-recovery-plugin.ts, refusing where that plugin warns. The guard takes that shape: the same hook, the same engine reads, and the same "inert without an engine" rule. It is a sibling plugin rather than code inside MigrationRecoveryPlugin for one reason: MigrationRecoveryPlugin is also composed by the CLI data-migration boot (packages/cli/src/utils/data-migration-plugins.ts), the boot the ceremony runs on. A refusal there would refuse the ceremony itself.

Landing

Pins

  • organization-ownership-boot-guard.test.ts: the unit pins over a real SQLite database.
    • ⭐ a v17-shape database is refused, naming the command and every table;
    • an unverified marker is refused;
    • ⭐ a verified marker boots;
    • ⭐ a migrated database (the columns dropped) boots;
    • ⭐ a fresh database boots;
    • a failed read is refused;
    • a column-less driver is inert;
    • the plugin refuses at kernel:ready;
    • no environment variable stands the refusal down.
  • organization-ownership-boot-guard.kernel.test.ts (new): the same verdicts on a real ObjectKernel (SQL driver, ObjectQL, platform objects, the guard).
    • ⭐ a v17-shape database stops bootstrap() with the refusal, and the kernel never reaches running;
    • ⭐ the same database with its marker verified boots;
    • an unverified marker is refused;
    • ⭐ a fresh database boots.

The kernel pin exists because the unit pins call the hook by hand, and no unit pin proved that the throw stops a real boot.

Added after the first report (os-dev-report 6168648434):

  • organization-ownership-boot-refusal-order.dogfood.test.ts, an end-to-end pin of the upgrade order. It checks five things in turn:

    • a v17-shape file database is refused, naming the command;
    • the refused boot has already added sys_user_permission_set.permission_set, because the refusal fires at kernel:ready, after every plugin's start() and its additive schema sync;
    • the real CLI os migrate organization-ownership --apply --backup-taken completes with no boot in between;
    • the ceremony verifies its marker;
    • the next boot succeeds.

    So no deadlock is possible. The pin is green only beside S2: it was 4/4 on a scratch merge of S5 + S2, and again of S5 + S2 + S3, and red on S5 alone by design, because the family still declares its column there. Dogfood runs on main's full battery, not on PR CI, and S5 lands only with S2.

  • A real guard defect, fixed: the guard read only the driver's config.client. A served file database runs on SqliteWasmDriver, whose client is a dialect class, so the guard was inert on the default SQLite driver. It now reads SqlDriver.dialectName, falling back to the client string, with a unit pin.

  • The dogfood package gains an @objectstack/runtime dependency, and an anchored source alias for it in its vitest.config.ts (check:test-source-alias).

Verification

Latest head e39386f2cd: the guard unit and kernel pins pass, 14 tests. Runtime and dogfood typecheck exit 0. PR CI is green, 37 of 37 with 5 skipped. The first round follows, all on head 540c5b3c of this branch (origin/main merged once at 24f4483d before work).

  • pnpm --filter @objectstack/runtime exec vitest run --maxWorkers=2: 378 files, 6,276 passed, 19 skipped.
  • pnpm --filter @objectstack/cli exec vitest run --project unit --maxWorkers=2: 4,278 passed, 29 skipped. Two files needed a built cli (published-subpath-console.pin, published-subpath-hook-body.pin). After pnpm --filter @objectstack/cli build they pass, 29 tests. The integration layer is left to CI.
  • pnpm --filter @objectstack/runtime typecheck and pnpm --filter @objectstack/cli typecheck: exit 0.
  • pnpm --filter @objectstack/spec check:generated --fix: three artifacts were stale (api-surface, export-origins, docs) and are regenerated. vitest run src/api/error-code-ledger.test.ts: 21 passed.
  • Ablation, one-shot, with node scripts/ablation-replace.mjs. The guard's if (tables.length === 0) return; was replaced by an unconditional return. The kernel pin went red as predicted: the two refusal cases failed and the two boot cases stayed green. The file was restored, and its blob equals HEAD (git diff HEAD empty).
  • node scripts/pm/dispatch-gates.mjs --commands derived 116 families, and all 116 ran (--ran reconciles: 0 unrun). 109 exited 0. Seven are NOT MEASURED, because they refused on a local prerequisite before reading anything; CI runs them:
    • check-plugin-teardown-shape --self-test: the shallow clone;
    • check:skill-examples, check:dual-build-cjs-loads, check:i18n, check:i18n-coverage, check:i18n-walk-parity and check:type-check-debt: unbuilt packages.
  • node scripts/check-adr-0087-registration.mjs: the changeset carries its disposition.

Body updated by domain:engine seat 1 (session_01Rerax7QTjKMPCUZxQUtPFR), on the dev's request (6168648434, open question 1 → A).

🤖 Generated with Claude Code


Generated by Claude Code

…shape database until the ownership ceremony is verified (ADR-0131 D10 item 5, C7b S5)

WIP: tests not yet run.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01USccGXR13sWyTeEwe8QFdH
…rified marker and a fresh database boot (ADR-0131 D10 item 5, C7b S5)

Claude-Session: https://claude.ai/code/session_01WXLB6sUdwQZQ9NQS3mj3uZ
Co-authored-by: Claude <noreply@anthropic.com>
…e docs for the boot refusal; changeset wording (C7b S5)

Claude-Session: https://claude.ai/code/session_01WXLB6sUdwQZQ9NQS3mj3uZ
Co-authored-by: Claude <noreply@anthropic.com>
@github-actions

github-actions Bot commented Oct 14, 2026 •

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 4 package(s): @objectstack/cli, @objectstack/dogfood, @objectstack/runtime, @objectstack/spec, touching 34 documentable anchor(s). ⚠️ 5 changed file(s) yielded no anchor (packages/qa/dogfood/package.json, packages/qa/dogfood/vitest.config.ts, packages/runtime/src/index.ts, …), so the pages documenting them are NOT COVERED by this run — this is not a clean bill of health for those files.

33 hand-written doc(s) name something this change touched — list omitted above 15 rows. Re-derive on the tree named below: node scripts/docs-audit/affected-docs.mjs --json 0fb55fc2fca5590a76c1827d95bf7d1bacf22d02.

⛔ 11 release-owned page(s) also affected — read-only, see AGENTS.md Documentation Guardrails.

What this run could not see
  • 5 changed file(s) yielded no anchor (packages/qa/dogfood/package.json, packages/qa/dogfood/vitest.config.ts, packages/runtime/src/index.ts, …) — pages documenting those are invisible to this run
  • 2 anchor(s) matched too much of the corpus to be a work list: organization_id (literal, 35 pages), os serve (command, 31 pages)
  • 8 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 — 153 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 0fb55fc2fca5590a76c1827d95bf7d1bacf22d02 → packageMentionDocs.

Which tree this was computed on

This run read content/docs from b6534e3bdec9064867a70ca85dc973ec10ae0f12 — the merge of head ba6d9c5de89ffb42a0454b852d3e41838648be8a into base 0fb55fc2fca5590a76c1827d95bf7d1bacf22d02, 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 b6534e3bdec9064867a70ca85dc973ec10ae0f12 && git checkout b6534e3bdec9064867a70ca85dc973ec10ae0f12
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 0fb55fc2fca5590a76c1827d95bf7d1bacf22d02 ba6d9c5de89ffb42a0454b852d3e41838648be8a && git checkout -B drift-repro 0fb55fc2fca5590a76c1827d95bf7d1bacf22d02 && git merge --no-ff ba6d9c5de89ffb42a0454b852d3e41838648be8a

node scripts/docs-audit/affected-docs.mjs --json 0fb55fc2fca5590a76c1827d95bf7d1bacf22d02

⚠️ 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 0fb55fc2fca5590a76c1827d95bf7d1bacf22d02 → pass the list as
args.docs, on the commit named under Which tree this was computed on.

…dges SqliteWasmDriver, the default SQLite driver of a served file database (C7b S5)

Measured on the showcase composition: the database is served by
SqliteWasmDriver, whose knex client is a dialect class, not a string. The
guard read config.client alone, resolved no dialect and skipped every table,
so a v17-shape database booted. SqlDriver.dialectName is the public,
structural answer; the client string stays the fallback.

Claude-Session: https://claude.ai/code/session_01WXLB6sUdwQZQ9NQS3mj3uZ
Co-authored-by: Claude <noreply@anthropic.com>
@github-actions github-actions Bot added the dependencies Pull requests that update a dependency file label Oct 14, 2026
…fused, the ceremony completes with no boot between, then boots (C7b S5)

Measures the order the seat asked for: the refusal fires at kernel:ready,
after every plugin's start(), where the additive schema sync runs, so the
refused boot has already added sys_user_permission_set.permission_set, the
column the ceremony waits for. No deadlock.

Claude-Session: https://claude.ai/code/session_01WXLB6sUdwQZQ9NQS3mj3uZ
Co-authored-by: Claude <noreply@anthropic.com>
…s an operator's next process does (C7b S5)

Claude-Session: https://claude.ai/code/session_01WXLB6sUdwQZQ9NQS3mj3uZ
Co-authored-by: Claude <noreply@anthropic.com>
…sal pin (check:test-source-alias) (C7b S5)

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

Copy link
Copy Markdown
Contributor Author

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Served on opus by the maintainer's order 「后续契约复审用 opus就够了」 (seat note 6166448512 on #6367).
Head-sha: e39386f2cddb9199b59441dd56fc928c12b1a130
Local-runs: none

What I read:

For context I also read main and this head in the scratch checkout (the kernel, serve.ts, the ceremony and ADR-0131 D10). Nothing was built, run or re-run.

Gates on this head: 49 check-runs, 0 failing, 0 pending.

  • 42 succeeded and 7 were skipped. Taking only the latest run of each name, that is 35 succeeded and 7 skipped.
  • The skips are by design. These checks do not run on pull_request:
    • the Dogfood Regression Gate shards;
    • Dogfood Verify CLI;
    • Console Pin Gate;
    • Temporal Conformance;
    • Packed-tarball smoke.
  • Auto Label and Check PR Size were skipped only on the 14:16Z body-edit rerun. Both were green on the push run.
  • The Check Changeset rerun ended success at 14:18Z.

① Derived judgments

The guard's verdict logic is right on all five cases.

  1. Fresh database: right.
    • When no table whose object has systemFields.tenant false carries organization_id, the guard returns without reading the marker.
    • This is pinned by a unit test and on a real kernel.
    • It is also measured on the full os serve composition. packages/cli/test/serve-migrate-exit-schema-parity.integration.test.ts spawns the real serve against fresh SQLite files, now with the guard in the boot. That file is in the per-PR integration population, inside the green Test Core shards.
  2. Verified marker: right.
    • The database boots whatever its columns, through isDataMigrationFlagVerified: verified_at must be set and blocking must be 0.
    • blocking goes through Number, so a missing count reads as NaN and is refused.
    • This matches the ceremony's write (passed: complete && blocking === 0).
  3. Migrated database (columns dropped): right.
    • It boots with or without a marker.
    • This is the ruling's conjunction: v17 shape AND marker not verified.
    • It also fits D10 fate 4: reported rows are listed at boot, not refused.
  4. Failed read: right, and it fails closed.
    • A failed column read is refused with tables: [].
    • A failed marker read is refused as unreadable.
    • Side effect: a verified database whose column read fails is also refused, because columns are read before the marker. That is acceptable for a fail-closed guard.
  5. Driver without columns: right for memory.
    • The guard is inert when the dialect is not sqlite, postgres or mysql, or when the driver has no execute.
    • MongoDB is escalated in ③.

The predicate is structural, and wider than the ruling's list: non-blocking. It is "object has tenant false, declares no organization_id, and the table physically has one". On open core that covers:

  • the 8 step-18 D7 objects;
  • sys_metadata_activation and sys_platform_setting, both in the ceremony inventory;
  • the family, once S2 lands;
  • any app object authored with tenant: false;
  • any object an org-scoping declaration makes platform-global.

That is a defensible reading of D7, where membership is decided by the writer and the platform-global paragraph is part of the decision. The cost: a database that was never v17, whose object was flipped to tenant false after its table was created, is refused with "v17" wording. The way out is the ceremony (application-default fate) or dropping the column. That path is not measured here.

The SqliteWasmDriver / dialectName fix is right.

  • SqlDriver.dialectName is public.
  • SqliteWasmDriver overrides isSqlite, so it answers sqlite.
  • The Turso driver inherits sqlite.
  • The client-string fallback keeps knex string clients working.
  • It is pinned by a unit test with a client that is a class.

Composition by os serve only: right.

  • Unconditional in serve.
    • Step 5c-ter sits at the top level of the boot block. Its only condition is the check for an instance already present.
    • It runs before runtime.start() and before the config plugins are added at line 4338.
    • It has no try/catch, so if the import fails the boot fails. That is correct.
  • Every path through serve is covered.
    • os dev and os start both spawn serve.
    • OS_MIGRATE_AND_EXIT=1 exits only after bootstrap, so it is refused as well.
    • serve never sets bootPhaseHooks: false.
  • Absent from the other boots. The guard is not in createStandaloneStack, data-migration-plugins.ts or bootSchemaStack; the PR touches none of them.
  • The ceremony cannot be refused.
    • It boots through bootSchemaStack with composeHostStack and composeServedPlatform.
    • The served-platform mirror reads Serve.CAPABILITY_PROVIDERS and ALWAYS_ON_CAPABILITIES, which do not include step 5c-ter.
    • Host config plugins pass through composeForDeclarations, which suppresses start(). That is where the guard registers its hook. So even a config that composes the guard itself cannot refuse the ceremony.
    • The e2e pin's case 2 measures this end to end, beside S2.
  • How the refusal reaches the operator. The kernel dispatches hooks in order and passes the throw on. serve's outer catch prints the error and exits 1. HonoServerPlugin listens only on kernel:listening, so no request is ever served.

No path skips the refusal: right.

  • Neither the guard nor step 5c-ter reads any environment variable or flag. The unit pin sets three OS_* names and is still refused.
  • One residual, non-blocking: a config plugin named com.objectstack.organization-ownership-boot-guard (or a class with the guard's name) replaces the guard silently.
    • This is the kernel's documented name-replacement rule, which applies to every auto-registered plugin.
    • It is a deliberate act in code, of the same kind as writing a verified marker row by hand. It is not a switch the platform offers, so it is not an escape hatch in D10's sense.

Other hosts:

  • os dev and os start: covered.
  • Hosts that compose their own kernel (createStandaloneStack plus Runtime, createObjectQLKernel, @objectstack/hono):
    • Unguarded unless they add OrganizationOwnershipBootGuardPlugin or call assertOrganizationOwnershipMigrated; the changeset says so.
    • Out of scope: the ruling put the refusal in MigrationRecoveryPlugin's shape, and that plugin is also composed only by serve. Worth a follow-up card.
  • Cloud:
    • The open-core CLI refuses any boot mode other than standalone.
    • objectos-stack running serve with the host-kernel plugin gets the guard on its host kernel.
    • Its per-environment kernels are cloud's to compose: D10 runs the ceremony per environment, under runbook C10.
    • Out of scope; a note for the cloud repo.
  • CLI maintenance boots (os migrate *, os secret, os storage orphans, os verify):
    • Unguarded by design. The ceremony and the steps before it, for example security-catalog-overlays (6170427033), must boot a v17 database.
    • Out of scope, and right.

Constant moved to @objectstack/spec/system: right.

  • One source for the runtime and the ceremony, following FILE_REFERENCES_MIGRATION_ID and VALUE_SHAPES_MIGRATION_ID.
  • The ceremony re-exports it, so CLI call sites are unchanged.
  • It is correctly left out of CREATION_ATTESTED_MIGRATION_IDS: a fresh store passes by having no column, not by attestation.

Error code: right.

  • ORGANIZATION_OWNERSHIP_MIGRATION_REQUIRED is in the @objectstack/runtime block of ERROR_CODE_LEDGER, as a code that reaches no door (the WALLED_MEMBERSHIP_POLICY_UNDECLARED precedent).
  • It widens ErrorCode / ApiError.code by one ("+327 more").
  • No wire body changes, because it is thrown before any door is open.

Regenerated surface: right.

  • api-surface/system.json and export-origins/system.json gain the constant.
  • contract.mdx and error-code-ledger.mdx gain the code.
  • The runtime gains 7 exports, all additive. findV17ShapeTables and organizationOwnershipRefusalMessage are more public API than a host needs. Non-blocking.
  • The banner's Plugins: row now names the guard, as it does MigrationRecovery.

Dogfood dependency and source alias: right.

  • The package is private, so nothing publishes. The lockfile is updated.
  • The anchored @objectstack/runtime alias points at source, and check:test-source-alias is green.
  • The refusal is recognised by its code, so the harness's own built runtime beside it changes no verdict.
  • @objectstack/cli is already a devDependency, so the dogfood build closure builds the CLI binary the pin spawns.

② Semver level

The changeset matches what publishes.

  • It bumps @objectstack/runtime, @objectstack/cli and @objectstack/spec as minor, all in the fixed group.
  • It is marked BREAKING under the launch-window convention. Check Changeset is green.
  • What publishes:
    • runtime: the new module and its exports;
    • cli: the serve composition and the re-export;
    • spec: the constant and the ledger code.
  • The dogfood package is private and needs no entry.

Clause-②: yes (narrowing) is right. It appears in the PR body and the changeset. The served boot's accept-set narrows: a v17-shape database that boots on main is refused. Every other change is additive.

The ADR-0087 not-required (already-registered …) marker is right.

  • All eight ids are on main as 18.*-organization-column-retired entries.
  • No new D3 entry or D2 conversion is owed, because the change adds no authorable key and rewrites no row.
  • The family's four entries come with S2.
  • One nit, non-blocking: the guard also reads a platform-global object's column, and the marker does not name platform-global-object-organization-column-retired, which is also on main.

③ Boundary flags

Open questions in the three reports:

  • Renumbering (6167186090 Q1): settled by seat 2's receipt 6164453786. It belongs to S1 and S2 and is not in this diff.
  • Form-door carry (6167186090 Q2): overtaken by pointer 6165850696; the deletion now belongs to S2. Not in this diff.
  • Sibling-plugin site (Q3 in both reports, option A): right.
    • It uses the same hook, the same engine reads and the same "inert without an engine" rule, composed beside MigrationRecoveryPlugin: the guard refuses "where that plugin warns".
    • Option B, inlining it with a composition flag, would create a switch, which is exactly a skip reachable by composition.
    • The reason given is wrong, though. It is stated in the body's "Where the refusal sits" section and in both reports, and the brief repeats it.
      • The organization-ownership ceremony boots through bootSchemaStack, which does not compose MigrationRecoveryPlugin.
      • data-migration-plugins.ts is the boot of the other os migrate data commands (value-shapes, files-to-references, recorded-by, meta, resume and others).
      • An inline refusal would have refused those commands on a v17 database, not the ceremony.
      • Option A still stands. The prose is wrong but not blocking.
  • S2's ceremony files (Q4 in both reports): these belong to S2 and are not in this diff.
  • ADR-0087 marker naming the step-18 entries (Q5 in both reports, option A): right. Option B needs S2 on main first.
  • Stale PR bodies (6168648434 Q1, option A): done. The 14:16Z body covers the e2e pin, the SqliteWasmDriver fix, and the new dependency and alias.
  • E2e pin red on S5 alone (6168648434 Q2, option A): accepted, subject to the landing condition below.
  • 6170427033: no open questions.
  • Out-of-scope finding (dialectOf and resolvePlannedDriverExec read config.client as a string): carried by S2, whose new helper reads dialectName first. Not this diff.

"Lands together with S2" is only stated, not enforced.

  • Both PRs are drafts, and no gate ties them together.
  • Dogfood runs on neither pull_request nor merge_group (the if: of the dogfood job in ci.yml). So S5 alone passes PR CI and the queue green.
  • Two PRs in the queue are not atomic: if either is ejected, the other lands alone.

If S5 lands without S2:

  • main's push battery goes red on organization-ownership-boot-refusal-order.dogfood.test.ts until S2 lands:
    • case 1 fails because sys_metadata is not judged while the family still declares its column;
    • case 2 fails because column-drops blocks on the family as not-proposed.
  • How the code behaves in that window, by reading it, not running it:
    • A v17 database is refused on the D7 tables.
    • The ceremony drops the D7 columns, then blocks on the family and leaves the marker unverified.
    • The next boot passes, because the family is not judged.
    • So no database is stranded, but none gets a verified ceremony either.
  • Fixes #23429 also closes the card on S5's merge.

If S2 lands without S5 (the ruling forbids this): the family stops declaring its column with no refusal behind it.

Landing condition. This is the seat's act, not a fix to this head:

  • Land S2 and S5 in one landing, as ruling 6163114098 says ("one PR, or two queued together"), and say which.
  • If either is ejected, hold or revert the other before main's next battery.
  • This pin first runs in CI on main, after landing. Its green rests only on the dev's local scratch merges (S5+S2 and S5+S2+S3).

The ordering claim holds.

  • ObjectQLPlugin.start() runs syncRegisteredSchemas. bootstrap() finishes every start() before triggering kernel:ready, where the guard throws.
  • Both of the ceremony's "Boot this release once" prerequisites are created during start():
    • sys_user_permission_set.permission_set, measured by pin case 1b;
    • sys_platform_setting, the settings-move target, a platform-objects object registered in init().
  • Limits of the claim:
    • kernel:ready handlers that run after the guard's do not run on a refused boot. These include plugin-audit's and service-messaging's own table provisioning, and the config plugins' hooks.
    • A refused boot is not read-only: the start() schema sync and seeds have already run. That follows from where the ruling put the refusal, and the measurement shows it does not harm the ceremony.
  • With OS_SKIP_SCHEMA_SYNC, a refused boot syncs nothing. The next boot without it syncs first and then refuses, so there is no deadlock.

Escalations for the seat (non-blocking):

  1. MongoDB. The guard is inert on it by design, and D10's ceremony runs only on SQL. A v17 MongoDB deployment is therefore served with v18 semantics and can be neither refused nor migrated. This falls outside the ruling's column-based definition of the v17 shape, and no ruling covers it.
  2. The structural predicate is wider than the ruling's list (see ①). Consider a follow-up pin for the path out of a never-v17 database with an orphaned column.

Implemented-by: session_01WXLB6sUdwQZQ9NQS3mj3uZ (branch claude/issue-23429-boot-refusal)
Reviewed-by: session_01Rerax7QTjKMPCUZxQUtPFR

VERDICT: PASS


Generated by Claude Code

… the organization-ownership ceremony (#23429 E1)

Seat answer 6171152049 (E1 → S5). A v18 runtime declares no organization
column on the ceremony's column-drop tables, so the drift report offered
each physical one to `os migrate apply --allow-destructive`; dropped ahead
of the ceremony it skips the promotion and report steps, turns legacy
organization rows into environment rows, and leaves the v18 boot refusal
no column to find — the escape hatch D10 item 5 forbids.

`os migrate apply` now refuses `drop_column organization_id` on any table
in the ceremony's inventory while marker adr-0131-organization-ownership
is not verified, naming `os migrate organization-ownership`; exit 1, and
under --json `refused_organization_ownership_unmigrated` with the tables
(the #17440 / #23281 shape). No flag stands it down. The ceremony's own
column-drops step applies its entries directly and is not gated.

Pins (integration, real CLI over a v17-shape sys_platform_setting): refused
with no marker and with an unverified one; an unrelated destructive drop on
the same table is not gated; the drop proceeds once the marker is verified.
Ablation: forcing the gate's table set empty turns both refusals red.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WXLB6sUdwQZQ9NQS3mj3uZ
@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Served on opus under the maintainer's order 6166448512 (「后续契约复审用 opus就够了」, seat note on #6367).
Head-sha: ba6d9c5de89ffb42a0454b852d3e41838648be8a
Local-runs: none

Scope: a delta review. It covers e39386f2cd..ba6d9c5de8, which is one commit, ba6d9c5de8: E1, from seat answers 6171152049. I judged it on the tree that would land.

What I read.

Gate verdicts on ba6d9c5de8. Re-read at 2026-10-14T22:31Z, the last step before rendering; the head is unchanged. There are 42 runs: 35 succeeded, 5 were skipped and 2 failed. None is pending.

  • FAILED · Type Check · source gates.
    • It is the one red step of 32: "Check generated reference docs are in sync with the spec" (pnpm --filter @objectstack/spec check:docs).
    • The log reads: content/docs/references/api/contract.mdx (out of date).
    • The cause is in ①(d): the merge is textually clean but semantically wrong. Blocking fix 1.
  • FAILED · TypeScript Type Check: the aggregate of the lane above.
  • Green:
    • Test Core (1/6) to (6/6), and the aggregate Test Core. E1's pin, apply-organization-ownership-gate.integration.test.ts, passed its 4 tests in shard 3/6.
    • Type Check · workspace, consumer gates and debt ledger.
    • Build Core and Build Docs.
    • Every Lint lane.
    • Check Changeset.
    • Governed Surface Queue Guard.
    • Validate Package Dependencies.
  • Skipped by design on pull_request: the Dogfood Regression Gate shards, Dogfood Verify CLI, Console Pin Gate, Temporal Conformance (live PG + MySQL) and Packed-tarball smoke. So no PR run exercises the dogfood pin, PostgreSQL or MySQL.

① Derived judgments

E1: os migrate apply --allow-destructive refuses an organization_id drop on a ceremony-inventory table while the marker is unverified. Right, and right against main's current ceremony.

  1. The accept-set narrowing is right, in the shape the seat ruled.
  2. The placement is right.
  3. The --json refusal code refused_organization_ownership_unmigrated is right.
  4. The new module utils/organization-ownership-drop-gate.ts is internal. Right.
    • The cli package's exports (., ./console, ./hook-body) do not reach it, and index.ts does not re-export it.
    • It mirrors comment-reactions-conversion.ts: a type import, then dynamic imports of @objectstack/platform-objects/system and @objectstack/spec/system. Both are already dependencies of cli.
  5. The pins are right.
    • The fixture. Four cases, on a v18 database with organization_id added raw to sys_platform_setting. That table is an inventory column-drop table, and v18 declares it tenant: false.
    • The cases:
      • refused, with no marker;
      • refused, with an unverified marker (blocking 2);
      • a control: an unrelated column, with no gated drop;
      • with the marker verified, the drop proceeds.
    • CI coverage. The file is *.integration.test.ts, not an e2e or live nightly tier, so the Test Core shards run it per PR.
    • "Real CLI" in the report means the command class run in-process (MigrateApply.run), not a spawned binary.
    • The ablation is the dev's claim; it was not re-run.
    • Gap, non-blocking. No case pins the whole-run consequence: a gated drop and an unrelated drop in one run, with nothing applied. A pin would hold the changeset sentence (fix 3) to the code.

The merge with main (S1, S3, S4), judged on tree 17dfffb703.

  • (a) The table set: E1's set still names every platform table S1 and S3 drop or promote. Right.
    • main left the set unchanged. Since 04a314873c, main changed one category key in the inventory and no table.
    • S3's drops. runColumnDrops takes its organization_id drops from plan tables of fate column-drop. Those are all inventory rows:
      • sys_audit_log;
      • the six D7 plumbing tables;
      • sys_metadata_activation, sys_presence and sys_platform_setting;
      • the sys_metadata family.
    • S1's promotions touch sys_metadata, sys_email_template, and sys_setting to sys_platform_setting.
    • S3's rewrite reads sys_user_permission_set.
    • All of these are in the set.
    • Where the gate bites. The set is wider than the drops: it is every inventory row. But the drift differ offers an organization_id drop only where v18 declares no column, so in practice the gate fires on the column-drop tables: the D7 tables now, and the family once S2 lands. That is what cli.mdx says.
    • Three edges, none blocking:
      • S3's sys_user_permission_set.permission_set_id drop is ceremony-owned, and it is not an organization_id drop, so E1 does not name it.
        • It is still held on every v17-shape database. The whole-run refusal fires whenever a D7 organization_id drop is in the same --allow-destructive run, and every v17 database carries those.
        • After the ceremony's column-drops step has run, the rewrite has already named every grant, so a later drop loses nothing.
      • Rotation shards. E1 compares the physical table name with inventory object names. The ceremony drops per physical table (table.tables), rotation shards (__rNNNNNN) included.
        • On open core the only rotation-sharded object, sys_activity, has fate attribution.
        • So no shard is a column-drop table today. This is latent: it matters only if a column-drop object ever rotates.
      • Host --fate-supplement rows that add an object fall outside the static set. In practice the same whole-run refusal covers them.
  • (b) The marker: the id and the verification state match what main's ceremony writes.
    • What the ceremony writes. It writes through recordDataMigrationRun(engine, { migrationId: ORGANIZATION_OWNERSHIP_MIGRATION_ID, passed: complete && blocking === 0, … }).
      • verified_at is set only by a complete run with zero blockers.
      • It is null on every checkpoint write and on every failed run.
      • column-drops is the last step, and the verified write comes after the post-check.
    • What E1 reads. E1 reads the same sys_migration row, with the same arbiter as the boot guard.
    • One definition of the constant. In the merged tree it is defined once, in @objectstack/spec/system (migration.zod.ts).
      • git combined S5's import-and-re-export hunk with main's rewrite of organization-ownership-apply.ts (S1, S3, S4) with no conflict.
      • The value is unchanged, and the command still imports it from the apply module.
  • (c) The refusal is outside the ceremony's own path. Right.
    • The ceremony's column-drops (dropColumnsThroughDrift) calls liveStack.detectManagedDrift() and applyMigrationEntries(…, { allowDestructive: true }) on its own boot, and never runs MigrateApply.
    • The only other caller of applyMigrationEntries is artifact-boot-migration.ts, and it passes allowDestructive: false.
    • main changed none of the following since the merge-base: apply.ts, serve.ts, standalone-stack.ts, migration-recovery-plugin.ts, migration-flag.ts, migration.zod.ts or schema-migrate.ts.
  • (d) The merge is textually clean but semantically wrong, twice.
    • content/docs/references/api/contract.mdx.
      • Both sides made the same edit. Each bumped the ApiError.code summary from … +326 more to … +327 more: S5 for ORGANIZATION_OWNERSHIP_MIGRATION_REQUIRED, and main for PASSWORD_RESET_REQUIRED.
      • git kept one copy. It takes an identical line once, so the merged file reads +327 more beside 335 codes, which is +328.
      • The gate measures it. check:docs reports contract.mdx (out of date), the one red step of Type Check · source gates.
      • The queue would refuse it too. lint.yml also runs on merge_group.
      • Blocking fix 1.
    • The dogfood pin organization-ownership-boot-refusal-order.dogfood.test.ts.
      • The call. Its step 2 spawns os migrate organization-ownership --apply --backup-taken --database-url … --out … with no --plan-file.
      • What main now requires. S4 (feat(cli): os migrate organization-ownership --apply executes only the reviewed plan file (ADR-0131 D10, C7b S4) #23437) landed after this branch's base. On main, --apply without --plan-file is refused before the boot: plan-file-required, this.exit(1).
      • The effect on the merged tree:
        • step 2 fails at run.status === 0;
        • the marker is never verified;
        • step 3's boot is still refused.
      • No gate catches it before landing. Dogfood runs on neither pull_request nor merge_group (6170906686). It would first go red on main's push battery after S2 and S5 land.
      • Not measured here. I read this; I did not run it. The dev's green runs (6168648434) were scratch merges cut before S4 reached main.
      • Blocking fix 2.

packages/spec/src/system/migration.zod.ts: right, and unchanged since PASS.

  • It adds ORGANIZATION_OWNERSHIP_MIGRATION_ID with its doc block, outside CREATION_ATTESTED_MIGRATION_IDS.
  • api-surface/system.json and export-origins/system.json gain it.
  • It is now the one read that E1, the guard and the ceremony share.

② Semver level

The levels match.

  • @objectstack/runtime, @objectstack/cli and @objectstack/spec all bump minor, in the fixed group. The change is BREAKING under the launch-window convention.
  • Check Changeset is green on this head.
  • The cli line is restated as BREAKING minor, "the command narrows", as 6171152049 instructed. That matches E1.

Clause-②: yes (narrowing): right. There are now two narrowed accept-sets:

  • the served boot;
  • os migrate apply --allow-destructive on an unmigrated database.

The line appears in the changeset and the body. The body does not yet describe the second narrowing (see ③).

The ADR-0087 marker is still right. It reads not-required (already-registered …).

  • E1 adds no authorable key, moves no spec key and rewrites no row.
  • The step-18 entries say "Existing rows keep the orphaned column until the v18 operator ceremony", which E1 now enforces.
  • The prior record's nit stands.

The changeset's text does not match what publishes (blocking fix 3, prose only).

  • The scope of the refusal. "any other destructive drop is unchanged", and "Only os migrate apply's drop of an inventory table's organization_id waits", are both wrong.
    • The code refuses the whole --allow-destructive run.
    • So on an unmigrated database every other destructive drop waits, and so does the additive and needs-confirm work of that same run.
    • Only a run without --allow-destructive is unchanged.
  • The order of the ceremony. "The ceremony drops these columns itself once it verifies" inverts the order. It appears in the changeset, in cli.mdx and in the refusal message. column-drops is the ceremony's last step, and the marker is written verified after it.
  • The ceremony's command line. Since S4, --apply requires --plan-file FILE. The changeset's FROM → TO line and E1's refusal message both name os migrate organization-ownership --apply without it, so they send an operator to a command that refuses.

③ Boundary flags

The s5_e1 claim in 6179430370: verified by reading, clause by clause.

The ablation and the local counts are the dev's. CI's verdict on the pins is in the gate summary above.

The #23461 body note, "Where the refusal sits", which names the wrong boot.

  • The note is right that the body is wrong. Its proposed replacement is also wrong.
  • Where MigrationRecoveryPlugin is composed. Only in two places:
    • serve.ts, step 5c-bis;
    • buildDataMigrationPlugins in packages/cli/src/utils/data-migration-plugins.ts. That is the boot of these commands:
      • os migrate meta, value-shapes, audit-metadata-bodies and recorded-by;
      • os migrate comment-reactions, resume, files-to-references and summary-nulls;
      • os storage orphans.
  • Where it is not composed. Neither createStandaloneStack nor bootSchemaStack composes it.
  • So the suggested text is false. It says "would reach every CLI data and schema boot built on createStandaloneStack, the ceremony's own (bootSchemaStack) among them". In fact an inline refusal would reach the data commands above, and never the ceremony, plan or apply.
  • The code comments are right. The comments in serve.ts 5c-ter and in the guard's header answer a different question: why the guard is not in createStandaloneStack.
  • Text for the seat's rewrite: "It is a sibling plugin because MigrationRecoveryPlugin is also composed by buildDataMigrationPlugins (packages/cli/src/utils/data-migration-plugins.ts), the boot of the os migrate data commands and os storage orphans; a refusal inside it would refuse those commands on a v17 database. The ceremony boots on bootSchemaStack, which composes neither plugin."
  • The rest of the body. Also add E1 and its pins, and move Verification to the new head. Non-blocking; this is the seat's.

The other items in 6179430370.

  • Open question 1 (under E2, single now refuses a plain member's data-door read of the three trail tables). This is S2's surface and not in this diff. It departs from the seat's own pin text, "single is unchanged", so it is escalated to the seat.
  • Open question 2 (the /meta trail doors admit setup.access). This predates this PR and is not in this diff. It is the seat's call; filing a card (B) is proportionate.
  • Out-of-scope findings:
    • Turso remote on an unmigrated database: S2's.
    • dialectOf and resolvePlannedDriverExec reading config.client as a string: not E1's concern. E1 reads the marker through the engine, with no raw SQL and no dialect.
  • The #23469 body note: S2's.

Earlier escalations.

  • MongoDB was closed by the maintainer (6171271939). E1 adds nothing there: the ceremony runs only on SQL.

Escalation to the seat: the landing form (6171152049, "two PRs queued together") meets the same collision.

  • Both PRs edit the same line. By 6179430370, S2 registers LegacyOrganizationRowsUnreadable and regenerates contract.mdx. After fix 1, S2 and S5 will each move … +327 more to … +328 more from the same main.
  • What the queue does with that. In a queue, the second PR's merge_group build merges an identical line, with no conflict, over 336 codes. check:docs then refuses it, the PR is ejected, and under 6171152049 the first is reverted.
  • Not measured. S2's tree is outside this review's inputs.
  • The seat chooses one of:
    • one PR, which ruling 6163114098 allows;
    • landing in sequence, with a main merge and a regeneration between the two, with the revert plan restated.

The landing conditions in 6170906686 still hold. The e2e pin is green only beside S2, and Fixes #23429 closes the card when S5 merges.

Implemented-by: claude/issue-23429-boot-refusal
Reviewed-by: session_01Rerax7QTjKMPCUZxQUtPFR

VERDICT: FAIL

  1. Merge main and regenerate the spec reference docs. Ruling [Decision] After a PR is open, merge main only on a conflict, a main-caused CI red or a queue removal, and re-check only the overlap; or keep merging main before every push #23390 allows this for a main-caused red.
    • Run pnpm --filter @objectstack/spec gen:schema && pnpm --filter @objectstack/spec gen:docs, so that content/docs/references/api/contract.mdx reads … +328 more.
    • Type Check · source gates (check:docs) must be green on the new head.
  2. Bring the dogfood pin onto main's ceremony (organization-ownership-boot-refusal-order.dogfood.test.ts, step 2).
    • Run the plan first: os migrate organization-ownership --database-url file:… --out PLAN.
    • Then run --apply --plan-file PLAN --backup-taken.
    • Re-measure all four steps on a scratch merge of S5 + S2 + current main, and report it. The pin is green only beside S2.
  3. Changeset, cli.mdx and refusal message, prose only.
    • The scope of the refusal. State that it holds the whole --allow-destructive run, as its two siblings do, and that a run without --allow-destructive is unchanged. Replace "any other destructive drop is unchanged" and "Only … waits".
    • The ceremony's order. Replace "once it verifies" with "in its column-drops step, before it records the run verified".
    • The ceremony's command line. Name os migrate organization-ownership --apply --plan-file FILE where the text names the ceremony's apply: the FROM → TO line and E1's refusal message.

Scope of the re-review: the delta after this head (the main merge, the regenerated contract.mdx, the pin and the prose). No logic change to E1 is asked for.


Generated by Claude Code

This was referenced Oct 14, 2026
@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

Superseded — closing unmerged. Seat 1 (domain:engine), 2026-10-15T01:40Z.

Per landing ruling 6180024026 on #23429, S2 and S5 land as one PR, #23469. Both register a dispatcher error code and regenerate the same contract.mdx line, so two queued PRs would eject each other.

Nothing more is pushed to claude/issue-23429-boot-refusal.


Generated by Claude Code

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

Labels

dependencies Pull requests that update a dependency file documentation Improvements or additions to documentation protocol:system size/xl tests tooling

Projects

None yet

2 participants