Skip to content

fix(cli): os migrate meta leads with the verdict and the refusals, then the applied edits, then the semantic notices - #20691

Merged
objectstack-fleet[bot] merged 3 commits into
mainfrom
claude/issue-20620-migrate-meta-order
Sep 29, 2026
Merged

objectstack-fleet[bot] merged 3 commits into
mainfrom
claude/issue-20620-migrate-meta-order

Conversation

@objectstack-fleet

Copy link
Copy Markdown
Contributor

Part of #20620
Clause-②: no

Direction 1 of the card's triage (order). Direction 2 (pairing an applied conversion with the semantic entry that judges it) needs a structured link on SemanticMigration in packages/spec, so it stays on the card. Direction 3 (write-back) is out, per triage.

What changed

os migrate meta --from N (the authored-source report, human output only) now prints three groups, in the order an upgrader acts on them. Each group opens with one header line that counts it:

  1. The verdict and the refusals. Either ✓ Migrated stack is schema-valid (Nms), or ⚠ Migrated stack does not yet pass schema validation — N refusals left after the chain. Resolve them, then run os validate: followed by one ✗ path: message line per issue of the migrated stack's parse. The lines are rendered by formatZodIssue, the same renderer behind the loader's defineX() validation failed block.
  2. Applied N mechanical change(s):, unchanged.
  3. N manual change(s) require your judgment:, unchanged.

The human branch of run() is now one exported function, printMigrationReport, so the order can be pinned in process. --step checkpoints stay between groups 2 and 3, and the --out line and the data-migration advice stay after group 3. A range that holds no step also leads with the verdict, which now lists the source's refusals, and the range note follows it.

Unchanged:

  • Every applied-edit line and every semantic-notice line, byte for byte and in chain order. No notice is dropped, filtered, merged or reworded (ADR-0087 D3, "never silence").
  • --json: same keys in the same order, same values, same array order.
  • Exit codes. A schema-invalid run still exits 0.
  • The loader's stderr output.

--json's applied and todos arrays are in chain order, and a consumer could rely on that order. It is left as it is.

Measurements

Each "before" run used the bin/run.js of a worktree at 6981abfd26 (this branch's base). Each "after" run used the build of 7c484d891f. Both runs used the same @objectstack/spec (6981abfd26). The merge of main since then does not touch packages/cli. Stacks:

  • examples/app-crm at 6981abfd26;
  • h1, a scratch defineStack config: two currency fields carrying scale (refused, no conversion), two dashboard.refreshInterval, two action.aria, and a two-branch flow decision with no mode;
  • dm, a scratch config with an image and a location field and one refreshInterval, run --from 16 so the data-migration advice prints.

stdout line where each group starts:

run before: applied / semantic / verdict after: verdict / applied / semantic
h1 --from 17 (5 applied, 242 notices, 2 refusals left) 9 / 16 / 780 (last line) 9 (+2 refusal lines) / 13 / 20
app-crm --from 17 (0 applied, 242 notices, valid) none / 9 / 773 9 / none / 11
dm --from 16 (1 applied, 319 notices, valid) 9 / 12 / 1044, after the data-migration advice 9 / 11 / 14, with the advice last
  • H0 (the refusals print after the catalogue): partly false. The verdict was the last line. The printer never listed a refusal. The only refusal list was the loader's [authored-source] defineStack() warning on stderr, printed during load, before any chain output. That list describes the stack as authored: 6 issues on h1, 4 of which the chain goes on to convert. The 2 refusals that remain after the chain were printed nowhere.
  • H1: holds, with one addition by design. The order is verdict and refusals, then applied edits, then notices, with one header line per group. Applied-edit lines match in number and in order: 5=5 (h1), 0=0 (crm), 1=1 (dm). Notice lines match in number and in order: 744=744, 744=744, 996=996. The preamble lines are equal. The same holds for --step and --out, with the snapshot path normalised. The only lines added are the refusal list: 2 on h1, 0 on the valid stacks. That is the content direction 1 asks for, and the printer had none of it before. The only lines reworded are the two schema-invalid verdict sentences: the old one pointed at "the manual changes above", which are now below.
  • H2: holds. The exit code was 0 before and after in all 11 invocations. --json is identical once duration is removed, in the same key order (h1, h1 empty range, crm, dm). The --out snapshot is byte-identical. stderr is byte-identical in every run.
  • H3 (ablation): holds. The mutation restores the old order and nothing else: the body runs first, the verdict prints last. It went through scripts/ablation-replace.mjs (anchor 1 → 0, blob 1a002a9b → d80c2332) under a trap-guarded restore. Result: exactly the 3 ORDER pins red, e.g. the verdict is the first line the report prints: expected 1030 to be +0. The header-count pin and the 4 SET pins stayed green. Restore proven: blob 1a002a9b equals the HEAD blob, git diff HEAD is empty, and the marker count is 0. The subject is ./meta.js, resolved to src by vitest (a relative import), so no dist was involved.

Verification

At 8304fb9e13 (final head: main merged in, then pnpm install --frozen-lockfile and the CLI closure rebuilt):

  • pnpm --filter @objectstack/cli typecheck: exit 0. The new test is in the tsc --noEmit program (--listFiles count 1).
  • pnpm --filter @objectstack/cli exec vitest run --project unit --maxWorkers=2: 233 files passed, 2 failed (5 tests). The 2 are test/published-subpath-console.pin.test.ts and test/published-subpath-hook-body.pin.test.ts. They fail the same way on a clean 6981abfd26 worktree: a macOS /var vs /private/var realpath difference. See the acceptance notes.
  • node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --ran over the 62 derived commands, each with its exit code: 62 run, all exit 0, 0 NOT-MEASURED. check:dual-build-cjs-loads and check:i18n-coverage first answered PREREQUISITE NOT MET (exit 3). They were re-run after a full turbo run build and exited 0.
  • The artifact-roster rows that could apply, all exit 0:
    • check-changeset-fixed, check:authz-resolver, check:error-code-casing and check:filter-alias-parity (all marked ⛔);
    • release-pending-publish --self-test, check:cli-examples-parity and check-platform-checklist-watchdog;
    • check-published-list-mirrors and check:commit-card-trailers.
  • Three PR-context gates exit 2 without a PR payload and are NOT MEASURED locally: check-single-claim-paths, check-partof-closing-keyword and check-closing-target-claim.
  • pnpm lint (the full eslint .): exit 0. Both touched files are linted, with 0 errors and 0 warnings (--format json).
  • node scripts/check-issue-citations.mjs --base origin/main: exit 0, 3 citations resolve.

At 7c484d891f (before the merge, same packages/cli):

  • The two queue-tier spawned tests that quote this printer's text, test/migrate-meta-default-range.test.ts and test/migrate-meta-engine-guidance.test.ts (--project integration): 10 passed, 1 skipped (a registry-selected skip, by design).
  • test/migrate-meta.e2e.test.ts (nightly tier): 19 passed.

Declared narrowing — verification ran UNLOCKED. scripts/pm/os-verify-lock.sh
could not take the shared verify lock on this host: no usable flock. The shared
verify lock is declared Linux-only (flock is util-linux, and a stock macOS does
not ship it), so the command below was run directly, without the lock —
a declared narrowing, not a silent one. No serialization guarantee held for this
run, nor for any sibling agent in this container while it ran.

pnpm exec turbo run build --filter='@objectstack/cli...' --concurrency=2
pnpm exec turbo run build --filter='!@objectstack/docs' --concurrency=2
pnpm --filter @objectstack/cli build
pnpm --filter @objectstack/cli typecheck
pnpm --filter @objectstack/cli exec vitest run --project unit --maxWorkers=2
pnpm --filter @objectstack/cli exec vitest run --project integration --maxWorkers=2 test/migrate-meta-default-range.test.ts test/migrate-meta-engine-guidance.test.ts
pnpm --filter @objectstack/cli exec vitest run --project integration --maxWorkers=2 test/migrate-meta.e2e.test.ts
pnpm lint

Acceptance notes

  • The refusal group is new content, and the only content this PR adds. The printer never had the migrated stack's refusals. The only list was the loader's, of the authored stack, on stderr. That loader warning is unchanged and still prints first during load. On h1 it names 6 issues, and the report's group names the 2 left after the chain. The two counts measure different stacks.
  • A published checklist step now quotes a stale sentence. docs/qa/platform-checklist/areas/cli.json, item cli.migrate-meta-codemod (revision 3), quotes the old invalid-verdict sentence verbatim ("resolve the manual changes above"). Its acceptance clause (the verdict agrees with schemaValid) still holds. The quoted step is now stale. It is not edited here: it needs a revision bump and a history entry by the checklist's author.
  • The upgrade skill lists the groups in the old order. skills/objectstack-upgrade/SKILL.md lists the printed groups as applied, manual, verdict. It claims no order, and every string it quotes is still printed. It is a governed surface, not edited here.
  • pnpm check:platform-checklist is red on main. The failure is areas/identity-auth.json: ABSENT SYMBOL … auth-plugin.ts#twoFactor, red on 6981abfd26 as well. The gate is maintainer-run and deliberately out of per-PR CI.
  • Two published-subpath pins are red on macOS only. published-subpath-console.pin.test.ts and published-subpath-hook-body.pin.test.ts compare a /var/folders/… path with its /private/var/folders/… realpath. They are red on a clean base worktree too, so the failure comes from the host, not from this diff.
  • Reported for the seat to file (class a): os migrate meta cannot open an object built with ObjectSchema.create that carries a retired key. The load-time tolerance wraps only the define* exports, and ObjectSchema.create( is used 35 times across examples/, as well as by HotCRM.
    • Repro: one object, ObjectSchema.create({ …, tenancy: { enabled: true, organizationField: 'organization_id' } }) inside defineStack.
    • Result: os migrate meta --from 17 exits 1 at load with the raw ZodError array. The array's own message prescribes "Run os migrate meta --from 17 …".
    • Control: the same object as a plain literal loads, object-tenancy-organization-field-removed applies, and schemaValid is true.
  • Exit code 0 on a schema-invalid run is unchanged, as test/migrate-meta-default-range.test.ts records.

Generated by Claude Code

hotlong and others added 3 commits September 30, 2026 01:21
…en the applied edits, then the semantic notices

The authored-source report printed the whole semantic catalogue first and
the schema verdict last, and never listed the refusals that keep the
migrated stack from parsing. The human report is now one function that
prints three groups, each under one header line counting it: the verdict
with every refusal left after the chain, the applied mechanical edits,
then every semantic notice. No notice is dropped or reworded, and the
--json payload is unchanged.

Claude-Session: https://claude.ai/code/session_local_1d2a197c-c20e-4e90-9be8-413d4d432289
Co-authored-by: Claude <noreply@anthropic.com>
…ess, and add the changeset

Order pins: the verdict and its refusals are the first lines, then the
applied-edit header, then the manual-change header, and nothing but the
refusals sits between the verdict and the applied edits. Completeness pins:
every applied edit and every semantic notice of a real two-hop chain run
prints exactly once, byte-identical and in chain order; every refusal of the
migrated stack is listed; nothing else is printed. Neither kind reads the
other's positions, so a reorder alone cannot redden the completeness pins.

Claude-Session: https://claude.ai/code/session_local_1d2a197c-c20e-4e90-9be8-413d4d432289
Co-authored-by: Claude <noreply@anthropic.com>
@github-actions github-actions Bot added documentation Improvements or additions to documentation tests tooling labels Sep 29, 2026
@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

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

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

  • 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 dataMigrations (symbol, a field of interface MigrationReport), 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 os migrate meta (command, read off packages/cli/src/commands/migrate/meta.ts))
  • content/docs/upgrading.mdx (via os migrate meta (command, read off packages/cli/src/commands/migrate/meta.ts))

⛔ 6 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/ 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
  • 5 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 — 25 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 14f80e23957165f6fb23c2b3d59bdc7668652dfb → packageMentionDocs.

Which tree this was computed on

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

node scripts/docs-audit/affected-docs.mjs --json 14f80e23957165f6fb23c2b3d59bdc7668652dfb

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

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: 8304fb9e1307a9348065e2e761dcceaf9de55300
Local-runs: none

Inputs read, via gh api (REST): card #20620 (body and all 4 comments: triage first grade 5888087153, claim 5894939836, os-dev-report 5895725152, seat answer 5895765767); PR #20691 (body, the 3-file list, the net diff against main at 14f80e2395, and the head's whole meta.ts through the contents endpoint for the lines the diff does not show: the --json branch, the parse target, the exit paths); the check-runs on the head. Reference reads in the worktree, not of the PR: AGENTS.md §3 (Clause-②), packages/cli/package.json exports, packages/cli/src/index.ts, packages/spec/src/migrations/chain.ts, packages/spec/src/index.ts, packages/cli/src/utils/format.ts, packages/cli/vitest.config.ts, docs/qa/platform-checklist/areas/cli.json, skills/objectstack-upgrade/SKILL.md, one earlier changeset for the Clause-② placement.

Check-runs on the head at read, 2026-09-29T18:06:45Z, newest run per name: 33 names. 28 success, 3 skipped (Build Docs, Console Pin Gate, Packed-tarball smoke opt-in), 2 in progress (Lint & Repo Gates; Test Core 3/6). 0 failed. Workflow runs on the head: 11 (8 success, 1 skipped, CI queued with shards still running, Lint & Type Check in progress). The two running checks are recorded as running; this record infers nothing for them, and the adopting seat reads them at adoption.

Binding direction: triage's first grade, direction 1 only (lead with the verdict and the refusals, then the applied mechanical edits, then the semantic notices, one header line counting each group; no notice dropped, ADR-0087 D3). Direction 2 is split to #20697 and direction 3 is out, per 5895765767. The PR says Part of #20620.

① Derived judgments

Accept-set: no change. The file list is packages/cli/src/commands/migrate/meta.ts, one new test beside it, one changeset. packages/spec/** is untouched, so no schema, conversion or semantic entry moved; the claim's read-only clause on spec held. RIGHT.

Public-surface changes the diff implies:

  • (a) The human stdout of os migrate meta --from N (authored-source arm, text face): three groups reordered to verdict-and-refusals, applied, semantic; one content addition (the migrated stack's refusals under the verdict); the two schema-invalid verdict sentences reworded; the trailing blank line now follows the verdict at the top. RIGHT, judged in (1) to (4).
  • (b) New module-level exports in meta.ts: printMigrationReport, MigrationReport, MigrationRefusal, and PendingDataMigration (was module-private). The package's exports map is ., ./console, ./hook-body, ./package.json, and src/index.ts re-exports six migrate command classes but not meta.js, so none of these reaches a consumer: internal, no public API change. RIGHT.
  • (c) printPendingDataMigrations now takes readonly PendingDataMigration[]: accepts a superset, internal. RIGHT.
  • (d) --json, exit codes, --out, the loader's stderr: unchanged, judged in (3). RIGHT.
  • (e) meta.report-order.test.ts in src/commands/migrate/, unit tier, beside the existing meta.stored-flags.test.ts precedent; UNIT_FILES is the tier walk's complement of the integration list. RIGHT.

(1) Byte-identity and order within group. The diff lifts the applied loop, the --step checkpoint loop, the semantic loop, the --out write and the data-migration advice out of run() into printMigrationReport with the three template strings character-identical on both sides of the hunk (the • path: from → to (conversionId) line, the ⚠ [protocol N] surface → replacement line with its why: and verify: lines, the ── protocol N ── checkpoint), and each loop still iterates result.applied, result.hops, result.todos in chain order; no filter, sort, dedupe or rewording is introduced anywhere. Relative order inside the body is preserved: applied, then --step checkpoints, then semantic, then the --out line, then the advice. The only movement is the verdict block, from last to first, and on the empty-result path from after the advice to before the range answer. The SET pins in the new test derive the expected applied, notice and refusal lines from the chain's own data and compare with toEqual, and the last pin sorts every non-blank printed line against the union of headers, refusals, edits and notices, so a dropped, merged or reworded line has nowhere to hide behind a correct order. The dev's real-stack counts (applied 5=5, 0=0, 1=1; notice lines 744=744, 744=744, 996=996; --step and --out likewise) read consistently with the diff. So the change is order and headers only, plus the one addition in (2). RIGHT.

(2) The refusal group. The refusals are parsed.error.issues where parsed = ObjectStackDefinitionSchema.safeParse(result.stack) (head meta.ts line 580), the MIGRATED stack, the same parse that feeds schemaValid in --json. The loader's [authored-source] defineStack() list (in config.ts, untouched, on stderr) is of the stack as authored and still names the keys the chain converts: on the dev's h1 stack it has 6 issues, the report's group the 2 left after the chain. Since every listed edit is a D2 conversion that defineStack already applies at load (triage), what blocks the stack is the post-chain set, so the migrated stack's issues are the right set, and the header says "left after the chain" so the two lists cannot be read as one. Within direction 1: the direction reads "lead with the verdict and the refusals", the printer had no refusal list to lead with (the card's "41 refusals" were the loader's stderr interleaved on the terminal), so listing them is the smallest change that executes the direction as written; the seat's answer 5895765767 already named it as the one content addition for this review to judge. The renderer is formatZodIssue, exported from @objectstack/spec's index and the loader's own renderer, so a refusal reads the same in both places. RIGHT.

Header count. The invalid verdict carries the count (N refusals left after the chain); the valid verdict Migrated stack is schema-valid carries no number, which is the zero. The applied and semantic headers print only when their group is non-empty, exactly as before. One header line per populated group holds. RIGHT, with that note.

(3) --json, exit codes, --out, loader stderr. --json: the if (flags.json) { await emitJson({...}); if (flags.out) writeFileSync(...); return; } block (head lines 584 to 617) is outside every diff hunk and returns before the human branch; its keys, values and array order come from the same result, so unchanged. Exit codes: neither the old nor the new human branch calls this.exit; the catch block is outside the hunks; a schema-invalid run still exits 0 (as test/migrate-meta-default-range.test.ts records). --out: written at the same place (after the semantic group, before the advice) with the same resolve(flags.out), and, as before, not written on the empty-result path (pre-existing, not touched). Loader stderr: packages/cli/src/utils/config.ts is not in the file list. RIGHT.

(4) The two reworded sentences. Old A ended "resolve the manual changes above, then run os validate."; new A reads "N refusals left after the chain. Resolve them, then run os validate:" and the refusal lines follow. "Them" names the lines printed directly under it, which are exactly the migrated stack's parse issues, so it is true in the new order, and more accurate than the old, which pointed at advisories rather than at what fails the parse. Old B (no hop) ended "Widen the range above, or run os validate for the refusals."; new B reads "nothing here has been fixed; N refusals, exactly as the source has them:", the refusals follow, then the range answer. With zero hops applyMetaMigrations returns stack: current with current never reassigned (chain.ts), so the migrated stack IS the normalized source and "exactly as the source has them" is true; the "widen the range above" pointer is dropped rather than inverted, and the range answer follows it (pinned: the range-answer index is greater than the verdict index). The valid verdict is unchanged. RIGHT.

(5) Ablation. The test file is 3 ORDER pins, 1 header-count pin, 4 SET pins, 8 in all. With the verdict moved back to last, ORDER 1 fails at expect(verdict).toBe(0) (the quoted "expected 1030 to be +0"), ORDER 2 at lines[0], ORDER 3 at the same index pin; the header-count pin filters by regex and the SET pins compare arrays derived from the chain data or slice from the semantic header, none reading a cross-group position, so all 5 stay green. 3 red and 5 green is what the mutation must produce, and the dev's reading matches. The ablated blob 1a002a9b is the head blob of meta.ts (the diff's index line reads e80aee74e3..1a002a9b48), so the ablation was of the file under review. It proves what it claims: the order pins bite on the reorder and the set pins do not. That the set pins bite on a dropped notice is by construction (toEqual against chain-derived arrays), not by ablation, and the PR claims no more. RIGHT. The changeset is judged in ②.

(6) Part of #20620. The card keeps direction 2 (the printer's pairing) pending on #20697 and the seat said the card stays open for it; a closing keyword would auto-close the card at merge, strand direction 2 and leave the pm labels behind. Part of with no closing keyword is right; the check-runs "Part-of PR must not also close its card" and "The card this PR closes must claim this branch" are green. RIGHT.

Wrong: none found.

② Semver level

.changeset/20620-migrate-meta-verdict-first.md: '@objectstack/cli': patch, body carries Clause-②: no, matching the PR body. What the diff publishes: a behaviour change to the human text face of one command (order, one added group, two reworded lines); the machine face (--json), the exit codes, the --out snapshot and stderr unchanged; no authorable key added or removed, no export reachable through the package's exports map, nothing removed or renamed. A fix in a released package takes patch, never skip-changeset (AGENTS.md §3), and a reordered human report publishes nothing more than that. Check Changeset: success. RIGHT.

Clause-②: no — right. Nothing an author can write is added or removed, so no; and no arm, since no (widening) is malformed.

③ Boundary flags

open_questions[0], the shape of direction 2's structured link (A/B/C/D): ANSWERED by the seat in 5895765767. Filed bare as #20697 with the dev's option A carried as the proposal; the shape is the spec seat's. Nothing for this PR.

Deviations (5), all declared, all ANSWERED:

  1. The draft PR was created with gh pr create (GraphQL, not REST): process only, no contract effect.
  2. The first label-write ran outside with-fleet, exit 3, nothing written; the re-run wrote once, read back.
  3. app-showcase would not load in the worktree; measured on app-crm plus two scratch stacks (h1: refusals, applied edits and notices; dm: the data-migration advice under --from 16). The three together exercise every group and the advice's placement.
  4. Integration and nightly runs cited at 7c484d891f: packages/cli is identical at 8304fb9e13 (the diff's head blob 1a002a9b48 is the blob ablated there).
  5. A detached base worktree for the before-readings, removed at cleanup.

Declared narrowing, verification run unlocked (no flock on macOS): declared, not silent; the check-runs on the head are the gate verdicts in any case. ANSWERED.

PR-context gates NOT MEASURED locally (single-claim paths, part-of closing keyword, closing-target claim): ANSWERED by the head's check-runs, all success: "No other open PR may claim the same issue", "No other open PR may claim the same single-writer path", "Part-of PR must not also close its card", "The card this PR closes must claim this branch".

out_of_scope_findings:

  • class a, os migrate meta aborts at load on an ObjectSchema.create object carrying a retired key: filed by the seat as [finding] os migrate meta aborts at load on an object built with ObjectSchema.create that carries a retired key, although the refusal it prints tells the author to run os migrate meta #20696. ANSWERED.
  • docs/qa/platform-checklist/areas/cli.json, item cli.migrate-meta-codemod (revision 3), step 7 quotes the old warning verbatim ("resolve the manual changes above, then run os validate"): confirmed stale after this PR; the item's acceptance clause (the verdict agrees with schemaValid) still holds and the checklist watchdog is green. ESCALATED to the seat: outside the claim's file surface, it needs a revision-4 bump and a history entry by the checklist's author (the checklist-author skill is the tool), and the step should name the refusal group so a runner knows the list now exists.
  • skills/objectstack-upgrade/SKILL.md lines 139 to 147 list Applied, manual, schema-valid and data migrations under "What it prints:": no order is claimed and every quoted string still prints (confirmed); a governed surface (Tier H), untouched, Governed Surface Queue Guard green. ESCALATED as a one-line reorder for that surface's next queue entry; not blocking.
  • check:platform-checklist red on main (identity-auth twoFactor): pre-existing, maintainer-run, out of per-PR CI, not this PR. ANSWERED.
  • Two published-subpath pins red on macOS only (/var versus /private/var), reproduced on a clean base worktree: host-only; CI is Linux and the Test Core shards answer for it (one still running at read). ANSWERED.

Semantic notice, mine: with stderr and stdout on one terminal an upgrader now sees two refusal lists, the loader's as-authored list before the chain and the report's post-chain list, with different counts. The report's "left after the chain" and the loader's [authored-source] prefix keep them apart; noted for the seat, nothing asked of this PR.

Implemented-by: claude/issue-20620-migrate-meta-order
Reviewed-by: local_1d2a197c-c20e-4e90-9be8-413d4d432289

VERDICT: PASS

@objectstack-fleet
objectstack-fleet Bot marked this pull request as ready for review September 29, 2026 18:12
@objectstack-fleet
objectstack-fleet Bot added this pull request to the merge queue Sep 29, 2026
Merged via the queue into main with commit 3b47a69 Sep 29, 2026
36 checks passed
@objectstack-fleet
objectstack-fleet Bot deleted the claude/issue-20620-migrate-meta-order branch September 29, 2026 18:28
akarma-synetal pushed a commit to akarma-synetal/framework that referenced this pull request Oct 7, 2026
…lied edit beside that edit, marked review (objectstack-ai#21025)

Fixes objectstack-ai#20620
Clause-②: no

Direction 2 of triage's first grade on objectstack-ai#20620 (comment `5888087153`):
「Where an applied conversion has a semantic sibling that judges it,
print the sibling beside the applied edit, marked **review**.」 It pairs
through the declared `SemanticMigration.conversionIds` only, never
through text mentions (release `5916560364`). Direction 1 (order) landed
in PR objectstack-ai#20691. Direction 3 (write-back) is not taken by triage, so this
PR closes the card.

## What changes

`printMigrationReport` (`packages/cli/src/commands/migrate/meta.ts`),
the human face of `os migrate meta --from N`, now prints group 2 through
`printAppliedEdits`. Under each run of applied edits by one conversion,
it prints one review line for every semantic entry that names that
conversion in `conversionIds`:

```text
  Applied 3 mechanical change(s):
    • dashboards[0].refreshIntervalSeconds: refreshInterval → refreshIntervalSeconds (dashboard-refresh-interval-to-refresh-interval-seconds)
    • flows[0].nodes[1].config.mode: mode unset (2 conditioned out-edges; every one whose condition held was taken) → inclusive (flow-decision-mode-inclusive-explicit)
    • flows[1].nodes[1].config.mode: mode unset (2 conditioned out-edges; every one whose condition held was taken) → inclusive (flow-decision-mode-inclusive-explicit)
      ↳ review the 2 edits above against the manual change [protocol 18] flow.nodes[].config.mode (decision) — an OMITTED mode on a decision that branches on its out-edges and carries two or more conditioned ones → nothing, where the out-edge conditions partition (exactly one can hold for any record): […]
```

(The review line is cut at `[…]` here. It is printed in full.)

- **It is a copy, not a move.** The review line repeats the entry's
`[protocol N] surface → replacement` headline, which is the line group 3
prints for that entry. Group 3 still prints every entry, byte for byte,
under the same `N manual change(s) require your judgment:` count, so
ADR-0087 D3's "never silence" holds. The `why` and `verify` lines are
found in group 3 under the same headline.
- **Declared links only.** `judgesByConversion` reads
`todo.conversionIds`, which the chain copies from the entry. Nothing
matches prose.
- **Nothing else moves.** Every edit line is byte-identical and in chain
order, and the group 2 header count is unchanged. An edit that no entry
judges prints with nothing under it. `--json`, `--out`, the exit code
and the loader's stderr are untouched. `--stored` does not use this
printer.

## The dispatch's mechanism assumptions, measured

- **H1 holds.** `applyMetaMigrations` builds each todo as `{ ...s,
toMajor }` (`packages/spec/src/migrations/chain.ts`), so every todo
carries `conversionIds`. On `main` the registry declares exactly two
links, and both point into the same hop:
`flow-decision-edge-branching-first-match` (18) →
`flow-decision-mode-inclusive-explicit` (18), and
`time-default-zone-refused` (18) → `time-default-utc-suffix-dropped`
(18). No authored link crosses a hop yet. The join runs over the flat
`applied` / `todos` arrays, so a cross-hop link pairs by construction. A
pin proves it by adding a link from a protocol-18 entry to the
protocol-17 conversion `action-execute-to-target` on a real chain
result. Across steps, the 108 step conversion ids are unique (0
duplicates).
- **H2 holds, as a copy.** Group 3 keeps every entry, byte-identical,
with its header count unchanged. The review line sits in group 2 beside
the edits it judges. I did not change it to a move. A move would take
the entry out of group 3, and its count would then stop matching the
chain's `todos`.
- **H3: one review line per run, not one per edit.** A conversion's
edits are contiguous by construction, because the chain replays one
conversion at a time and its `apply` emits every edit before the next
conversion starts. So a run is "this conversion's edits in this hop".
HotCRM had 13 edits by `flow-decision-mode-inclusive-explicit`. One
review line per edit would repeat a paragraph-long `replacement` 13
times, which is the noise this card is about. No information is lost:
every edit line still names its `conversionId`, and the review line
counts the edits above it ("the 2 edits above", or "the edit above" for
a run of one). If a conversion's edits were ever split into two runs,
each run would get its own review line, so a review line never sits far
from its edits.
- **H4: `--stored` does not use `printMigrationReport`.** `runStored`
prints `formatStoredMigrationReport` from
`@objectstack/metadata-protocol`, so this PR does not touch it.

## Evidence: real stacks through `bin/run.js`

Two small authored stacks (`export default { … }` configs) were run with
`os migrate meta --from 17`, once on `origin/main` at `315888d660` and
once at this PR's head, `c6622657f2`:

- a decision stack: two flows, each with an edge-branched decision with
two conditioned out-edges and no `mode`, plus a dashboard
`refreshInterval`;
- a time stack: two `time` field defaults, `09:00Z` and
`17:30:00+00:00`, plus a dashboard `refreshInterval`.

| | decision stack | time stack |
|:---|:---|:---|
| stdout lines, before → after | 813 → 814 | 813 → 814 |
| stdout diff (timing and `--out` path normalised) | one added line, the
review line under the 2 `flow-decision-mode-inclusive-explicit` edits |
one added line, the review line under the 2
`time-default-utc-suffix-dropped` edits |
| group 3 entries / header count | 253 = 253 | 253 = 253 |
| `--json` apart from `duration` | identical | identical |
| `--out` snapshot | byte-identical | byte-identical |
| stderr | 0 bytes both runs | 0 bytes both runs |
| exit code (human / `--json`) | 0 / 0 both runs | 0 / 0 both runs |

Before this change, the decision edits printed on lines 13 and 14, and
the entry that judges them printed at line 351.

## Tests

The pins extend
`packages/cli/src/commands/migrate/meta.report-order.test.ts` rather
than a new file. That file already holds the printer's helpers (`run`,
`appliedLines`, `noticeLines`, `refusalLines`) and the SET pins the
pairing must not break, so the pairing pins reuse them, and every pin on
this printer stays in one file. The new `PAIR` block adds:

1. the judge prints once, directly under the run of edits it judges,
counting them, inside group 2;
2. a run of one says "the edit above";
3. an edit no entry judges prints exactly as before, with no review line
under it, and group 2's edit lines equal the chain's, in order;
4. a stack whose applied conversions have no link prints no review line
at all;
5. group 3 keeps every entry, the judge included, with the chain's count
and bytes;
6. every non-blank line is a header, a refusal, an edit, a notice, or a
review line that the declared links call for. The expected review lines
are computed without the printer's run logic;
7. every link the registry declares is paired, replayed over the linked
conversion's own `fixture.before`, so a link the spec lane adds later is
covered when it lands. Anti-vacuity: the two links named in the dispatch
are asserted present;
8. a link to a conversion that an earlier step replays pairs across
hops.

Measured at `c6622657f2`:

- `pnpm --filter @objectstack/cli exec vitest run --project unit
--maxWorkers=2 src/commands/migrate/meta.report-order.test.ts`: 16
passed (8 existing, 8 new).
- The unit-tier neighbours (`doctor-deprecation-hint-commands`,
`meta.stored-flags`, `migrate-meta-strict-factories`, and this file): 4
files, 41 passed.
- `test/migrate-meta-default-range.test.ts`,
`test/migrate-meta-engine-guidance.test.ts` and
`test/migrate-meta.e2e.test.ts` are integration tier, so they are
declared to CI. Their only group 2 assertions are `toContain` checks,
which an added line cannot break.
- `pnpm --filter @objectstack/cli typecheck`: exit 0. The test file is
in the `tsc --noEmit` program (`--listFiles`, 1 hit).

**Ablation.** The fix was committed first (`19563f6c4e`). `node
scripts/ablation-replace.mjs` replaced `const judges =
judgesByConversion(result.todos);` with an empty map (anchor x1 → x0,
blob `868eef7e8983` → `542c67cdfca2`). The subject is imported
relatively from source, so no build was involved. Result: **5 red**
(pins 1, 2, 6, 7 and 8) and **11 green**. The 11 are the 8 order, count
and set pins and the 3 control pins: unjudged edit, no-link stack, and
group 3 intact. The restore was proven by blob `868eef7e8983` equal to
HEAD and an empty `git diff HEAD`.

## Gates

`node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack
--commands`, with no paths, at `c6622657f2` (merge base `9b0de7de73`),
derived 62 commands. Each was run and its exit code recorded before any
pipe. All 62 exited 0. `--ran`: `62 derived, 62 run, 0 NOT-MEASURED, 0
UNRUN`. On the first round, `check:dual-build-cjs-loads` and
`check:i18n-coverage` answered PREREQUISITE NOT MET (exit 3, no dist).
They were re-run after `pnpm turbo run build
--filter=!@objectstack/docs`, and both are green. At reconciliation
time, `origin/main` was already 3 commits ahead, and one family file
(`scripts/check-platform-checklist.mjs`) had changed there. That family
reads `docs/qa/platform-checklist/`, which this diff does not touch.

**Lint, as a proven narrowing.** `pnpm lint` is a repo-wide scan owned
by CI. Run at `c6622657f2`:

- **Population.** ESLint's own config was asked through its API
(`isPathIgnored` and `calculateConfigForFile` against
`eslint.config.mjs`, inline config off). `meta.ts` is LINTED with 6
rules and `meta.report-order.test.ts` is LINTED with 5. The changeset is
in no config's population.
- **Count.** `--format json` over those 2 files: 2 results, 0 errors, 0
warnings.
- **Invariance.** `eslint.config.mjs` enables no type-aware linting:
none of its 7 `parserOptions` blocks sets `project`. Its only disk reads
at load are two baseline JSON files this diff does not touch. So no
untouched file's verdict can move.

## Acceptance notes

- `skills/objectstack-upgrade/SKILL.md:139-147` describes `Applied N
mechanical change(s)` as "one line per rewritten site". With this PR, a
judged run also carries a review line. The surface is Tier H governed,
and a one-line mention can ride with the group reorder that the previous
ACCEPT on this card already left for that surface's next queue entry.
- The `printMigrationReport` docblock cites "242 notices for protocol
18" as the measurement behind direction 1. Today's chain prints 253. It
is a dated measurement, not a claim this PR depends on, so it is left as
written.
- `docs/qa/platform-checklist/areas/cli.json` item
`cli.migrate-meta-codemod` locates planted spellings in the `Applied`
block by their edit line. The added review lines do not change that.

---
_Generated by [Claude
Code](https://claude.ai/code/session_01VvcEokUG1tvVxkceYfR5XB)_

---------

Co-authored-by: Claude <noreply@anthropic.com>
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

Development

Successfully merging this pull request may close these issues.

1 participant