Skip to content

fix(cli): os migrate meta --write rewrites the declared protocol range the load still refuses - #22252

Merged
objectstack-fleet[bot] merged 4 commits into
mainfrom
claude/issue-22219-migrate-meta-write-range
Oct 8, 2026
Merged

objectstack-fleet[bot] merged 4 commits into
mainfrom
claude/issue-22219-migrate-meta-write-range

Conversation

@objectstack-fleet

Copy link
Copy Markdown
Contributor

Fixes #22219
Clause-②: no

What changed

os migrate meta --from N --write now rewrites the manifest's declared protocol range when the load would still refuse the migrated source under it. Before, it wrote the chain's mechanical edits and left engines: { protocol: '^16' } as it was, so the load refusal that names this command as its remedy came straight back.

  • Which range: --to, capped at the protocol this runtime implements, in the scaffold's spelling ('^17' on this build). See H5 for why the cap is needed.
  • Where: in place, under the key the handshake read the range from (engines.protocol, else engines.platform, else the legacy engine.objectstack). See H2.
  • How: the edit rides the chain's own plan, write and re-check. The planner treats it as one more applied entry with one more change. A re-check that disagrees restores it with every other file, and a range edit that does not land is caught by that same re-check. See H3.
  • Nothing to convert: the range edit is written on its own. See H4.
  • Left alone: a range the load admits ('>=16'), an absent or unparsable range, and a range at or above the target (a range is never lowered). A range that --from starts above is also left, with a line naming the --from that would move it.
  • Output: the human report names the file, the line and the from → to text. A dry run names the edit --write would make. --json reports the edit as write.range, a NEW key (see H6).

Measured on main 7b926f76 (H1, premise holds)

The CLI was built from that tree and each load went through os serve, with --from 16:

  • Fixture A: ^16 plus a tombstoned views[0].list.striped.
    • Before: the schema refuses it. ✗ views.0.list.striped: … Run os migrate meta --from 16 …
    • --write reported Wrote 1 of 1 mechanical change(s) into 1 file(s). The reload was then refused: ✗ package 'com.example.h1' targets protocol ^16 (engines.protocol) but this runtime is protocol 17.0.0. This is a major-version break. Run: objectstack migrate meta --from 16
  • Fixture B: ^16 with nothing to convert.
    • Before: the same refusal at the handshake.
    • --write reported --write: the chain made no mechanical change here, so no file was written. The reload returned the same refusal.

The PM's readings, measured

  • H2: the three homes.
    • No chain step reads or writes any of the three keys (no conversion or migration entry names engines or engine).
    • The schema accepts all three (engines.platform: '^16' and engine.objectstack: '^16.0.0' both parse).
    • The handshake still reads each one when engines.protocol is absent. Both are refused with migrateCommand: objectstack migrate meta --from 16.
    • Moving a range between keys would be a conversion the chain has not declared. The key the handshake read is the key it reads again on reload. So the edit is in place, and both legacy homes are pinned.
    • One measured wrinkle: engine.objectstack: '^17' is refused at parse (invalid_format, because the schema wants a full version). That home is therefore written '^17.0.0'.
  • H3: the plan. The range is a RangeRewrite input to planAuthoredSourceWrite. It joins as the last entry and is reported apart (plan.range). verifyAuthoredSourceWrite takes the range the written sources still owe:
    • when the range was written, it must be absent from the re-run;
    • when the range was left, it must still be there.
      The restore case is pinned. Its error reads still converted: manifest.engines.protocol (declared protocol range).
  • H4: no conversions. Measured above (no file was written). The range edit is now written alone, and the report says so.
  • H5: which range.
    • os init, the create-objectstack blank template and os lint's fix all write '^' + PROTOCOL_MAJOR, and that form is used here. A range that already admits the target ('>=16') is pinned as a no-op.
    • --to: on this build --to defaults to 18 (the chain's terminus) while the runtime implements 17.
    • A literal '^18' is refused by this runtime. Measured through os serve: targets protocol ^18 … Run: objectstack migrate meta --from 18. So the written major is min(--to, PROTOCOL_MAJOR): --to above the runtime writes the runtime's major, and --to below it writes --to.
  • H6: --json. The edit does not fit the existing write.written / write.manual lists. Those list exactly the chain's applied entries, an invariant already pinned (writes the applied set and nothing else), and the range is not one of them. It is reported as a new key, write.range: status, path, from, to, plus file / line when written or kind / reason when left. Per the claim, the seat amends the Clause-② line if this key needs it; line 2 above is no as dispatched. A dry run's --json carries no range, because that would be a new top-level key.

Pins (packages/cli/test/migrate-meta-protocol-range.test.ts, unit tier)

The pins run in-process over MigrateMeta.run, and each reload goes through the load's own door: the CLI's loadConfig (whose defineStack runs the schema), then AppPlugin.init (whose handshake runs before the manifest registers).

  • --from 16 with a converted key:
    • Control: the load refuses it (STACK_SCHEMA_INVALID, naming migrate meta --from 16).
    • The dry run names the edit and writes nothing.
    • --write rewrites the range and the key, and only those bytes change.
    • The reload is not refused.
  • --from 16 with nothing to convert:
    • Control: the load refuses it at the handshake (OS_PROTOCOL_INCOMPATIBLE, status 422, migrateCommand: objectstack migrate meta --from 16).
    • --write writes the range alone and says so.
    • The reload is not refused.
  • '>=16' is left byte for byte, and write.range is absent.
  • Legacy homes: engines.platform and engine.objectstack are rewritten in place, and each reload is not refused.
  • --to is capped at the runtime's major. A --from above the range is left, and the report names --from 16. The planner never lowers a range, and never touches an absent, unparsable, admitted or non-string range.
  • Codemod:
    • a forced re-check mismatch restores the range with the chain's edit;
    • a range edit that does not land is caught and everything is restored;
    • a shared range literal is refused (shared) while the chain's edit beside it is written.
  • The 17 → 18 shape is NOT added here (it waits for feat(spec)!: PROTOCOL_VERSION 17 → 18 in an ordinary PR — regenerated spec-changes.json and upgrade guide, ^18 handshakes, pre-mode lockstep exception (#22085 Q1 → B) #22215). It is one more SHAPES row:
  { from: 17, member: "dashboards: [{ name: 'ops', label: 'Ops', widgets: [], refreshInterval: 300 }]", converted: ['dashboard-refresh-interval-to-refresh-interval-seconds'], before: 'refreshInterval: 300', after: 'refreshIntervalSeconds: 300' },

Ablation (one-off, meta.ts restored to a blob equal to HEAD ecbcd924, git diff HEAD empty)

The test imports meta.ts relatively, so it resolves to source and no rebuild applies. Each mutation went through scripts/ablation-replace.mjs, which reported anchor 1 → 0 and a changed blob. Both legs ran with a trap that restores from HEAD.

  • A: the range taken out of the plan. The range argument to planAuthoredSourceWrite was deleted.
    • Result: 8 failed, 6 passed.
    • With a converted key, the re-check caught the missing range and restored every file. The reload is then red with the schema refusal, because the write was undone. This is a different direction from B, and it is the H3 guarantee doing its job.
    • With nothing to convert, nothing is written.
  • B: no range owed at all, which reproduces the world before this fix.
    • Result: 11 failed, 3 passed.
    • The reload pin turns red with exactly the measured refusal: ProtocolIncompatibleError … targets protocol ^16 (engines.protocol) but this runtime is protocol 17.0.0 … Run: objectstack migrate meta --from 16.

Verification (at 9578a2af, main f4bed583 merged in)

  • New pin file: 14/14 passed. It is a member of the unit project (vitest list --project unit).
  • @objectstack/cli unit project: 264 files, 3888 tests passed.
  • The migrate meta and codemod files (7 files): 75 passed, plus 1 pre-existing skipIf skip.
  • pnpm --filter @objectstack/cli typecheck: exit 0. The tsc --noEmit and check:test-typecheck steps both pass, and the new test is in tsconfig.test.json's program.
  • pnpm lint (full eslint . --no-inline-config): exit 0.
  • dispatch-gates --ran: 65 derived, 65 run, 0 NOT-MEASURED, 0 UNRUN. The list is identical to the dispatched one.
    • check:dual-build-cjs-loads and check:i18n-coverage first exited 3 (PREREQUISITE NOT MET: dist absent for packages outside the CLI closure). Re-run once those dist trees existed, both exit 0.
  • Integration tier: declared to CI. The diff touches no integration-tier file and no spawn entry.

Acceptance notes

  • Docs. content/docs/upgrading.mdx (the --write row and callout) and skills/objectstack-data (--write applies the proven sites, landed in docs(skills): objectstack-data says what os migrate meta --from 16 does: lists the edits, --write applies the proven sites #22199) do not mention the range rewrite. Both are outside this card's file surface (content/docs) or a governed surface (skills/**).
  • --out snapshot. The --out snapshot (result.stack) keeps the authored range. The docs already call it an oracle to diff against, not a file to ship.
  • Duplicate narrowing. handshakeSlice in meta.ts repeats the type narrowing that toHandshakeManifest in utils/protocol-version-gap.ts does. That file is not on this card's surface, so it was not exported from there.
  • Load-path conversions are never written. A key that a load-path conversion converts (retiredFromLoadPath: false, e.g. driver: 'mongo') in a stack the schema otherwise accepts is converted by defineStack during the authored-source load. The chain therefore never sees it, and --write never writes it. Measured: --from 16 --write --json gives applied: [] while the source keeps 'mongo' and the load warns. Reported to the PM; not changed here.

Generated by Claude Code

@github-actions github-actions Bot added size/l documentation Improvements or additions to documentation tests tooling labels Oct 8, 2026
@github-actions

github-actions Bot commented Oct 8, 2026

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 1 package(s): @objectstack/cli, touching 27 documentable anchor(s).

19 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 73a0a6bf1db3216a0c82bee1503011040c6a1394.

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

What this run could not see
  • 4 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 — 28 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 73a0a6bf1db3216a0c82bee1503011040c6a1394 → packageMentionDocs.

Which tree this was computed on

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

node scripts/docs-audit/affected-docs.mjs --json 73a0a6bf1db3216a0c82bee1503011040c6a1394

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

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/l tests tooling

Projects

None yet

2 participants