fix(cli): os migrate apply refuses before writing any DDL on an unloadable host config - #13383
Conversation
…st config (#13118) Maintainer ruling 2026-08-29, verbatim 「同意」, option 2: refuse first, write no DDL, exit non-zero. #12953 had ruled only the exit status, so the same run reconciled the operator's schema against a set it had just called UNMEASURED. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TvqBFLRzXdSPcbusDoED9k
…ays at 144
`check:type-check-debt --re-measure` measured @objectstack/cli at 145 raw
errors against a frozen 144: the knex handle was reached through
`{ knex: (t: string) => unknown }`, so `.select()` on it was TS2571. The
ratchet is shrink-only, so the fix is the error, never the entry.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TvqBFLRzXdSPcbusDoED9k
📓 Docs Drift CheckThis PR changes 1 package(s): 7 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:
What this run could not see
Coarse fallback — 23 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): Which tree this was computed onThis run read A worktree cut from an older # while this PR is open — GitHub drops the merge commit once it closes
git fetch origin 320689c76312f83f3b8269e0b3b603e0758d98a8 && git checkout 320689c76312f83f3b8269e0b3b603e0758d98a8
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin ececf7a216eec0128b5c97ed6462b68caed7a179 7df22cf6baf4743dc609508a9888c5c947eda276 && git checkout -B drift-repro ececf7a216eec0128b5c97ed6462b68caed7a179 && git merge --no-ff 7df22cf6baf4743dc609508a9888c5c947eda276
node scripts/docs-audit/affected-docs.mjs --json ececf7a216eec0128b5c97ed6462b68caed7a179
|
PM 复核:ACCEPT-on-green,已 undraft。两条硬前置都真的跑了。
裁决的两条硬前置 —— ⛔ 没有一条被跳过① 部分 apply 的收敛性:测了,带正控,结论是「收敛」。 projA 降级 apply(EXIT=1,9 表)→ 修好 config → 全量 apply(EXIT=0),对照 projB 从未降级的单次全量 apply ⇒ ⇒ ⭐ 所以本改动是契约诚实,⛔ 不是数据抢救 —— 裁决预判的两种走向里是较轻的那一种,且裁决说「收敛 ⇒ 照常执行」,照做了。⭐ PR 还申报了这次测量的边界:降级集是严格子集,所以修复那次运行只剩增量工作。⛔ 没有把结论说得比读数大。 ② 引导路径侦察:没有真实的 bootstrap 路径,且是实测的。 实现本身网关放在 ⭐ ⭐ 最漂亮的一处:正控内建在三方向里「零张表」作为读数毫无价值,除非同一个探针能在有表时看见表。⇒ 方向 2(config 缺席)与方向 3(config 可加载)就是那个正控:同一个命令、同一个 helper、同一次运行。若 另外两处同级的严谨:
⭐ 一道门红了,被修掉而不是被豁免
⛔ 自报的协议违规:本席核过规则原文,dev 的自我定性是准确的dev 主动报告:曾用一次 ⛔ 本席没有采信自述,而是读了规则本身。
⇒ 规则是绝对的,⛔ 没有"自己的分支"例外,且逐字点名了 实际损害:实测为零。 自己的分支、PR 之前、⛔ 无第二个行为者、之后每一次推送都是普通提交。⛔ 但"没造成损害"⛔ 不等于"没违规",本席也不把它记成豁免。 ⭐ 主动上报是正确的动作,本席明确记下来:一次被隐藏的 force-push ⛔ 无法从 PR 上看出来,代价会由下一个撞上它的人付。⇒ ⛔ 不因为自报而加重,也 ⛔ 不因为无损害而抹去。
交付不因此作废:force-push 发生在 PR 之前、只动了自己分支的历史,全部读数取自 另两条卡外产出① CDN 缓存滞后 ⇒ 已立卡 #13387(p1, ② 两条 comment 通道的解析陷阱 —— 记在 #13165 上(那张卡正是"MCP 返回的 body 实体被转义")。 处置:ACCEPT-on-green。 CI 完整跑绿后 arm。 Generated by Claude Code Generated by Claude Code |
Fixes #13118
Maintainer ruling 2026-08-29, verbatim 「同意」, option 2 (two concurring comments, 14:20Z and 14:55Z):
#12953 / PR #13113 shipped the exit-status half. This is the behaviour half.
All readings below were taken on this branch at
7df22cf6ba, except the "before" measurements, which were taken on the branch point3ec8646f1b(origin/main) with an unmodified tree — that ordering is what makes them a before/after pair.What changed
packages/cli/src/commands/migrate/apply.tsgains one gate, placed exactly:detectManagedDrift()only reads the catalog), and os migrate plan exits 0 when the host config exists but could not be loaded — the one remaining UNMEASURED shape after #12938 #12953 pinned that the refusal replaces the STATUS, not the document. The plan and the--jsonpayload still reach their reader,composition.hostConfigLoadedincluded;flushSchemaDdl()andapplyMigrationEntries()— the only two calls in the file that write.packages/cli/src/utils/schema-migration-plugins.tsgains an opt-innoDdlExecutedoption on the shared refusal helper, plus the exportedNO_DDL_EXECUTED_NOTICEsentence. It is opt-in rather than deduced from the command name because it is a claim about what a particular run did to the operator's database: only a call site that actually returned before its mutating work can honestly make it.os migrate planpasses nothing and its message is byte-identical to #12953's.⛔ No flag, env var or escape hatch — option 3 was refused in the same ruling.
⛔
os migrate planis untouched.Precondition ①: does a partial
applyconverge?Answer: YES, it converges — with a positive control that proves the comparison can see a difference.
Fixture: a project whose
objectstack.config.tsthrows unlessOS_E2E_13118_SECRETis set, and which (when it loads) declarescv_ticketandcv_note. Schema read withbetter-sqlite3oversqlite_master(type, name, tbl_name, sql; whitespace-normalised;sqlite_%excluded). Deliberately not a file hash — SQLite rewrites header bytes on any read-write open.Step 1 (the degraded run, on
origin/main) created 9 tables, none of them the deployment's:Main comparison — A (partial, repaired, full) against B (full only):
Positive control — B (full) against C (config one field short):
So "identical" above is a measurement, not a blind read.
What this means for the ruling. Convergence holds, so option 2 is contract honesty, not data rescue — it is not upgraded to a data-safety necessity. Per the ruling, it is implemented anyway; the cost is simply low.
One honest boundary on the reading: the degraded object set is a strict SUBSET of the full one (the deployment's own objects are absent, not mis-shaped), so the repairing run only has additive work to do. That is why it converges, and it is also why the convergence result does not generalise to a hypothetical degraded run that could shape a table differently.
Precondition ②: bootstrap-path reconnaissance
Answer: NO real bootstrap path found. Not hypothesised — measured, and provable in code.
The card's worry was that a config-broken deployment might depend on
applyto create the platform floor in order to become fixable. It cannot, because a config-broken deployment cannot serve at all:os serveloads the host config with no fallback —packages/cli/src/commands/serve.ts:2169,bundleRequire({ filepath: absolutePath, ... })— and the command's own top-level catch turns any throw intoprintError(...)plusthis.exit(1)(packages/cli/src/commands/serve.ts:4499-4508). Measured on the same fixture:os startspawnsserve, so it inherits that refusal.No container entrypoint, CI workflow, package script or scaffold template runs
os migrate apply.docker/Dockerfile:67isCMD ["os", "start"];grepformigrateacross.github/workflows/*.ymlreturns only prose.The docs place the floor at boot, not at
apply:content/docs/deployment/self-hosting.mdx:116— "Safe migrations run at boot" — and everymigrate applymention is a deliberate operator step after reading a plan.A floor created for a deployment that cannot boot is a floor nothing can use; the path back is fixing the config, which is also the path back to a bootable deployment. So the card does not go back for re-ruling.
The three-direction pins, all three measured on BOTH halves
packages/cli/test/migrate-apply-refuses-before-ddl.e2e.test.ts— real child processes throughbin/run-dev.js+ tsx, and asqlite_masterread with a connection of the test's own after the child exits.origin/main)sys_metadata+ 4)lo_ticket+ the platform floor, unchangedDirections 2 and 3 are the built-in positive control for direction 1: the same probe, the same run, seeing tables when tables exist. A blind probe turns those two red while direction 1 stays green.
The refusal's own wording, captured from a human-mode run after the change (exit 1, 0 tables afterwards):
Reverse verification — direction predicted BEFORE it was run
Prediction, recorded first. Deleting only the
#13118 REFUSE BEFORE ANY DDLgate (leavingrun()'snoDdlExecuted: trueand the helper untouched) goes RED, and PARTIALLY red — exactly two assertions, both in direction 1: "wrote NO DDL" and "reports the refusal in the --json document". Predicted to stay GREEN: direction 1's exit-status assertion, direction 1's stderr-wording assertion, all of directions 2 and 3, the whole unit file, and the whole #12953 e2e. Those greens are the point: they show the new pins measure the MUTATION rather than re-testing #13113's exit code, and the stderr one reproduces the exact failure this card exists to end — a run that SAYS "NO DDL WAS EXECUTED" while having executed DDL.Observed — the prediction held in every term.
…(8)is 9 tables — the same 9 the before-measurement recorded.Mutation proven on disk, not by an exit code:
Restore proven on disk, by the same instrument:
No rebuild was needed, and that is checked rather than assumed. The e2e spawns
packages/cli/bin/run-dev.jsthrough tsx, which executespackages/cli/srcdirectly (packages/cli/test/helpers/serve-process.tsstates it: "runs the command fromsrc/through tsx — so a file usingrunServe()needs nopackages/cli/distat all"), and the unit file imports its subject by relative path insidesrc. The observation is its own control: the ablation changed behaviour with no build, so the child read source. The restored tree is byte-identical by blob hash to the tree that produced the green run below.Tests
Run at
7df22cf6ba, the final commit:pnpm --filter @objectstack/cli typecheck— exit 0.Gates
Union derived from the real change set, not from memory:
node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstackat7df22cf6ba, 5 paths, plus the convention-triggered family it names for a diff that adds test files. Each line below is the gate's OWN verdict, with the exit code captured before any pipe.Repo-wide lint, the whole population rather than a narrowed one —
pnpm lint(eslint . --no-inline-config) — exit 0, no output, 1m11.9s.One gate went RED and was FIXED, never waived
pnpm check:type-check-debtfirst reported:The new error was mine —
packages/cli/test/migrate-apply-refuses-before-ddl.e2e.test.tsreached the driver's knex handle through a type that erased tounknown, so.select()on it was TS2571. The ratchet is shrink-only, so the fix is the error: the handle is now typed structurally to exactly the two calls the test makes. Re-measured: 144, equal to the frozen entry. ⛔ The ledger entry was not touched.NOT MEASURED — recorded as such, never folded into the green list
node scripts/check-test-completeness.mjs— exit 3,PREREQUISITE NOT MET: "this gate grades a savedturbo run testlog, and no log was named". Its own text says the local reading for this gate is NOT MEASURED and that it is not a red. CI tees it a log on every invocation, so CI's reading is unaffected.node scripts/pm/check-half-states.mjs— exit 3,PREREQUISITE NOT MET — the token in the environment is not a valid GitHub credential. Nothing was swept. This container has no real GitHub credential; it is a board sweep and unrelated to this diff.pnpm --filter @objectstack/cli typecheckreadspackages/cli/tsconfig.json, whoseincludeis["src"]. Verified withtsc --listFilesrather than assumed:apply.ts,schema-migration-plugins.tsandschema-migration-plugins.test.tsare each read (1 hit);test/migrate-apply-refuses-before-ddl.e2e.test.tsis read 0 times. That file is therefore NOT covered by the package'stypecheckscript — but it IS type-checked, bycheck:type-check-debt --re-measure, whose generated program includes the hidden test tree and which measured this package at exactly 144. Both readings are stated because only the pair is honest.Changeset
.changeset/migrate-apply-refuses-before-ddl.md,"@objectstack/cli": minor.Why
minorand notpatch, in one sentence: #12953's exit-status half shipped asminorunder the same repo launch-window convention for a deliberate change to a published command's observable behaviour, and this half additionally adds a new--jsonmessagevalue (refused_unloadable_host_config) that a consumer can branch on — so it is a surface addition, not a defect repair.⛔
skip-changesetis not applicable: this publishes from@objectstack/cli.Generated by Claude Code