Skip to content

feat(cli): os migrate meta --write — write the chain's mechanical edits into the authored sources - #22108

Merged
objectstack-fleet[bot] merged 7 commits into
mainfrom
claude/issue-9591-migrate-meta-write
Oct 7, 2026
Merged

objectstack-fleet[bot] merged 7 commits into
mainfrom
claude/issue-9591-migrate-meta-write

Conversation

@objectstack-fleet

Copy link
Copy Markdown
Contributor

Refs #9591 (os migrate meta --write; the retirement-sentence restoration goes to the spec lane)
Clause-②: yes

What this adds

os migrate meta --from N --write writes the chain's mechanical changes into the authored source files in place, at every site it can prove is one literal in one project file. It lists every other mechanical change with the reason it was not written. It never writes a semantic TODO. Without --write nothing changes: the dry run, its report and its --json payload are what they were, and --out still writes its snapshot.

New module: packages/cli/src/utils/authored-source-codemod.ts (it uses the ts compiler namespace that ts-morph re-exports, the same way emitted-source-parses.ts does). The command change is in packages/cli/src/commands/migrate/meta.ts.

The widened surface, stated for the contract review

  • The flag. --write is a boolean that defaults to false, on the authored-source mode only. It declares exclusive: ['stored'], so os migrate meta --stored --write is refused by oclif's flag validation before any work, with exit 2 and --stored=true cannot also be provided when using --write. This is the same mechanism as --out / --from / --to / --step. --stored / --apply are untouched.
  • What it writes. It writes each entry of the chain's applied set at a site it traces to one object or array literal in one project file. The trace goes through define* calls and the .create(…) factories of @objectstack/spec exports, module-level const bindings, relative imports and re-exports (named and export *), and Object.values() over a namespace import, in the module namespace's sorted order. A site is written only when three things hold:
    1. the loaded value matches the literal's statically known values;
    2. every binding crossed on the way has no reference other than the walk's own;
    3. the file is under the config's directory and not under node_modules.
  • How it writes. Edits are text splices at node positions, so every byte outside an edited site stays as it was: comments, formatting and key order. A renamed key keeps its value text and its comments. A removed key takes its own line(s), including a comment on those same lines; comment lines of their own are kept. A new key is appended after the last key, on its own line or inline, matching the literal.
  • What it refuses. Each refused change is listed under --json write.manual[] as { conversionId, path, kind, reason }, and in the human report as not written [kind]: reason. The kinds form a closed set:
    • computed: the value is an expression;
    • helper: the value is built by a call that is not a define* helper or a spec .create;
    • spread: a spread supplies the key or may override it;
    • shared: a binding on the way is referenced elsewhere;
    • outside-project: a package, a path alias, or a file outside the project;
    • mismatch: the loaded value disagrees with the literal;
    • injected: the loader supplied the key, such as a map-form collection's name;
    • unspellable: no literal spelling exists for the new value;
    • layout: a comment or other code shares the site's line, or the edit would not parse;
    • unattributed: no edit could be tied to the entry;
    • entangled: a conversion's edits are written whole or not at all.
  • What it never writes. It never writes a semantic TODO (todos), because the planner is not even handed them. It never writes a site a conversion declines, because a declined site produces no mechanical change.
  • Fail-closed. It writes nothing (status: 'unwritten', exit 1) when a file changed on disk after it was read. After writing, it re-loads the config and re-runs the chain. Unless the re-run applies exactly the changes it left (matched by path (conversionId)), it restores every written file to its previous bytes (status: 'restored', exit 1).
  • Output. The human report gains one group after the semantic notices and before the data-migration advice, which stays last: Wrote N of M mechanical change(s) into K file(s):, per file and line, then the list left to apply by hand, then the re-run verdict. --json gains a write key only with --write: status, files, written, manual, unexplained, verification, and error when there is one.
  • Text (H8). The help text, the printed group and the docs say "in place" and "traced to one literal". None of them says "automatically", and the pins hold that.

PM readings, measured (on 3d918850; re-run on the merged tree b1799bf8)

  • H1, confirmed. On a fixture project that carries 9 retired sites across 7 per-artifact modules, os migrate meta --from 16 --out snap.json printed Applied 9 mechanical change(s): with one line per site. The md5 of every source file was identical before and after the run. The only file written was the snapshot.
  • H2, confirmed, and it is the design. An applied entry carries { toMajor, conversionId, surface, from, to, path }. Its from / to are display strings, not edits (striped → (removed), 'previousPeriod' → { kind: 'previousPeriod' }). Its path points into the normalised stack. Its anchor differs per conversion: the renamed-to key, the removed key, or a container (path: 'api' for api.requireAuth). So the edit is taken from a structural diff of the stack the chain started from against the stack it produced. Each change is tied back to the entries whose paths explain it, under three ordered rules: (1) the site, a key inside it, or a container around it; (2) a sibling key, which is a rename's old key; (3) a move between two containers of one subject. Writing then requires the proof above, and every refusal class met is in the list above.
  • H3, confirmed on fixtures. Every written file equals its old bytes with only the attributed sites edited, asserted byte for byte for every file in the project, barrels included. A second run applies 0 mechanical changes and writes nothing.
  • H4, confirmed. Semantic TODOs are never written. With --write, todos lists exactly what the dry run of the same build lists. Per the seat's heads-up about migrate meta: the stack-derived relevance proof that #20620's ruling names as the only exit for a notice is unbuilt — 17.7.0 still prints all 304 protocol-18 manual notices to an app none of them applies to #22072, this is pinned relative to the dry run and not as a fixed listing.
  • H5. The convention followed is the house convention for source-file writers, not the --stored --apply prompt. os i18n extract --out, os datasource introspect and os generate take their flag as the opt-in, with no prompt. The confirm() + --yes prompt belongs to row rewrites, where a write mints a checksum and a history entry. It is also unreachable here: --yes is a --stored-only flag that storedOnlyFlagsIn refuses outside --stored, and that code is off limits to this card. The fail-closed re-run is the safety net instead. --write --stored is refused, as the PM leaned.
  • H6, confirmed. A key is stripped (view.list.striped, agent.knowledge, flow.active, object.tenancy.organizationField). A value is rewritten: datasource.driver is a scalar to a scalar, and compareTo: 'previousPeriod' becomes { kind: 'previousPeriod' }, a scalar to an object. A key is renamed (refreshInterval → refreshIntervalSeconds, value and trailing comment kept). A key is added (config: { mode: 'inclusive' } on decision nodes, on its own line and inline). The partial case: compareTo: { offset: '1y' } is written as { kind: 'previousYear' }. The uncovered { offset: '7d' } produces no mechanical change, so --write leaves its bytes as they are. The schema verdict still refuses it and the semantic notice still names it, exactly as before.
  • H7, confirmed. The module names no conversion. A pin reads the module source and asserts that none of the chain's conversion ids appears in it. ADR-0120 协议 18 项:D2 conversion(声明索引 unique: true → 'global')+ 裸 true 硬拒 + synonym pin 退役 —— 挂 18 列车,现在勿动工 #5082 and Phase 2 of #11333: retire the legacy string[] arm of manifest.permissions (major, standard retirement route) #13458 conversions are covered when they land.
  • H8, confirmed (see "Text" above). One more sentence changed for H8: the stored-only refusal said the authored-source mode "writes nothing but --out". It now says it writes "only the --out snapshot and, with --write, the authored sources".

Evidence

  • Real-app round trip. A copy of examples/app-crm at b1799bf8 was back-dated in 3 sites over 2 files: a view striped: true reached through a named-re-export barrel and Object.values(views), and two dashboard compareTo values in a typed plain literal. --from 16 --write wrote all 3, and the re-run verdict was ok. All 32 source files then compared byte-identical to examples/app-crm at b1799bf8. The unmodified copy reports applied: 0 and writes nothing.
  • Pins (packages/cli/test/migrate-meta-write.test.ts, unit tier, in-process, temp projects that link the real @objectstack/spec). They cover:
    • exactness over every file;
    • idempotence;
    • the applied set as the written-or-left set;
    • todos as listed by the dry run, and the declined 7d arm untouched;
    • the add shape;
    • the dry-run and --out controls;
    • both failure exits (unwritten, and restored to identical bytes);
    • every refusal kind: 6 kinds over 7 sites of a real project through the command, and 5 more through the planner;
    • --stored --write refused;
    • the help text;
    • the no-conversion-id pin.
  • Ablations, run from the committed state through scripts/ablation-replace.mjs, each restored with the blob equal to HEAD and git diff HEAD empty:
    1. Disabling the shared-binding check turned 3 of the refusal pins red: the shared literal was written.
    2. Deleting only a member's node text, not its own lines, turned 3 exactness and idempotence pins red. The parse guard refused the broken edits, so the written set shrank.

Semver

@objectstack/cli: minor. A new flag is additive, and Clause-②: yes requires at least minor. main is not in Changesets pre mode: origin/main at bafb58bb has no .changeset/pre.json. check-changeset-no-major is green.

Acceptance notes

  • The retirement sentence in packages/spec/src/shared/retired-key.ts is not touched. It belongs to the spec lane, per the claim. Its current wording, "list the mechanical edits for existing sources; apply them by hand", stays true and only undersells --write.
  • skills/objectstack-upgrade/SKILL.md still says the command "writes nothing but --out" in its troubleshooting row. That stays true for the default invocation and undersells --write. It is a Tier H governed surface, so it is not touched here. Carrier: the spec lane's sentence-restoration PR, or the skills owner.
  • The authored mode has no row in content/docs/deployment/cli.mdx. Only --stored does. The authored mode's flag table lives in content/docs/upgrading.mdx, so --write was added there, and the cli.mdx paragraph on the two modes now names it.
  • Measured limits of the walk, all in the refusing direction:
    • a tsconfig paths alias is refused as outside-project;
    • a value reached only through a dynamic import() or a require() is not read;
    • the shared-binding count reads the files in the static import graph.
    • The post-write re-run is the backstop for convergence.

Deviation, stated rather than taken silently

content/docs/upgrading.mdx is outside the claim's file surface. It said "os migrate meta does not rewrite your source files" and called --out "the only file the command writes". This change makes both sentences false. os-dev.md requires fixing a published sentence the change makes false, and it wins over the dispatch on conflict. So the callout, the flags table and the loop comment were corrected, and the conflict is reported to the seat.

Local verification (head be29d66f unless noted)

  • pnpm --filter @objectstack/cli exec vitest run --project unit at b1799bf8, the merged tree: 260 files, 3815 tests passed.
  • At be29d66f: the 4 meta test files passed, 52 tests (migrate-meta-write 23, meta.stored-flags, meta.report-order, migrate-meta-strict-factories).
  • pnpm --filter @objectstack/cli typecheck: exit 0, including check:test-typecheck.
  • The integration tier is declared to CI. The diff touches no integration-tier file and no spawn entry.
  • The derived gate union (node scripts/pm/dispatch-gates.mjs --commands) at be29d66f: 96 commands, all exit 0. --ran reconciliation: 96 derived, 96 run, 0 NOT-MEASURED, 0 UNRUN. The PM's list plus pnpm check:docs-image-tag was added by derivation.
  • pnpm lint (the full eslint . --no-inline-config) at be29d66f: exit 0.
  • One earlier gate pass was discarded because it ran while a build was rewriting packages/spec/dist. Its three exit-3 results and one exit-1 result were build-in-flight artefacts, NOT MEASURED. Every number above is from the clean re-run.

Generated by Claude Code

claude added 5 commits October 7, 2026 15:52
The human report keeps its indentation (a --write failure exits from inside
the try, and the catch rethrows an oclif exit), so a later merge of main
reconciles cleanly. The pins hold that --write writes exactly the applied set,
that semantic TODOs are listed as the dry run lists them (not a pinned
listing), and that both failure exits leave no file half-written.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RWZbGvPFcRKvUqASZtunCU
@github-actions

github-actions Bot commented Oct 7, 2026 •

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

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

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

  • content/docs/ai/skills-reference.mdx (via node_modules (literal, a string literal in locate))
  • content/docs/automation/flows.mdx (via os migrate meta (command, read off packages/cli/src/commands/migrate/meta.ts))
  • content/docs/automation/hook-bodies.mdx (via os migrate meta (command, read off packages/cli/src/commands/migrate/meta.ts))
  • content/docs/data-modeling/fields.mdx (via os migrate meta (command, read off packages/cli/src/commands/migrate/meta.ts))
  • content/docs/data-modeling/objects.mdx (via os migrate meta (command, read off packages/cli/src/commands/migrate/meta.ts))
  • content/docs/data-modeling/queries.mdx (via os migrate meta (command, read off packages/cli/src/commands/migrate/meta.ts))
  • content/docs/deployment/cli.mdx (via node_modules (literal, a string literal in locate), os migrate meta (command, read off packages/cli/src/commands/migrate/meta.ts))
  • content/docs/deployment/index.mdx (via os migrate meta (command, read off packages/cli/src/commands/migrate/meta.ts))
  • content/docs/protocol/objectql/query-syntax.mdx (via os migrate meta (command, read off packages/cli/src/commands/migrate/meta.ts))
  • content/docs/protocol/objectui/actions.mdx (via os migrate meta (command, read off packages/cli/src/commands/migrate/meta.ts))
  • content/docs/ui/actions.mdx (via os migrate meta (command, read off packages/cli/src/commands/migrate/meta.ts))
  • content/docs/ui/apps.mdx (via os migrate meta (command, read off packages/cli/src/commands/migrate/meta.ts))
  • content/docs/ui/dashboards.mdx (via previousPeriod (literal, a string literal in a comment on a changed line), os migrate meta (command, read off packages/cli/src/commands/migrate/meta.ts))
  • content/docs/ui/translations.mdx (via os migrate meta (command, read off packages/cli/src/commands/migrate/meta.ts))
  • content/docs/upgrading.mdx (via node_modules (literal, a string literal in locate), os migrate meta (command, read off packages/cli/src/commands/migrate/meta.ts))

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

  • content/docs/releases/v12.mdx (via os migrate meta (command, read off packages/cli/src/commands/migrate/meta.ts))
  • content/docs/releases/v17/17-0.mdx (via os migrate meta (command, read off packages/cli/src/commands/migrate/meta.ts))
  • content/docs/releases/v17/17-1.mdx (via os migrate meta (command, read off packages/cli/src/commands/migrate/meta.ts))
  • content/docs/releases/v17/17-3.mdx (via os migrate meta (command, read off packages/cli/src/commands/migrate/meta.ts))
  • content/docs/releases/v17/17-4.mdx (via os migrate meta (command, read off packages/cli/src/commands/migrate/meta.ts))
  • content/docs/releases/v17/17-5.mdx (via os migrate meta (command, read off packages/cli/src/commands/migrate/meta.ts))
  • content/docs/releases/v17/17-6.mdx (via os migrate meta (command, read off packages/cli/src/commands/migrate/meta.ts))
  • content/docs/releases/v17/17-7.mdx (via os migrate meta (command, read off packages/cli/src/commands/migrate/meta.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
  • 55 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 172be37da7a767a8364ca841af3ed7dbb426ab5e → packageMentionDocs.

Which tree this was computed on

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

node scripts/docs-audit/affected-docs.mjs --json 172be37da7a767a8364ca841af3ed7dbb426ab5e

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

@github-actions github-actions Bot added documentation Improvements or additions to documentation tests tooling labels Oct 7, 2026
…predicate

`test/exit-signal.pin.test.ts` holds every catch that a this.exit() inside its
try can reach to open with `if (isExitSignal(error)) throw error;`. The --write
failure exits added two such calls, and the catch opened with a hand-rolled
`oclif.exit` check instead; the pin named both lines. Same behaviour, the one
spelling the pin reads.

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

Copy link
Copy Markdown
Contributor Author

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: 94dfa0c23d9af0f49df0daf96f72cdcc9dfabae4
Local-runs: none

Read-only shape held: the net diff origin/main...94dfa0c2 (merge base aa71c4d9, 6 files, +2764/−14, equal to the PR's file list), card #9591's body and all 5 comments (triage 6038918699, the claim 6041032543 as amended, two os-dev-reports, the seat's ACCEPT), ruling #9529 and its 4 comments, PR #22108's body, the seat's declarations (#20163 6043200226, #6023 6043211160, the cli seat's #22072 reply 6042070491 on #6024), and the head's check-runs. The PR head had not moved: it is 94dfa0c2 at both reads.

Check-runs on 94dfa0c2, read 2026-10-07T18:23:53Z: 38 runs — 32 success, 5 skipped (Console Pin Gate; the label-triggered re-runs of Check PR Size, Auto Label, Packed-tarball ×2), 1 in_progress: Test Core (1/6), started 17:53:14Z. No conclusion is failure. Of the seven required contexts, six are success (Lint & Repo Gates, TypeScript Type Check, Dogfood Regression Gate, Build Core, Temporal Conformance, Governed Surface Queue Guard); Test Core has shards 2–6 success and shard 1/6 still running. An earlier read at 18:17:27Z also had the label-triggered Check Changeset in progress; it concluded success by the second read. ⛔ Not green at read time — an in-progress run is not a pass; the owning seat reads a success conclusion before arming. The verdict below is on the contract.

① Derived judgments

Each accept-set and public-surface change the diff implies, read off the code at the head, judged against the changeset, the PR body and the two docs pages.

  1. The flag. write: Flags.boolean({ default: false, exclusive: ['stored'] }) in migrate/meta.ts, authored-source mode only; one help example added. os migrate meta --stored --write is refused by oclif's flag validation before run() (pinned: flags.write.exclusive contains stored, and MigrateMeta.run(['--stored','--write']) rejects). Right.
  2. Stored-only flags unchanged. --apply, --yes, --database-url, --force, --type are still refused outside --stored by storedOnlyFlagsIn; only the refusal sentence changed, to "writes only the --out snapshot and, with --write, the authored sources" — true of the code. --stored / --apply paths untouched. Right.
  3. Without --write, nothing moves. The codemod is reached only through flags.write ? this.writeSources(…) : undefined; the --json payload gains write only when the outcome exists (...(write ? { write } : {})); the human report's group prints only when report.write is set; --out is written exactly as before. Pinned: the dry run has no write key, the project snapshot is byte-identical, --out still writes. Right.
  4. What it writes (the accept set of the walk). From the config's default export (or its module namespace when there is none), through object/array literals, module-level const bindings, relative imports and re-exports (named, export *, namespace re-export), Object.values() over a namespace import (sorted export order) or over an object literal, and the first argument of @objectstack/spec define* helpers and .create factories. A site is written only when: the literal's statically known values are a subset of the loaded value (subsetOf(partialValue(literal), loaded), and an exact match for the member being rewritten); every binding crossed has no reference outside the walk's own identifiers; the container's realpath is under the config's directory and not under node_modules. Everything else refuses with a named kind. Right — and in the refusing direction throughout.
  5. What it refuses. CodemodRefusalKind is exactly the eleven named in the changeset, the PR body and upgrading.mdx: computed, helper, spread, shared, outside-project, mismatch, injected, unspellable, layout, unattributed, entangled. --json write.manual[] carries { conversionId, path, kind, reason }; the human line is not written [kind]: reason. Pinned: six kinds through the command on one project, five through the planner; every refused site keeps its bytes while its neighbours are written. Right.
  6. How it writes. Text splices at node positions: a rename replaces only the key-name node (shorthand handled); a removed member takes its own line(s) with a same-line comment, and refuses layout when other code shares the line; one-line literals delete with their separators; an added key is appended after the last kept member, inline or on its own line, inserting the missing comma; a rewritten value is respelled in the file's quote style. A conversion's edits are written whole or not at all (entangled); an overlapping edit or a parse regression (syntacticDiagnostics count rises) refuses as layout and the build restarts without that component. Pinned byte-for-byte over every file of the fixture project, barrels included; idempotence pinned (second run applies 0, writes 0). Right.
  7. What it never writes. planAuthoredSourceWrite is handed applied only — todos never reach it; a declined site produces no structural diff. Pinned relative to the dry run (not a fixed listing), with the { offset: '7d' } arm untouched and schemaValid still false. Right.
  8. Fail-closed. writeAuthoredSources compares every file to its planned before bytes before writing any → unwritten, exit 1. After writing, the config is reloaded and the chain re-run; verifyAuthoredSourceWrite requires the re-run's applied to equal the plan's manual as a multiset of path (conversionId); otherwise restoreAuthoredSources puts every file back → restored, exit 1; a reload or re-run throw also restores. Exit 1 in both --json and human mode; both this.exit(1) calls sit inside the try, and the catch now opens if (isExitSignal(error)) throw error; (round 2's fix, confirmed in the diff). Pinned: both exits, bytes restored. Right. One reading, not a defect: a plan with no writable site (rewrites.length === 0, manual.length greater than 0) is status: 'written', exit 0, printing Wrote 0 of N … into 0 file(s) then the manual list — the WriteOutcome docblock says so, and it matches the dry run's exit 0.
  9. Report placement. Group ④ prints after the --out line (which follows the semantic notices) and before printPendingDataMigrations, which stays last; the nothing-to-migrate branch prints a one-line note. Right, as the PR body states.
  10. Text (H8). The flag description, the printed group, the changeset and both pages say "in place" / "traced to one literal"; none says "automatically". Pinned on the description and on the printed group. Right.
  11. The two pages. cli.mdx division-of-labour clause: right. upgrading.mdx flags row, both paragraphs of the callout, loop comment: right; the retained "the only file it writes is --out" now sits under "Without --write", so it is true again. I grepped content/docs at the head for the old claim: no other hand-written page states it — the remaining hits are verbatim quotations of the spec's shared tombstone sentence (automation/flows.mdx, data-modeling/queries.mdx, protocol/objectql/query-syntax.mdx) and AUTO-GEN references/, which move with the spec sentence, not with this PR; deployment/index.mdx and ui/dashboards.mdx stay true. One imprecision: the changeset lists the write JSON keys as status, files, written, manual, unexplained, verification and omits error, which writeOutcomeJson emits on unwritten / restored; the PR body names it. Incomplete, not false — fix at leisure.
  12. ADR check (Prime Directive [WIP] Add Chinese version of the documentation #13). ADR-0087 D3 designs migrate meta as the explicit source-rewriting path ("source rewriting only happens via D3's explicit migrate meta"; the 2026-07-31 addendum: "migrate meta rewrites files"). This diff implements that decision and reverses none; no new ADR is owed. Note: the new module cites feat(cli): os migrate meta --write — the AST codemod that rewrites authored sources for the mechanical applied set (v18) #9591 but not ADR-0087; meta.ts's docblock carries D3 already.
  13. Scope. Six files, equal to the amended claim; no packages/spec path, no conversion added, the chain untouched. Right.

② Semver level

.changeset/9591-migrate-meta-write.md: "@objectstack/cli": minor. Correct: the only package that publishes anything here is @objectstack/cli; the change is additive (a new flag; the dry run, its report and its --json payload are byte-identical without it; nothing removed or renamed), so minor is the floor and the ceiling. main is not in Changesets pre mode: .changeset/pre.json is absent on origin/main (4935c66b) and on the head. Check Changeset concluded success on the head (both runs).

Clause-②: yes (widening) — a new public CLI flag that writes users' authored source files. Declared on the PR body (line 2, Clause-②: yes) and in the changeset body (Clause-②: yes (widening)); the two agree, the arm is the widening arm, and yes takes at least minor, which it has. No ADR-0087 disposition marker is owed: nothing is breaking.

③ Boundary flags

open_questions is [] in both os-dev-reports. Every dev deviation and seat note, answered or escalated:

  1. upgrading.mdx outside the claim's surface — answered. The claim was amended in place (17:29Z) and both pages declared to domain:devx ([PM seat] domain:devx · seat 2 — 🟢 os-justin · session_01VF48aw8RPG6wzDnMgp6rtw #20163 6043200226, [PM seat] domain:devx @ objectstack — 🟢 baozhoutao · session_01VDtqoecgES7ScQYGbFVDRv · R9 · landed 3 · #20004 awaits skip-changeset · in flight 0 #6023 6043211160); [PM seat] domain:devx · seat 2 — 🟢 os-justin · session_01VF48aw8RPG6wzDnMgp6rtw #20163's body, refreshed 18:00Z, records no objection. Fixing the two sentences the change made false was the right act.
  2. H5, no confirmation prompt — answered, accepted. Every confirm() / --yes site under packages/cli/src/commands is a row or secret-store writer (migrate apply, files-to-references, value-shapes, summary-nulls, audit-metadata-bodies, meta resync, secret rewrap/orphans, …); no source-file writer prompts; --yes is --stored-only and refused outside it; --write is an explicit opt-in defaulting to false, the re-run-and-restore is the backstop, and the docs say "Commit first".
  3. cli.mdx has no authored-mode row — answered: the --write row went into upgrading.mdx's flags table (the authored mode's table) and the cli.mdx paragraph names the flag.
  4. migrate meta: the stack-derived relevance proof that #20620's ruling names as the only exit for a notice is unbuilt — 17.7.0 still prints all 304 protocol-18 manual notices to an app none of them applies to #22072 heads-up — answered: todos pinned relative to the dry run, the applied set pinned as written-or-left, write appended at the end of the payload; reconciliation at landing agreed in 6042070491.
  5. Round 2 code change in a non-rework round — verified: a two-line isExitSignal import and rethrow; the exit-signal pin that caught it is in the unit tier CI runs on this head.
  6. Round 1's narrowed tier run — self-corrected; the full tier was run at the final head and CI on the head is the record.
  7. PR assignee relay failure / relay duplicate risk / attribution trailer / origin/main moved — procedural, no contract bearing: the seat set the assignee; a late duplicate would be a copy of the same report; commits carry the model-free trailer pair; the queue rebuilds against current main.
  8. ESCALATED — the retirement sentence restoration (packages/spec/src/shared/retired-key.ts and retired-key-migrate-sentence.test.ts). The card body and triage put it in this PR; the claim routes it to domain:spec, and the PR is Refs, so feat(cli): os migrate meta --write — the AST codemod that rewrites authored sources for the mechanical applied set (v18) #9591 stays open until the spec lane lands it. Two constraints for that lane, from this diff: --write writes only the sites it can prove and lists the rest, so a restored sentence cannot say "rewrite existing sources automatically" unqualified; and the default run still only lists, so the sentence must name --write. The current sentence stays true of the default invocation.
  9. ESCALATED — skills/objectstack-upgrade/SKILL.md:62 and :469 ("writes nothing but --out"). Tier H governed; true of the default invocation, silent on --write; carried to the Release line / skills lane. Right not to touch here.
  10. Measured walk limits — a paths alias refuses outside-project, dynamic import() / require() is not read: both refusing. The shared-binding count reads only files reachable from the config by static import, so a file outside that graph importing the same literal is not seen: this one is in the writing direction. Disclosed in the PR body; the docs' "a binding something else also uses" is a fair summary; the re-run backstop does not cover it. Acceptable for an opt-in flag; noted so no later text overstates the proof.
  11. CI not green at read time (Test Core (1/6) in progress) — a landing condition separate from this verdict; recorded above with its time.

Implemented-by: claude/issue-9591-migrate-meta-write
Reviewed-by: session_01RWZbGvPFcRKvUqASZtunCU

VERDICT: PASS

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

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants