Repository navigation
fix(cli): os migrate meta runs on a composeStacks project and migrates its package bodies - #22326
Conversation
Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RWZbGvPFcRKvUqASZtunCU
…them back Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RWZbGvPFcRKvUqASZtunCU
… control Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RWZbGvPFcRKvUqASZtunCU
…a composed project Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RWZbGvPFcRKvUqASZtunCU
Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RWZbGvPFcRKvUqASZtunCU
📓 Docs Drift CheckThis PR changes 1 package(s): 18 hand-written doc(s) name something this change touched — list omitted above 15 rows. Re-derive on the tree named below: ⛔ 9 release-owned page(s) also affected — read-only, see AGENTS.md Documentation Guardrails. What this run could not see
Coarse fallback — 28 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): Which tree this was computed onThis run read A worktree cut from an older # while this PR is open — GitHub drops the merge commit once it closes
git fetch origin 7fc032ff0fa1f8020c5357a53fd5e65569e15459 && git checkout 7fc032ff0fa1f8020c5357a53fd5e65569e15459
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 28bff18d0c4013db86d61eba87c739c3145e17fd db3e20d1c90e31bf4e2f0dd16d255170ad7439e8 && git checkout -B drift-repro 28bff18d0c4013db86d61eba87c739c3145e17fd && git merge --no-ff db3e20d1c90e31bf4e2f0dd16d255170ad7439e8
node scripts/docs-audit/affected-docs.mjs --json 28bff18d0c4013db86d61eba87c739c3145e17fd
|
packageOwnedCollectionKeys stays exported; its doc names both readers. Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RWZbGvPFcRKvUqASZtunCU
Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RWZbGvPFcRKvUqASZtunCU
…ready applies, on a stack the schema accepts (objectstack-ai#22351) Part of objectstack-ai#22256 Clause-②: no Release: the composed half stays open on objectstack-ai#22256. That half is a conversion the load still applies inside a `composeStacks` package body. Its carrier is the `domain:cli` seat, which takes it to the `domain:spec` seat with the H4 measurement below. It needs a spec seam or a second copy of composition's rule. `os migrate meta` now lists and writes a conversion the load still applies, such as `datasources[].driver: 'mongo'`, on a one-package stack the current schema ACCEPTS. Every change is in `packages/cli`. There is no `packages/spec` change, and nothing here reads or writes the producer's provenance key. ## What changed 1. **The shim keeps the argument of an accepted `defineStack` call** (`src/utils/config.ts`). `__tolerant` gains a `keep` hook. It runs after a successful call, outside the `try`. The `defineStack` wrap passes `__keepAuthored`, which defines one non-enumerable property on the returned stack, holding the argument. Its key is a CLI-owned `Symbol.for` key, `@objectstack/cli:authored-source/accepted-argument`. A refused call is unchanged: it hands its argument through raw, and `__recordStack` records it for `composeStacks`. A call takes exactly one of the two arms. 2. **`loadConfig({ authoredSource: true })` starts the default export from that argument** (`authoredArgumentOf`). It reads the argument off `mod.default` BEFORE the named-export merge, which is where the provenance mark and the conversion record are already read: the merge's spread drops non-enumerable properties. It follows the record to the end, so `defineStack(defineStack({ … }))` answers the inner literal. `stackProvenance` and `stackConversions` are still read off the built stack. A load without `authoredSource`, which is every other command, is untouched. 3. **`meta.ts`: no code change, one comment.** Both chain call sites (`:1159`–`:1165`, and the `--write` re-run at `:1343`–`:1344`) already run `normalizeStackInput(config, { convert: false })` over `loadConfig(…, { authoredSource: true }).config`, and that value is now the stack as written. The comment says where it comes from, and that the composed path is not covered. 4. **Changeset**: `@objectstack/cli` `patch`, `Clause-②: no`. It keeps PR objectstack-ai#22326's "Known limit" true, and restates it for both kinds of composed input. ### The carrier (H3): a property on the stack, not a WeakMap in the shim's record module The record has to cross from the bundled shim to `loadConfig`. The only values that cross are the config module's exports, and the default export IS the stack the call returned. A property on it crosses with it, with no global state and no lifetime beyond the value's. The hand-through WeakMap (PR objectstack-ai#22326) lives in a module bundled inside the load, and nothing outside the load can reach it; reaching it would take a process-global. The property is non-enumerable, like the producer's own mark, so a spread, `JSON.stringify` and a schema parse all leave it behind. ### Why `loadConfig` substitutes, and `meta.ts` does not re-merge In the order's route, `loadConfig` exposes the record and `meta.ts` starts from it at its two call sites. `meta.ts` would then have to redo `loadConfig`'s named-export merge over the authored argument, which is a second copy of that rule. Substituting before the merge keeps one merge. The `--write` planner also gets the authored value, so its literal match (`subsetOf(literal, loaded)`) compares the literal with what the author wrote. ## PM readings, measured All on `origin/main` `3599fef1` with the built CLI (`node packages/cli/bin/run.js`), against temp projects that link this worktree's built `@objectstack/spec`. - **H1, reproduced.** The stack is a one-package stack the schema accepts, with `datasources: [{ name: 'docs', label: 'Docs', driver: 'mongo', config: { url: 'mongodb://mongo.internal:27017/docs' } }]`. `migrate meta objectstack.config.ts --from 16 --write --json` gave exit 0, `"applied": []` and `"write": {"status": "written", "files": [], "written": [], "manual": [], "unexplained": []}`. The source kept `driver: 'mongo'`. A later load (`os validate`) printed: `defineStack: datasources[0].driver: 'mongo' → 'mongodb' (converted at load; conversion 'datasource-driver-mongo-to-mongodb', retires in protocol 18). Update the source to the canonical shape — the conversion stops running then.` - A first probe with `config: {}` was REFUSED, because a mongo datasource needs a connection target. It took the raw hand-back, and the conversion was listed and written. Only an accepted stack shows the defect. - **H2, confirmed by instrument.** `loadConfig(…, { authoredSource: true }).config.datasources[0].driver` was `'mongodb'`, and so was the chain input after `normalizeStackInput(…, { convert: false })`. `stackConversions` named `datasource-driver-mongo-to-mongodb`. On success the shim's `__tolerant` returned the real result. Where `driver` is converted: `buildDefinedStack` (`packages/spec/src/stack.zod.ts`) calls `normalizeStackInput` with conversions on, in both modes. That calls `applyConversions`, which runs `datasourceDriverMongoToMongodb` (`packages/spec/src/conversions/registry.ts`). No other `define*` helper runs the pass. - **H3, fixed as above.** After the change, the same H1 run gave `applied` = `[datasource-driver-mongo-to-mongodb @ datasources[0].driver, mongo → mongodb]`, `write.files` = `[{ objectstack.config.ts, sites: 1 }]`, `verification.ok: true` and `schemaValid: true`. The source line now reads `driver: 'mongodb'`. A later `os validate` printed no `converted at load` line, and a re-run applied nothing. - **Nothing to convert is unchanged, measured on the corpus.** All four example apps are ACCEPTED by `defineStack` and carry no load-path conversion, so the fix changes each one's chain input. I ran `migrate meta --from 16 --json` and `--from 17 --json` on `app-crm`, `app-multi-package`, `app-showcase` and `app-todo`. The eight `--json` documents, minus `duration`, are byte-identical by md5 before and after. I also ran `--write --from 16` on a throwaway copy of each app. The `--json` outcome and the written bytes are identical by md5: `app-showcase` writes 3 sites in `src/automation/flows/index.ts` and `app-todo` 1 site in `src/flows/task.flow.ts`, both before and after. - **The `--out` snapshot changes for an accepted stack.** It is now the authored stack plus the chain's edits, as it already was for a refused stack. Measured on the three one-package apps, every difference is one of two kinds. Either a key is present only BEFORE: parse defaults such as `manifest.scope`, `manifest.defaultDatasource`, `flows[].version` and `hooks[].runAs`, and `objects[].actions` from `defineStack`'s action merge. Or the parse transformed a value: `hooks[].condition` and `flows[].edges[].condition`. Nothing appears that the author did not write. The composed app's snapshot is unchanged. The changeset says so. - **H4, measured. NOT fixed here.** The project is `composeStacks([ServiceStack, AppStack], { manifest: 'preserve' })`, with the service body carrying the accepted mongo datasource. Before AND after this change, `--from 16 --write --json` gives exit 0, `applied: []`, `write.files: []` and `schemaValid: true`. The sources keep their md5, and a later load prints the notice once. With a refused spelling added to the same body (`time` default `'10:00Z'`), `applied` lists only `time-default-utc-suffix-dropped @ packages[0].manifest.objects[0].fields.starts_at.defaultValue`, because the `strict: false` re-production converts the driver. - **Why it stops.** The chain's per-body run reads `packages[i].manifest`. `composeStacks` assembles that body from the stack the input's `defineStack` call RETURNED (`assemblePackageBody`: `{ ...stack.manifest }`, then each package-owned key). To start that run from the authored input, `packages/cli` needs two things it does not have as values: 1. The mapping from entry to input (`preservePackageEntries`: an input with `packages` contributes its own entries, and an input without `manifest` contributes none). 2. The body assembly itself. The codemod's `packageBodyKeys` gives the key SET, and its `composedBody` step states the assembly over SYNTAX nodes. Neither produces a value. Writing either as a value is a second copy of composition's rule. The real `composeStacks` refuses the authored inputs at its step 0 (`STACK_PROVENANCE_MISSING`). Marking them would take the spec's provenance key, or `defineStack`, which converts. So this half needs a `packages/spec` seam, for example a `defineStack` mode that skips the D2 pass, or an exported body assembler. It goes back to the seat. - **H5, holds.** An accepted call is kept (`__keepAuthored`), and a refused call is handed through (`__recordStack`). They are the two arms of one `try`. The composed re-production calls the REAL `defineStack` (`__specRoot.defineStack`), not the wrap, so it keeps nothing. A composed artifact carries no record, so `loadConfig` uses it as before. The pin is a stack carrying one refused spelling and one load-path spelling: each conversion is applied once, and both are written. ## Pins — `test/migrate-meta-load-conversions.test.ts`, `unit` tier The tests run in-process over `MigrateMeta.run`, against temp projects that link the real spec. No process is spawned and no kernel is booted. `vitest list --project unit` lists the file, and `--project integration` does not. 5 tests: - **The fix.** The accepted mongo stack. The dry run lists `datasource-driver-mongo-to-mongodb @ datasources[0].driver`. `--write` writes it: `write.files` = `[{ objectstack.config.ts, sites: 1 }]`, `manual: []`, `unexplained: []`, and the bytes are the source with `'mongo'` → `'mongodb'` and nothing else. The re-run applies nothing. The strict load's `stackConversions` goes from naming the conversion to `[]`. The reload is read through `stackConversions`, not the stderr notice, because `defineStack` prints that notice once per process. - **Where the argument is read.** With `export const onEnable` beside the default, the authored-source load gives `driver: 'mongo'`, `namedExports: ['onEnable']`, `onEnable` merged, `stackProvenance: true` and `stackConversions` naming the conversion. The strict load still gives `'mongodb'`. - **Defined twice.** `defineStack(defineStack({ … }))` lists the conversion exactly once. - **Control.** An already-canonical source gives `applied: []` and `write` = `{ status: 'written', files: [], written: [], manual: [], unexplained: [] }`, and every byte is unchanged. Its `applied`, `todos`, `absentTodos` and `schemaValid` equal those of the chain over the stack `defineStack` built, which is where the chain started before this change. - **Mixed (H5).** One refused spelling (`time` default `'10:00Z'`) and one load-path spelling (the valid mongo datasource). `applied` = `[mongo, time]`, each once. `write.files` = `[{ objectstack.config.ts, sites: 2 }]`, and both edits are written. The re-run applies nothing, and the strict load accepts and converts nothing. PR objectstack-ai#22326's pins (`test/migrate-meta-composed.test.ts`, 8 tests, including both one-package controls) stay green. ## Ablations Each ablation went through `scripts/ablation-replace.mjs`. The anchor hit once, the mutation was proven on disk, and the restore was proven: blob `0488d7e55d38` = HEAD, and `git diff HEAD` empty. The suite imports `src` directly, so no `dist/` is involved. The direction was stated before each run. 1. **The converted hand-through restored in the loader** (`authoredBase` = `baseConfig`). Predicted 3 red, 2 green. Got 3 red (the fix, where the argument is read, defined twice) and 2 green (control, mixed). The fix pin's first red: `expected [] to deeply equal [ { …(4) } ]`. 2. **The shim no longer keeps the argument** (the `defineStack` wrap passes only `__recordStack`). The same 3 red, 2 green. 3. **The record followed one level only.** Predicted 1 red. Got 1 red (defined twice) and 4 green. ## Local verification at `038ef735d` - **CLI unit tier**: `pnpm --filter @objectstack/cli exec vitest run --project unit --maxWorkers=2` gave `Test Files 269 passed (269)` and `Tests 3956 passed (3956)`. - **Typecheck**: `pnpm --filter @objectstack/cli typecheck` (`tsc --noEmit`, then `check:test-typecheck`) exited 0, ending `check:test-typecheck: OK … 3 file(s) / 28 error(s) / 6 pinned signature(s) held`, the pre-existing ledger. It ran at `93f524998`. The only later commit is the changeset, so the TypeScript tree is the same. - **Integration tier**: declared to CI, because this diff touches no integration-tier file and no spawn entry. As a targeted check, I ran the two integration files that drive `migrate meta` over an authored source, `test/migrate-meta-default-range.test.ts` and `test/migrate-meta-engine-guidance.test.ts`, with `--project integration`: `Test Files 2 passed (2)`, `Tests 10 passed | 1 skipped (11)`. - **Gates**: `node scripts/pm/dispatch-gates.mjs --commands`, re-derived over this diff, gives the same 65 commands the order listed. All 65 exited 0. `check:dual-build-cjs-loads` first exited 3 (`PREREQUISITE NOT MET`: 8 packages outside the closures I had built had no `dist/`). After building those 8 it passed (`106/66/712/1` entries/packages/cjsFiles/probes). `dispatch-gates --ran` reports `65 derived, 65 run, 0 NOT-MEASURED, 0 UNRUN`. - **Lint**: `pnpm lint` (`eslint . --no-inline-config`, the whole repo) exited 0 with no findings. - **Base**: the branch is on `3599fef1`. `main` has since moved 6 commits, and none touches `packages/cli`, the spec's stack or provenance modules, or PR objectstack-ai#22326's changeset. I did not merge it; CI's merge ref covers the join. ## Acceptance notes - **PR objectstack-ai#22326's "Known limit" sentence** ends "…, the same as for an input the current schema accepts." Inside a composed project, which is its context, it stays true. Read as the one-package path, this PR makes the comparison stale. This PR's changeset restates the limit for both kinds of composed input. I did not edit the other changeset: it is outside the claimed file surface. - **`content/docs/upgrading.mdx`**: I read the `--write` paragraph. Nothing in it goes stale, because it makes no claim about load-path conversions. No docs edit. - **Named exports, in this one load.** `migrate meta`'s merge now judges whether a named export is shadowed against the authored argument, not the built stack. A top-level key that the parse defaults and that the author also exports would now merge instead of being reported as shadowed. Measured on the corpus, no TOP-LEVEL key differs between the two; every difference is nested. - **A stack spread from a built stack** (`defineStack({ ...base, … })`) still reaches the chain with `base`'s converted values. The spread drops the record, as it drops the provenance mark, and `--write` refuses that site as `spread` anyway. - **A one-input `composeStacks([x])`** returns `x` itself. Measured: it now lists `datasource-driver-mongo-to-mongodb @ datasources[0].driver`, and `--write` lists that site as `helper` under the codemod's existing composed-key rule. ## Round 2 (REWORK `6068295985`), written by the `domain:cli` seat - **Merged `main`** with a merge commit, `daa27b7c7` (parents `038ef735d` and `35afb1587`). There was no rebase and no force-push, and no path conflicted. - **PR objectstack-ai#22326's pending changeset, corrected** (`2f8e479a3`). In `.changeset/22289-migrate-meta-composed-project.md`, the "Known limit" bullet's closing clause ", the same as for an input the current schema accepts" is removed. Nothing else in that file moves. - This PR makes that clause false, and both changesets ship in one `@objectstack/cli` release. - It is a deliberate correction of another PR's pending note, so `check-empty-changeset`'s foreign-changeset rule is red by design. The seat confirms that in a comment on this PR. - The first Acceptance note above (PR objectstack-ai#22326's "Known limit" sentence) no longer applies. - **The open question (the `--out` snapshot) is ruled A.** It is kept as shipped and declared in this PR's changeset. - **Re-verified by the dev at `2f8e479a3`,** after a full `turbo run build`: - CLI unit tier: 269 files, 3956 tests passed. Typecheck exit 0. `pnpm lint` exit 0. - The same 65 gates re-derived over the final diff: 64 exit 0, and `check-empty-changeset` exits 1 on the foreign-changeset rule only. `dispatch-gates --ran`: `65 derived, 65 run, 0 NOT-MEASURED, 0 UNRUN`. - **The docs-drift advisory (`6068211309`), re-derived at `2f8e479a3`** with `node scripts/docs-audit/affected-docs.mjs --json 35afb15`. It lists 16 hand-written pages and 9 release-owned ones (read-only). - The two pages that describe `os migrate meta` (`content/docs/upgrading.mdx` and `content/docs/deployment/cli.mdx`) make no claim this PR changes. The `--out` row says "the migrated stack as a JSON snapshot", which still holds. `cli.mdx:1354` is about `--stored`, which this PR does not touch. - The other 14 name `os migrate meta` as a remedy, which stays true. - So no docs edit is owed. --- _Generated by [Claude Code](https://claude.ai/code/session_01RWZbGvPFcRKvUqASZtunCU)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
… inside composed package bodies (objectstack-ai#22393) Fixes objectstack-ai#22256 Clause-②: no (a seam off the public entry: an internal `Symbol.for`-keyed parameter, undeclared on `DefineStackOptions` and not exported; the seat's answer A in its review on objectstack-ai#22256) The composed half of objectstack-ai#22256 (the one-package half landed in PR objectstack-ai#22351). Inside a `composeStacks([…], { manifest: 'preserve' })` project, `os migrate meta` now lists a conversion the load already applies inside a package body (for example `datasources[].driver: 'mongo'`), `--write` writes it into the file that authored that input, and the next load converts nothing. This holds whether the input's `defineStack` call accepted its argument or was refused and produced again in `strict: false` mode. ## The seam (H2), and why it is off the public entry `composeStacks` assembles each `packages[i].manifest` body from the stack its input's producer RETURNED, and `defineStack` runs the load-time ADR-0087 D2 conversion pass in both modes. No existing switch skips that pass for a `defineStack` call (no option, env or global; `applyConversions` has none either), so a `packages/spec` change is required. Candidates measured: | | carrier | api-surface rows | export-origins | package `exports` | `DefineStackOptions` type | verdict | |---|---|---|---|---|---|---| | C1 | an internal parameter: a spec-owned `Symbol.for` key read off `defineStack`'s options, honoured only with `strict: false`, that skips the D2 pass | +0 | +0 | +0 | unchanged | **chosen** | | C2 | a symbol-keyed `composeStacks` option assembling bodies from per-input authored sources | +0 | +0 | +0 | unchanged | rejected: bodies would be authored while the flattened half stays converted and parsed, so the option-B strip comparison fails on parse defaults and the artifact shape changes even on canonical projects | | C3 | a declared `DefineStackOptions` member (a `convert: false` twin of `NormalizeStackInputOptions.convert`) | +0 (rows are `name (kind)` only) | +0 | +0 | widened | public option, `Clause-②: yes` | | C4 | a new export (`preservePackageEntries` / `assemblePackageBody`, or a `composeAuthoredStacks`) | +1 | +1 | entry | n/a | public export, `Clause-②: yes` | | C5 | CLI-only: fill the marked empty object `composeStacks([])` returns with authored content | n/a | n/a | n/a | n/a | rejected: it would attach a producer's mark to content no producer judged (ruling B) | | C6 | CLI-only: rebuild the bodies in `packages/cli` | n/a | n/a | n/a | n/a | refused by the `domain:cli` seat's cut | C1 is the conversion-free twin of the `strict: false` re-produce the shim already performed for refused inputs (PR objectstack-ai#22326), so the semantic delta is exactly "the D2 pass is skipped". A strict call ignores the key, so it can never let an old spelling reach the strict parse unconverted; the output is marked as every non-strict output is. The key is documented beside `defineStack` (`AUTHORED_INPUT_OPTION`, `packages/spec/src/stack.zod.ts`) and is neither declared on `DefineStackOptions` nor exported. `check:api-surface` and `check:export-origins` stay green with no regenerated artifact. ## The consumer (H3) `packages/cli/src/utils/config.ts`, the authored-source shim's `composeStacks` wrap: every input whose `defineStack` call the shim saw is produced again from what the author wrote, through the real `defineStack` in `strict: false` mode with the key. A refused input is its recorded hand-through (as before); an accepted one is the argument PR objectstack-ai#22351 keeps under `AUTHORED_ARGUMENT_KEY`, followed to the innermost literal. Inputs the shim never saw a `defineStack` call build (a nested composition, a plain object) reach the real `composeStacks` untouched, so `STACK_PROVENANCE_MISSING` for an unwrapped input is unchanged. Composition then runs its own rule over the authored inputs. No composition logic is copied into `packages/cli`. `meta.ts` and the `--write` planner are unchanged in code (comment only): the planner already traces `packages[i].manifest.KEY` to input i's literal (PR objectstack-ai#22326). Measured: the H4 site `packages[0].manifest.datasources[0].driver` is written into `src/service.stack.ts`, the input's own module, and nothing else moves. ## Measurements **H1 on `origin/main` `16096e8d7` (BASE), built CLI, composed fixture** (`composeStacks([ServiceStack, AppStack], { manifest: 'preserve' })`, the service body carrying `driver: 'mongo'`): | case | `applied` | `write.files` | source | `os validate` reload | |---|---|---|---|---| | A: accepted input | `[]` | `[]` | keeps `'mongo'` | prints `converted at load … datasource-driver-mongo-to-mongodb` | | B: refused input (`'10:00Z'`), re-produced `strict: false` | only `time-default-utc-suffix-dropped` | `src/service.stack.ts` 1 site | keeps `'mongo'` | prints `converted at load` | | C: canonical | `[]` | `[]` | unchanged | — | **After the fix** (same fixtures, `--from 16`): | case | `applied` | `write.files` | verification | re-run | `converted at load` lines on reload | |---|---|---|---|---|---| | A | `datasource-driver-mongo-to-mongodb @ packages[0].manifest.datasources[0].driver` | `src/service.stack.ts` 1 site | ok | `applied []` | 0 | | B | mongo then time, both under `packages[0].manifest…` | `src/service.stack.ts` 2 sites | ok | `applied []` | 0 | | C | `[]` | `[]` | — | `[]` | 0 | **Corpus control, BASE vs fix** (both trees built, the four example apps, `app-multi-package` being the composed one; `--from 16` and `--from 17`; `--json` dry and `--write` on same-depth copies; `duration` removed): 16 of 16 documents byte-identical by md5, the written-file sets identical, and the written bytes identical (`app-showcase` `src/automation/flows/index.ts` `2aec23aad9d3`, `app-todo` `src/flows/task.flow.ts` `4ffb6d9aa6ce`, in both trees). **The `--out` snapshot of a composed project** moves, as ruling A (REWORK `6068295985`) already accepted for one-package stacks: on `app-multi-package` at `--from 17`, 53 differences, every one a key the schema's parse fills in that exists only on BASE (`externalId`, `hidden`, `multiple`, `readonly`, `searchable`, `sortable`, `unique`, `required`, `scope`, `defaultDatasource`, `datasource`, `isSystem`, `priority`, `deleteBehavior`, `active`, `isDefault`, `expanded`). No value changed and nothing was added. The changeset states it. ## Pins - `packages/spec/src/stack-authored-input.test.ts` (3): the key keeps the authored spelling, the output is marked, and no conversion is recorded, while the control without it converts; a strict call ignores it; `composeStacks` over such inputs assembles the body from the authored spelling, and over plain non-strict inputs from the converted one. - `packages/cli/test/migrate-meta-composed-load-conversions.test.ts` (5, `unit` tier, in-process): the H4 accepted case listed, written into the input's file and nothing else, a clean re-run, and a strict reload with an empty `stackConversions`; the authored-source load's body carries `'mongo'` while every other load's carries `'mongodb'`; an input defined twice is listed once; the refused input re-produced by the shim lists and writes both conversions; the canonical control writes nothing and its `--json` summary equals the one the built composition gives. - PR objectstack-ai#22326's 8 composed pins and PR objectstack-ai#22351's 5 one-package pins stay green beside them. ## Ablations Both committed first, mutated through `scripts/ablation-replace.mjs` (anchor hit once, mutation proven on disk, restore proven: blob equal to HEAD and `git diff HEAD` empty, whole-tree `git status --porcelain` empty). 1. **The seam** (dist leg, because the CLI suite resolves `@objectstack/spec` through its `exports`): deleted `convert: strict || !asksForAuthoredInput(options),` from `buildDefinedStack`. Presence reading on the pristine build: the marker in 4 built files. After the spec rebuild, `ablation-dist-preflight --absent`: absent from all 232 built files, tree reading "mutate leg". Predicted spec 2 red / 1 green and the new CLI file 4 red / 1 green (control green), the 13 neighbouring pins green; got exactly that (`Tests 2 failed | 1 passed (3)`; `Tests 4 failed | 14 passed (18)`). Restore leg: rebuild, marker present in 4 built files, tree clean, `3 passed (3)` and `18 passed (18)`. 2. **The accepted arm of the shim** (no dist leg: the CLI suite imports `src`): made `__composable` hand an accepted input through as built. Predicted 3 red (the three accepted-input pins) / 15 green; got `Tests 3 failed | 15 passed (18)`. ## Deliberate correction of two pending changesets This change makes the "Known limit" bullet of two pending, unreleased changesets false, and all three ship in the same `@objectstack/cli` release: `.changeset/22289-migrate-meta-composed-project.md` (PR objectstack-ai#22326) and `.changeset/22256-migrate-meta-load-path-conversions.md` (PR objectstack-ai#22351). Each loses exactly that one bullet; nothing else in either file moves. `check-empty-changeset`'s foreign-changeset rule is therefore red by design, in its DELIBERATE CORRECTION class (precedent PR objectstack-ai#21683 and PR objectstack-ai#22351); it asks for the correction to be confirmed on the PR, not restored. ## Tests and gates All at `a0d0a6253` (this branch merged with `origin/main` `117d34de3`), after rebuilding the CLI closure: - `pnpm --filter @objectstack/cli exec vitest run --project unit --maxWorkers=2` (the whole unit tier): `Test Files 271 passed (271)`, `Tests 3973 passed (3973)`. The integration tier is declared to CI: the diff touches no integration-tier file and no spawn entry. - `pnpm --filter @objectstack/spec exec vitest run --project local --maxWorkers=2` (the package's `test` script): `Test Files 627 passed (627)`, `Tests 18745 passed | 1 todo (18746)`. - `pnpm --filter @objectstack/spec --filter @objectstack/cli run typecheck`: exit 0. Both `check:test-typecheck` ledgers hold unchanged (spec 52 files / 246 errors / 135 signatures; cli 3 / 28 / 6), so both new test files compile clean. - eslint, narrowed, as a measurement rather than a skip. Population: the 5 changed `.ts` files, all inside eslint's linted set (no "file ignored" notice in the JSON output). Count from `--format json`: 5 files, 0 errors, 0 warnings. Invariance: `eslint.config.mjs` enables no type-aware linting (no `parserOptions.project`, no `projectService`), so this diff cannot move the verdict of any untouched file. The full `pnpm lint` is CI's. - `pnpm --filter @objectstack/spec check:generated`: all 15 generated artifacts up to date, tree clean. `node scripts/pm/dispatch-gates.mjs --commands` (no paths) at `a0d0a6253` derives 90 commands from the change set (8 paths against merge base `117d34de3`). All 90 ran with their exit codes recorded, and `--ran` answers `90 derived famil(ies) accounted for — 90 run, 0 NOT-MEASURED`. - 89 exit 0, among them `check:api-surface` ("public API surface + factory signatures unchanged"), `check:export-origins` ("5363 exports across 19 entry points resolve exactly as recorded"), `check:dual-source-exports`, `check:exported-any`, `check:docs`, `check:nul-bytes`, `check:cross-package-test-inputs`, `check-changeset-no-major` and `check-adr-0087-registration`. - One exit 1, by design: `check-empty-changeset --base origin/main`, the foreign-changeset rule on the two corrected changesets above. - `check:dual-build-cjs-loads` first exited 3 (PREREQUISITE NOT MET: 8 packages without `dist/`). After those 8 were built it exited 0, and that is the code recorded. ## Acceptance notes - The 22289 changeset's "What changed" bullet and the `stack-provenance.ts` module header described the conversion as applied in both modes. The text-only round `9abe8861b` (seat review `6072731732`) made both exact. The bullet now says a refused input "is produced again by `defineStack(input, { strict: false })` with the load-time conversions skipped (objectstack-ai#22256) before it is handed to `composeStacks`", and the header names the one exception. - A single-input `composeStacks([x])` under `os migrate meta` now hands the chain `x` produced again as authored (normalised, bound actions merged) instead of the raw argument. Only the `--out` snapshot of such a project can differ, and only when it has bound standalone actions. This edge's `--out` now differs from a one-package stack's snapshot shape, which keeps bound actions unmerged (contract review `6073322979`, finding 6). Carrier: the `domain:cli` seat, at the next change to `--out`. - C5 above rests on an observation, not a filed defect: a built stack is an ordinary mutable object, so the mark never covered later mutation; `composeStacks([])` is one instance of that. - Docs: no hand-written page under `content/docs` describes the composed limit (searched), and no `skills/**` text states it. --- _Generated by [Claude Code](https://claude.ai/code/session_01DhTqaEHqPVSVnAkjG3jywn)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
Fixes #22289
Clause-②: no
os migrate metanow runs on a project whose config exportscomposeStacks([defineStack({ … }), …], { manifest: 'preserve' }), and it converts the retired spellings inside the package bodies. The one-package path is unchanged. Every change is inpackages/cli. There is nopackages/specchange, and nothing inpackages/cliwrites the provenance mark: the producer marks its own output.What changed
src/utils/config.ts, the authored-source shim). The shim records each stack it hands through for a refuseddefineStackcall, with the options it was called with. A generatedcomposeStackswrap maps only those recorded inputs through the realdefineStack(input, { ...options, strict: false }), then calls the realcomposeStacks. Every other input reaches the realcomposeStacksuntouched, including a plain object the author never wrapped, so that one is still refused. The one-package path keeps handing the authored argument through raw (H3 below says why). The record is one bundled module that every shim module imports, so a stack handed through by one@objectstack/specentrypoint is recognised by another, and a record lasts for one load only.src/commands/migrate/meta.ts,applyMetaMigrationsToPackages, used at all three chain call sites: the main run, the--writere-run and the empty-range probe). An option-B artifact keeps each definition only underpackages[i].manifest, and the conversions walk a stack's own collections. So the chain now also runs over each body as a stack, and lists each body edit under the body's own path (packages[0].manifest.objects[0].fields.starts_at.defaultValue).appliedkeeps hop order,todosis the stack's own list, andabsentTodoskeeps an entry only when every run names it. A stack with nopackageslist gets the plain chain result back.--writetraces a body edit to the input that authored it (src/utils/authored-source-codemod.ts). The walk now followscomposeStacks([…]), but only intopackages[i].manifest.KEY, and only when every input up to i is a stack literal with amanifestand nopackagesof its own. Then entry i is input i's body.KEYis read the way composition assembles a body: from the input when it is a package-owned collection the input writes, otherwise from the input'smanifest. Any other composed key is refused ashelperand listed.src/utils/stack-collections.ts:packageOwnedCollectionKeys()is exported, unchanged, so the codemod reads the CLI's one derivation of that key set instead of writing a second one.mainexported it too, for the enumeration pin (PR fix(cli): os serve and seven sibling readers read a multi-package config's package-owned keys through its package bodies #22321). After the merge it is one export, and one doc paragraph names both readers.test/normalized-call-sites.test.ts: the ledger row for the renamed call site moves fromtop-leveltopackages, with the measurement named.--writecondition, stated where authors read it. The changeset and the--writeparagraph ofcontent/docs/upgrading.mdxboth say exactly when a package-body change is written: input i and every input before it must be a stack literal that writes its ownmanifestand nopackages. Otherwise package i is not one input's body, and the change is listed. The docs paragraph keeps its list true: the clause that a value built by a function call is listed now reads "any other function call". The docs edit is prose only, and the seat declared it todomain:devx.Round 2: the seat's REWORK
6065347099mainwith a merge commit,82f398833(parents122ae3bd9and28bff18d0c). There was no rebase and no force-push.src/utils/stack-collections.tsconflicted, because both sides exportedpackageOwnedCollectionKeysand each added a paragraph naming its own reader. The resolution keeps the export and writes one paragraph naming both. Themeta.tsauto-merge (main foldspendingDataMigrationsonto the union stack) is clean. The second enumerationmainadded totest/normalized-call-sites.test.ts(every top-level read of a package-owned key inpackages/cli/src) is green over this PR's code. Its "no read is unclassified" test names nothing new, so there was nothing to classify.content/docs/upgrading.mdx: the--writeparagraph is edited as described in item 6.PM readings, measured
Measured with the built CLI against temp projects that link this worktree's built
@objectstack/spec. "Before" isorigin/main4e4111ca0.H1, reproduced. The composed project (every input wrapped in
defineStack, atimedefault'10:00Z'in the service package) gave--from 17 --jsonexit 1 with"code":"STACK_PROVENANCE_MISSING"and the messagecomposeStacks provenance check failed (1 input): 'com.probe.svc' (stack #0) was not built by \defineStack`. … Wrap each input: …. The one-package control gave exit 0 withapplied=[time-default-utc-suffix-dropped @ objects[0].fields.starts_at.defaultValue]`.H2, confirmed. The shim hands back
authored[0]unmarked,composeStacksis not wrapped, and its step 0 refuses the unmarked input. The composed project is refused only when a body'sdefineStackrefused: the same project with'10:00'gave exit 0,applied: [],schemaValid: true.H3, measured. The plain route changes the control, so it was not taken. Variant A, a
strict: falsefallback for every refuseddefineStack(applied to the built dist for the measurement only, then restored by md5), was run on two one-package controls at--from 16and--from 17:datasources[0].driver: 'mongo', at--from 16: before,applied=[datasource-driver-mongo-to-mongodb @ datasources[0].driver, time-default-utc-suffix-dropped @ objects[0]…]andwrite.files=[{ objectstack.config.ts, sites: 2 }], with the source written to'mongodb'. Under variant A,applied=[time-default-utc-suffix-dropped]only,sites: 1, and the source keeps'mongo'.So the one-package path keeps the raw hand-back, and the route applies only where
composeStacksneeds a marked input (change 1). Measured after the change: c1 and c2 at both majors give a--jsonsummary (applied,schemaValid, the wholewriteobject) and written source bytes identical by md5 to the baseline.H4, confirmed, and fixed in
packages/cli. After change 1 alone, the composed project gave exit 0, butapplied: []andschemaValid: false, with the schema refusingpackages.0.manifest.objects.0.fields.starts_at.defaultValue. The artifact's top level held onlymanifestandpackages. After change 2:applied=[time-default-utc-suffix-dropped @ packages[0].manifest.objects[0].fields.starts_at.defaultValue]andschemaValid: true. With--write, the authored literal is rewritten,verification.okis true, and a second run applies nothing. The walker is unchanged.H5, holds. A truly unwrapped input (a plain object beside a wrapped one) is still refused with
STACK_PROVENANCE_MISSING, and the refusal names only'com.probe.app' (stack #1). The refusal text is unchanged.Real project.
examples/app-multi-package(composed, preserve, valid) under--from 17 --jsongives exit 0,applied: [],schemaValid: true.Pins —
test/migrate-meta-composed.test.ts, unit tierThe test runs in-process over
MigrateMeta.runagainst temp projects that link the real spec. It spawns no process and boots no kernel, so it runs inTest Corewith the rest of the CLI unit tier. 8 tests:src/service.stack.ts) loads, andappliedis exactly the body site above, withschemaValid: true.--write.write.files=[{ src/service.stack.ts, sites: 1 }]. The tree equals its old bytes with only that literal edited. The re-run givesapplied: [], and a strictloadConfig(no shim) accepts the sources and reportsstackProvenance: true.--writeon a body it cannot trace. For an input built by a local function, the change is listed ashelperand every byte is left alone.--from 16,applied(conversion, path, from, to) andwrite.filesequal the values measured on4e4111ca0, and so do the written bytes.STACK_PROVENANCE_MISSING, naming'com.probe.app'and not'com.probe.svc'.packages, the result equalsapplyMetaMigrations. With bodies: copy-on-write (the input is unmutated, and an untouched body keeps its identity),appliedequals the hops' concatenation, and eachabsentTodosis a subset of itstodosby identity.Ablations
Each ablation used
scripts/ablation-replace.mjs: the anchor hit once, the mutation was proven on disk, and the restore was proven (blob equals HEAD,git diff HEADempty). The suite importssrcdirectly, so no dist is involved. The runs were atfb129c3e1, when the file had 7 tests.__composablereturns its input). 3 red, 4 green. The two composed pins went red with the original falseSTACK_PROVENANCE_MISSING. The boundary pin also went red, because the refusal then names'com.probe.svc'too (2 inputs). The controls and the merge pins stayed green. Restored: blob982786a6180d.applyMetaMigrationsToPackagesreturns the plain result). 3 red (both composed pins and the merge pin,applied[]), 4 green. Restored: blobe1aa2f776836.composeStackswalk disabled. 1 red (the--writepin:write.files[]), 6 green. Restored: blob359b722e7b4c.Local verification at
122ae3bd9(round 1)pnpm --filter @objectstack/cli exec vitest run --project unit): 267 files and 3936 tests pass.vitest list --project unitliststest/migrate-meta-composed.test.ts, and--project integrationdoes not. The integration tier is CI's: this diff touches no integration file and no spawn entry.pnpm --filter @objectstack/cli typecheck, which istsc --noEmitpluscheck:test-typecheck): exit 0.dispatch-gates.mjs --commands, re-derived over this diff, gives the same 65 commands the order listed. All 65 exit 0, anddispatch-gates --ranreports 65 derived, 65 run, 0 NOT-MEASURED, 0 UNRUN. On an earlier pass with only the CLI closure built,check:dual-build-cjs-loadsandcheck:i18n-coverageexited 3 (prerequisite not met). After a fullturbo run buildboth pass:106 published require entry point(s) across 66 package(s) loadand13 config(s) … none new.pnpm lint, which iseslint . --no-inline-config): exit 0, no findings.Local verification at
db3e20d1c(round 2, merged head)--project integration): 97 files pass, with 922 tests passed and 2 skipped.pnpm --filter @objectstack/cli typecheck): exit 0.dispatch-gates.mjs --commands, re-derived over the final diff, gives 95 commands: the 65 from round 1 plus 30 docs-family gates thatcontent/docsadds. All 95 exit 0, after a fullturbo run build.dispatch-gates --ranreports 95 derived, 95 run, 0 NOT-MEASURED, 0 UNRUN. Among them:check:doc-authoring✓,check:doc-anchors("466 internal #fragment link(s) … all resolve"),check:docs-single-h1✓, speccheck:docs("225 generated files in sync") andcheck:nul-bytesOK.pnpm lint): exit 0, no findings.Acceptance notes
--writenever writes a conversion the authored-source load already applied (e.g.driver: 'mongo') — the chain sees a pre-converted stack and reportsapplied: []#22256's class, now also reached on a composed project. For a refused composed input,strict: falsestill runs the load-time D2 pass. Measured: a composed body carrying both'10:00Z'anddriver: 'mongo'lists and writes the time default, while'mongo'is applied at load, is not listed, and is not written. That is the same as for any input the current schema accepts. [finding] cli(migrate meta):--writenever writes a conversion the authored-source load already applied (e.g.driver: 'mongo') — the chain sees a pre-converted stack and reportsapplied: []#22256 remains open, and its fix will need to cover this path as well as the one-package one. The changeset states the limit.objects: { probe_slot: { … } }) is converted and listed.--writelists it asmismatch("an object where the loaded value is an array") and writes nothing, becausestrict: falsehas already normalized the body to an array. Measured. The one composed example in this repo uses array form. Carrier: none.absentTodos. On today's registry the intersection agrees with the top-level verdict: every relevance question isstack-declares, which already readspackages[], and the two entries that judge a conversion both ask aboutagents. No fixture separates the two, so no pin claims to. The intersection is kept because a body's own applications are visible only to that body's run.manifest-permissions-string-list-removed), and it already walkspackages[].manifest. A body run finds nomanifestkey, so nothing is applied twice.planProtocolRangestill reads only the top-levelmanifest's declared range. Whether a package body's own declared range needs the same rewrite was not measured here.src/utils/stack-collections.ts,test/normalized-call-sites.test.tsand the onecontent/docs/upgrading.mdxparagraph were added to the claim's file surface at review. Nothing else moves.--writeparagraph ofcontent/docs/upgrading.mdxwould have gone false with this change, so it is edited here (round 2, prose only). The seat declared the edit todomain:devx.6065243708) lists 18 hand-written pages; its list is omitted above 15 rows. I re-derived it on this tree withnode scripts/docs-audit/affected-docs.mjs --json 28bff18d0c, which gave the same 18 pages plus 9 release-owned ones. I read each page for a statement about--writetracing,STACK_PROVENANCE_MISSINGor the authored-source load. Only theupgrading.mdxparagraph above goes false, and it is edited here.deployment/cli.mdxline 1433 ("writes the ones it can trace to one literal") and line 1896 (os validate's provenance refusal) stay true.Written by session
session_01RWZbGvPFcRKvUqASZtunCU, rounds 1 and 2; body refreshed 2026-10-08T18:14Z.