Skip to content

fix(cli): os migrate meta runs on a composeStacks project and migrates its package bodies - #22326

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

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

Conversation

@objectstack-fleet

@objectstack-fleet objectstack-fleet Bot commented Oct 8, 2026 •

Copy link
Copy Markdown
Contributor

Fixes #22289
Clause-②: no

os migrate meta now runs on a project whose config exports composeStacks([defineStack({ … }), …], { manifest: 'preserve' }), and it converts the retired spellings inside the package bodies. The one-package path is unchanged. Every change is in packages/cli. There is no packages/spec change, and nothing in packages/cli writes the provenance mark: the producer marks its own output.

What changed

  1. A refused composed input is produced again by the producer (src/utils/config.ts, the authored-source shim). The shim records each stack it hands through for a refused defineStack call, with the options it was called with. A generated composeStacks wrap maps only those recorded inputs through the real defineStack(input, { ...options, strict: false }), then calls the real composeStacks. Every other input reaches the real composeStacks untouched, 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/spec entrypoint is recognised by another, and a record lasts for one load only.
  2. The chain runs over each package body (src/commands/migrate/meta.ts, applyMetaMigrationsToPackages, used at all three chain call sites: the main run, the --write re-run and the empty-range probe). An option-B artifact keeps each definition only under packages[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). applied keeps hop order, todos is the stack's own list, and absentTodos keeps an entry only when every run names it. A stack with no packages list gets the plain chain result back.
  3. --write traces a body edit to the input that authored it (src/utils/authored-source-codemod.ts). The walk now follows composeStacks([…]), but only into packages[i].manifest.KEY, and only when every input up to i is a stack literal with a manifest and no packages of its own. Then entry i is input i's body. KEY is read the way composition assembles a body: from the input when it is a package-owned collection the input writes, otherwise from the input's manifest. Any other composed key is refused as helper and listed.
  4. 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. main exported 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.
  5. test/normalized-call-sites.test.ts: the ledger row for the renamed call site moves from top-level to packages, with the measurement named.
  6. The --write condition, stated where authors read it. The changeset and the --write paragraph of content/docs/upgrading.mdx both say exactly when a package-body change is written: input i and every input before it must be a stack literal that writes its own manifest and no packages. 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 to domain:devx.

Round 2: the seat's REWORK 6065347099

  1. Merged main with a merge commit, 82f398833 (parents 122ae3bd9 and 28bff18d0c). There was no rebase and no force-push. src/utils/stack-collections.ts conflicted, because both sides exported packageOwnedCollectionKeys and each added a paragraph naming its own reader. The resolution keeps the export and writes one paragraph naming both. The meta.ts auto-merge (main folds pendingDataMigrations onto the union stack) is clean. The second enumeration main added to test/normalized-call-sites.test.ts (every top-level read of a package-owned key in packages/cli/src) is green over this PR's code. Its "no read is unclassified" test names nothing new, so there was nothing to classify.
  2. The changeset states the condition above, and the code did not change.
  3. content/docs/upgrading.mdx: the --write paragraph 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" is origin/main 4e4111ca0.

  • H1, reproduced. The composed project (every input wrapped in defineStack, a time default '10:00Z' in the service package) gave --from 17 --json exit 1 with "code":"STACK_PROVENANCE_MISSING" and the message composeStacks 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 with applied=[time-default-utc-suffix-dropped @ objects[0].fields.starts_at.defaultValue]`.

  • H2, confirmed. The shim hands back authored[0] unmarked, composeStacks is not wrapped, and its step 0 refuses the unmarked input. The composed project is refused only when a body's defineStack refused: 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: false fallback for every refused defineStack (applied to the built dist for the measurement only, then restored by md5), was run on two one-package controls at --from 16 and --from 17:

    • c1, the refused stack: unchanged.
    • c2, the refused stack plus datasources[0].driver: 'mongo', at --from 16: before, applied = [datasource-driver-mongo-to-mongodb @ datasources[0].driver, time-default-utc-suffix-dropped @ objects[0]…] and write.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 composeStacks needs a marked input (change 1). Measured after the change: c1 and c2 at both majors give a --json summary (applied, schemaValid, the whole write object) 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, but applied: [] and schemaValid: false, with the schema refusing packages.0.manifest.objects.0.fields.starts_at.defaultValue. The artifact's top level held only manifest and packages. After change 2: applied = [time-default-utc-suffix-dropped @ packages[0].manifest.objects[0].fields.starts_at.defaultValue] and schemaValid: true. With --write, the authored literal is rewritten, verification.ok is 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 --json gives exit 0, applied: [], schemaValid: true.

Pins — test/migrate-meta-composed.test.ts, unit tier

The test runs in-process over MigrateMeta.run against temp projects that link the real spec. It spawns no process and boots no kernel, so it runs in Test Core with the rest of the CLI unit tier. 8 tests:

  • The fix. A two-file composed project (the service stack in src/service.stack.ts) loads, and applied is exactly the body site above, with schemaValid: true.
  • --write. write.files = [{ src/service.stack.ts, sites: 1 }]. The tree equals its old bytes with only that literal edited. The re-run gives applied: [], and a strict loadConfig (no shim) accepts the sources and reports stackProvenance: true.
  • --write on a body it cannot trace. For an input built by a local function, the change is listed as helper and every byte is left alone.
  • Control. For both one-package stacks above at --from 16, applied (conversion, path, from, to) and write.files equal the values measured on 4e4111ca0, and so do the written bytes.
  • Boundary. The unwrapped input is refused with STACK_PROVENANCE_MISSING, naming 'com.probe.app' and not 'com.probe.svc'.
  • Merge contracts. With no packages, the result equals applyMetaMigrations. With bodies: copy-on-write (the input is unmutated, and an untouched body keeps its identity), applied equals the hops' concatenation, and each absentTodos is a subset of its todos by 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 HEAD empty). The suite imports src directly, so no dist is involved. The runs were at fb129c3e1, when the file had 7 tests.

  1. The raw hand-back restored on the composed path (__composable returns its input). 3 red, 4 green. The two composed pins went red with the original false STACK_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: blob 982786a6180d.
  2. The body pass removed (applyMetaMigrationsToPackages returns the plain result). 3 red (both composed pins and the merge pin, applied []), 4 green. Restored: blob e1aa2f776836.
  3. The codemod's composeStacks walk disabled. 1 red (the --write pin: write.files []), 6 green. Restored: blob 359b722e7b4c.

Local verification at 122ae3bd9 (round 1)

  • CLI unit tier (pnpm --filter @objectstack/cli exec vitest run --project unit): 267 files and 3936 tests pass. vitest list --project unit lists test/migrate-meta-composed.test.ts, and --project integration does not. The integration tier is CI's: this diff touches no integration file and no spawn entry.
  • Typecheck (pnpm --filter @objectstack/cli typecheck, which is tsc --noEmit plus check:test-typecheck): exit 0.
  • Gates. dispatch-gates.mjs --commands, re-derived over this diff, gives the same 65 commands the order listed. All 65 exit 0, and dispatch-gates --ran reports 65 derived, 65 run, 0 NOT-MEASURED, 0 UNRUN. On an earlier pass with only the CLI closure built, check:dual-build-cjs-loads and check:i18n-coverage exited 3 (prerequisite not met). After a full turbo run build both pass: 106 published require entry point(s) across 66 package(s) load and 13 config(s) … none new.
  • Lint (pnpm lint, which is eslint . --no-inline-config): exit 0, no findings.

Local verification at db3e20d1c (round 2, merged head)

  • CLI unit tier: 268 files and 3951 tests pass.
  • CLI integration tier (--project integration): 97 files pass, with 922 tests passed and 2 skipped.
  • Typecheck (pnpm --filter @objectstack/cli typecheck): exit 0.
  • Gates. dispatch-gates.mjs --commands, re-derived over the final diff, gives 95 commands: the 65 from round 1 plus 30 docs-family gates that content/docs adds. All 95 exit 0, after a full turbo run build. dispatch-gates --ran reports 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 ✓, spec check:docs ("225 generated files in sync") and check:nul-bytes OK.
  • Lint (pnpm lint): exit 0, no findings.

Acceptance notes

  • The route's one cost is [finding] cli(migrate meta): --write never writes a conversion the authored-source load already applied (e.g. driver: 'mongo') — the chain sees a pre-converted stack and reports applied: [] #22256's class, now also reached on a composed project. For a refused composed input, strict: false still runs the load-time D2 pass. Measured: a composed body carrying both '10:00Z' and driver: '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): --write never writes a conversion the authored-source load already applied (e.g. driver: 'mongo') — the chain sees a pre-converted stack and reports applied: [] #22256 remains open, and its fix will need to cover this path as well as the one-package one. The changeset states the limit.
  • A body authored in map form (objects: { probe_slot: { … } }) is converted and listed. --write lists it as mismatch ("an object where the loaded value is an array") and writes nothing, because strict: false has 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 is stack-declares, which already reads packages[], and the two entries that judge a conversion both ask about agents. 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-level conversions. One exists (manifest-permissions-string-list-removed), and it already walks packages[].manifest. A body run finds no manifest key, so nothing is applied twice.
  • Not measured. planProtocolRange still reads only the top-level manifest's declared range. Whether a package body's own declared range needs the same rewrite was not measured here.
  • File surface. src/utils/stack-collections.ts, test/normalized-call-sites.test.ts and the one content/docs/upgrading.mdx paragraph were added to the claim's file surface at review. Nothing else moves.
  • Docs. The --write paragraph of content/docs/upgrading.mdx would have gone false with this change, so it is edited here (round 2, prose only). The seat declared the edit to domain:devx.
  • The docs-drift advisory (comment 6065243708) lists 18 hand-written pages; its list is omitted above 15 rows. I re-derived it on this tree with node 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 --write tracing, STACK_PROVENANCE_MISSING or the authored-source load. Only the upgrading.mdx paragraph above goes false, and it is edited here. deployment/cli.mdx line 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.

@github-actions

github-actions Bot commented Oct 8, 2026 •

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 1 package(s): @objectstack/cli, touching 28 documentable anchor(s). ⚠️ 1 changed file(s) yielded no anchor (packages/cli/src/utils/stack-collections.ts), so the pages documenting them are NOT COVERED by this run — this is not a clean bill of health for those files.

18 hand-written doc(s) name something this change touched — list omitted above 15 rows. Re-derive on the tree named below: node scripts/docs-audit/affected-docs.mjs --json 28bff18d0c4013db86d61eba87c739c3145e17fd.

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

What this run could not see
  • 1 changed file(s) yielded no anchor (packages/cli/src/utils/stack-collections.ts) — pages documenting those are invisible to this run
  • 1 anchor(s) matched too much of the corpus to be a work list: defineStack (literal, 66 pages)
  • 4 name(s) were too generic to anchor anything (single lowercase words)
  • the SDK route bridge reached 54 of 206 client-bound route-ledger rows — the other 152 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 152: 0 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 55 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 97 are undecided (no in-repo declaration, on a ledger that has other in-repo registrars — absence and an unreadable spelling are not distinguishable here). The rows themselves: node scripts/docs-audit/affected-docs.mjs --bridge-coverage
  • a page that states a rule by its inputs shares no identifier with the emitter that implements the rule, so an emitter-only diff cannot list it — not on this run and not on any run. Measured on fix(driver-sql): emit varchar(maxLength) for a text field a declared index keys on #11430: content/docs/protocol/objectql/types.mdx documents the text-family column mapping by the ObjectQL type names it maps FROM (text / textarea / html) while the diff changed createColumn; it went unlisted, and it was the page that diff falsified, in four places. No shared token exists to detect this on, so a rule your change carries has to be re-read by hand in the pages that restate it.
  • a key NAME is not a key, so the hand re-read the line above prescribes can land on the wrong schema. The same spelling is authorable on one governed type and a [REMOVED] tombstone on another for each of active, aria, joins, objects, template, tools and version (censused on [finding] tools is a key on BOTH AgentSchema (tombstoned, dead) and SkillSchema (live, cloud-attested), so a name-based search attributes skill examples to the agent key — it produced a false stop-the-line alarm on PR #19059 #19093 over the liveness ledger's governed types, top-level keys); nothing in a search result distinguishes the two, so a grep hit on a LIVE example reads as evidence about the DEAD key. Measured on fix(spec): the agent.tools liveness row says dead — it claimed live on a key the schema tombstoned #19059: content/docs/ai/agents.mdx was reported as contradicting the agent.tools tombstone over its tools: example at :161, which is inside the defineSkill({ block opened at :155 — the page was already correct. Settle ownership by PARSING the value against both schemas, never by the name: that literal PASSES SkillSchema, and as an AgentSchema it FAILS at tools with the tombstone prescription. ⛔ These names are not the whole class — a key retired through a .strict() guidance map leaves no tombstone in the walked shape and none of them here (tool.category, live as AIToolDefinition.category).

Coarse fallback — 28 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): node scripts/docs-audit/affected-docs.mjs --json 28bff18d0c4013db86d61eba87c739c3145e17fd → packageMentionDocs.

Which tree this was computed on

This run read content/docs from 7fc032ff0fa1f8020c5357a53fd5e65569e15459 — the merge of head db3e20d1c90e31bf4e2f0dd16d255170ad7439e8 into base 28bff18d0c4013db86d61eba87c739c3145e17fd, 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 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

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

claude added 2 commits October 8, 2026 17:26
packageOwnedCollectionKeys stays exported; its doc names both readers.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RWZbGvPFcRKvUqASZtunCU
@objectstack-fleet
objectstack-fleet Bot marked this pull request as ready for review October 8, 2026 18:33
@objectstack-fleet
objectstack-fleet Bot added this pull request to the merge queue Oct 8, 2026
Merged via the queue into main with commit 59fb299 Oct 8, 2026
44 checks passed
@objectstack-fleet
objectstack-fleet Bot deleted the claude/issue-22289-migrate-meta-composed branch October 8, 2026 19:01
akarma-synetal pushed a commit to akarma-synetal/framework that referenced this pull request Oct 9, 2026
…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>
akarma-synetal pushed a commit to akarma-synetal/framework that referenced this pull request Oct 9, 2026
… 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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentation Improvements or additions to documentation size/l tests tooling

Projects

None yet

2 participants