Skip to content

fix(cli): os serve and seven sibling readers read a multi-package config's package-owned keys through its package bodies - #22321

Merged
objectstack-fleet[bot] merged 6 commits into
mainfrom
claude/issue-22288-serve-package-requires
Oct 8, 2026
Merged

objectstack-fleet[bot] merged 6 commits into
mainfrom
claude/issue-22288-serve-package-requires

Conversation

@objectstack-fleet

Copy link
Copy Markdown
Contributor

Fixes #22288
Clause-②: no

What this changes

A composeStacks([…], { manifest: 'preserve' }) config carries every package-owned key (requires, tiers, analyticsCubes, flows, objects, …) once, inside the body of the package that declared it. Its top level carries none of them. os serve read the capability providers to mount from the top-level requires only (serve.ts:2847 on 6729e107), so a capability a package declares was not mounted. The card named two sibling readers. The enumeration this PR adds found the same top-level read in seven more readers in packages/cli. All of them are fixed here.

Every reader now uses one rule: resolveStackCollection (utils/stack-collections.ts, the rule #22285 gave the build preflight), which takes the top-level value when the stack carries one and otherwise every package body's. Or it folds the stack with authoringRuleUnionStack where the stack is loaded. A stack with no packages[] reads exactly as before. serve imports only stack-collections.ts (core plus spec), so it stays off artifact-packages.ts and @objectstack/lint.

reader file change
os serve providers (requires) commands/serve.ts stackDeclaredCapabilities(config), a new one-line export over resolveStackCollection(…, 'requires')
os serve tiers commands/serve.ts resolveStackCollection(config, 'tiers')
os serve analytics cubes commands/serve.ts the top level first (its array, then the legacy cubes spelling), then the bodies
os serve declared-flow count (automation line) commands/serve.ts resolveStackCollection(config, 'flows').length
os migrate plan / apply providers utils/schema-migration-plugins.ts stackDeclaredCapabilities(config)
os generate missing-capability hint utils/scaffold-wiring.ts declaredCapabilities reads by the same rule; JSDoc rewritten (below)
os generate types / client / migration commands/generate.ts the four readers fold the loaded config on entry
os doctor commands/doctor.ts folds once where it normalizes the config
os diff commands/diff.ts folds each side where it is loaded
os migrate meta data-migration advice commands/migrate/meta.ts folds at the one call of pendingDataMigrations

Measured, through the doors

Each door was run with bounded boots/runs of two-package fixtures and their one-package controls. BEFORE is 6729e107, AFTER is this branch. The fixtures are the ones in the pins below.

door two packages, before two packages, after one package (control, before = after)
os serve (config, no artifact), package requires: ['automation'] Info: Optional service not present: automation; banner 31 plugins, no AutomationServicePlugin Plugin loaded: com.objectstack.service-automation; banner 32 plugins incl. AutomationServicePlugin Plugin loaded: com.objectstack.service-automation
os serve, package requires: ['ai'] (no open-edition provider) ✓ Server is ready exit 1 ✗ Capability "ai" resolves to @objectstack/service-ai, which is not available in the open edition … the same exit 1 line
os dev, package analyticsCubes [Analytics] Service started with 0 cubes: (none) … with 1 cubes: prb_note_cube … with 1 cubes: prb_note_cube
os dev, package screen flow, no automation no flow line ⚠ Flows: 1 flow(s) declared but the automation engine is not enabled … the same line
os serve, package tiers without auth ignored: com.objectstack.auth mounted, ready exit 1 ✗ This stack mounts no auth, … the same exit 1
os migrate plan, host plugin hard-depending on automation, package requires: ['automation'] exit 1 ✗ [Kernel] Dependency 'com.objectstack.service-automation' not found for plugin 'com.probe.connector' exit 0, Composed AutomationServicePlugin for requires: ['automation'] … exit 0, same note
os generate flow done --object prb_ticket, app package declares ['automation','triggers'] ⚠ It will not run yet: objectstack.config.ts does not require 'automation', 'triggers' and a requires: line to add ✓ Reaches the stack, no warning no warning
os generate types --dry-run 0 record interfaces 2 2
os generate migration --format sql --dry-run 0 CREATE TABLE 2 2
os doctor no metadata check ran; ✅ Environment is healthy circular-dependency and unused-object checks ran (2 unused) the same 2
os diff --json, one object added to the service package total: 0 total: 1, prb_extra added total: 1
os migrate meta --from 16 --json, a file field in the service package dataMigrations: [] adr-0104-file-references adr-0104-file-references

The PM readings (H1–H5)

  • H1, reproduced, and narrower than the card says. Bare os serve on a two-package config reads [] (table, row 1). os dev and os start reach the same line of code, but they do NOT reproduce the defect. Both boot a compiled artifact: os dev always compiles, and os start auto-compiles when no artifact exists. createStandaloneStack resolves the artifact's packages[] (resolveArtifactCollections), and mergeBootConfig lays the resolved requires over the top level. Measured on 6729e107 with the same two-package fixture: os dev and os start each log Plugin loaded: com.objectstack.service-automation once and Optional service not present zero times. So does os serve with a built dist/objectstack.json beside the config. The requires defect is therefore confined to a config boot with no compiled artifact. Triage graded p1 on os dev / os start reach. On this measurement, the requires half does not reach them. The tiers, analyticsCubes and flow-count halves do reach os dev (table, rows 3–5), because the artifact path does not carry those keys.
  • H2. Serve calls the existing rule (resolveStackCollection) and does not copy it. The import stays off artifact-packages.ts.
  • H3, measured, with two narrowings rather than one. (1) A capability a package declares with no installed provider now stops an os serve config boot. This is table row 2, and the same exit 1 the one-package app always gave. os dev / os start already stopped there, since the artifact path made those tokens "declared". (2) A package's own tiers now apply on os serve, os dev and os start (row 5). The claim named one narrowing. Both are named in the changeset. Nothing in this repo boots a multi-package config whose packages declare requires or tiers. examples/app-multi-package declares neither, and no test boots a preserve config with either.
  • H4. Both readers read [] on a multi-package config, and both are fixed (rows 6 and 7). The os generate hint now reads the list a server mounts. Its JSDoc keeps the defineStack half true. On a one-package stack the list is its top-level requires, which is also what defineStack's trigger rule reads. On a multi-package stack, defineStack judged each package against its own requires when the config loaded. A refusal there is the load-failed branch, reached before the hint reads anything, so the union is what decides whether the item runs.
  • H5. test/normalized-call-sites.test.ts gains a second table. It enumerates every member read of a package-owned key off a stack-named receiver in packages/cli/src. The key set comes from packageOwnedCollectionKeys() (now exported from stack-collections.ts, derived from the two schemas, 37 keys, envelope keys excluded). Every read is classified. A resolved row carries evidence: code that must be present in the file (comments and strings blanked), which proves the fold. A top-level row carries the reason. 53 reads in 15 files after the fix: 6 top-level rows (the build preflight's config.requires ×2, serve's config.analyticsCubes first leg and config.docs, collect-docs' artifact stack.docs, scaffold-wiring's presence probe) and 12 resolved receivers. The grammar's bounds are written in the file: a stack held under another name, a computed key, a member chain, and a cast whose type has parentheses. None of those spellings reads a package-owned key in src today.

Pins, and the tier each runs in

  • test/serve-package-declared-capabilities.test.ts: integration tier (per-PR), runServe( boots. Covers automation mounted on two packages, the one-package control, the ai narrowing, cubes plus the flow line, and the tiers narrowing. 5 boots, about 60s under a shared box. Not named *.e2e.test.ts, so it gates PRs.
  • test/package-owned-command-readers.test.ts: integration tier (per-PR), spawns os doctor, os diff --json and os migrate meta --json, two packages vs one.
  • src/utils/schema-migrate.requires-providers.integration.test.ts: integration tier, a second case where a package declares requires, on the real kernel boot.
  • test/package-owned-readers.test.ts: unit tier. stackDeclaredCapabilities, the os generate hint and the types/migration generators against real composeStacks output, each paired with one defineStack.
  • test/normalized-call-sites.test.ts: unit tier, the enumeration, with a scanner self-test (read forms, writes skipped, envelope keys and prose ignored) and a positive control on the real tree.

Ablation (one-time; nothing permanent left behind)

Run with scripts/ablation-replace.mjs (WRAP mode, the anchor must hit, restore on EXIT/INT/TERM), at 6a30bcff0. No build leg: the scanner reads src and the spawned CLI runs src through tsx (bin/run-dev.js).

  • serve.ts back to Array.isArray((config as any).requires) ? … : []: anchor 1→0, blob 7a588141d660→4bf8b068950a. Three tests red: no read is unclassified, naming commands/serve.ts :: config.requires; two packages: the service package's automation is mounted; and the ai narrowing. The one-package control and the cubes/tiers cases stayed green. Restored: blob 7a588141d660 == HEAD, git diff HEAD empty.
  • scaffold-wiring.ts back to a top-level-only read: two hint tests red in package-owned-readers.test.ts. Restored: blob 3ec828a90838 == HEAD, git diff HEAD empty.

Direction observed: red, as expected.

Verification

Everything below ran at the merged head a58d828b0. That is this branch merged with origin/main at dc4a5c630, which brought #22304's serve.ts change. The merge was clean, and all four edits to serve.ts were checked present after it. The dists were rebuilt after the merge.

  • pnpm --filter @objectstack/cli typecheck: exit 0. That is tsc --noEmit, then check:test-typecheck: OK … 3 file(s) / 28 error(s) / 6 pinned signature(s) held, so no new signature.
  • pnpm --filter @objectstack/cli exec vitest run --project unit: 267 files, 3943 tests passed.
  • pnpm --filter @objectstack/cli exec vitest run --project integration: 97 files, 922 passed, 2 skipped. This tier is owed locally because the diff adds integration-tier files and touches the serve boot path.
  • Gate union: node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commands derives the same 67 commands as the dispatch order lists. All 67 ran and exited 0. The --ran reconciliation reads 67 derived, 67 run, 0 NOT-MEASURED, 0 UNRUN. On the pre-merge tree, check:dual-build-cjs-loads and check:i18n-coverage first refused with PREREQUISITE NOT MET because some packages had no dist. Those dists were built and both gates re-ran green.
  • pnpm lint (full, eslint . --no-inline-config): exit 0.
  • Before the merge, at d12d93891: the unit tier (267/3943), the integration tier (96 files, 919 passed, 2 skipped), full lint and the gate union were all green as well.

Acceptance notes (observations, not filed)

  • Artifact-only boots. createStandaloneStack's result carries requires, objects, manifest, permissions, positions and i18n, but not tiers, analyticsCubes or flows. An os start with an artifact and no config therefore reads those three as empty even for a one-package artifact. Inferred from the code, not measured. That is a different shape from this card (not multi-package specific). Carrier: none.
  • Doc embed lint. collectAndLintDocs' comment says a doc already on a package body "is also in docs above, where lintMetadataEmbeds has judged it". That was true of the additive shape, but the 2026-09-22 addendum emits a body's docs only in the body, so a package body's inline docs may not reach the embed lint. Inferred, not measured. Carrier: none.
  • Legacy spellings kept as they were. Serve's cubes fallback, and os generate's data?.objects fallback, are consumer-side spellings that predate this card. They are kept byte-for-byte in precedence, not endorsed.
  • Wiring lines. On a multi-package config, the os generate "Not wired" advice still says inside defineStack({ … }) without naming which package. The requires: line it prints is the union of what the packages declare plus what is missing, so it never drops a declared token.
  • metadata: every boot of a multi-package artifact built by os build warns that its flat src/docs pages are "claimed by no package body" — the CLI puts them at the top level by its own rule, and the warning`s remedy cannot be followed #22190, the producer-side member of this family, is not addressed here and remains open.

Generated by Claude Code

claude added 6 commits October 8, 2026 14:20
…ti-package config

A multi-package composeStacks(..., { manifest: 'preserve' }) config carries
every package-owned collection inside the body of the package that owns it and
none at its top level. These readers looked at the top level only:

- os serve: requires (providers mounted), tiers, analyticsCubes (the always-on
  analytics provider's cubes) and the declared-flow count of the automation line;
- os migrate plan/apply: the requires tokens that order a host plugin's hard
  dependency on its provider;
- os generate: the missing-capability hint, and the objects that types, client
  and migration generation emit;
- os doctor, os diff and os migrate meta's data-migration advice.

Each now reads by resolveStackCollection's rule (the top level when present,
otherwise every package body) or folds the stack with authoringRuleUnionStack.
A stack with no packages[] reads exactly as before.

Claude-Session: https://claude.ai/code/session_01RWZbGvPFcRKvUqASZtunCU
Co-Authored-By: Claude <noreply@anthropic.com>
… enumerate every top-level read

- normalized-call-sites.test.ts gains a second table: every read of a
  package-owned key (the key set derived in stack-collections.ts) off a
  stack-named receiver in src, classified resolved (with evidence present in
  the code) or top-level on purpose (with the reason).
- serve-package-declared-capabilities.test.ts boots os serve on two-package
  configs: requires, the requires narrowing, analyticsCubes, the flow count and
  tiers, with the one-package control.
- package-owned-readers.test.ts and package-owned-command-readers.test.ts pin
  os generate, os doctor, os diff and os migrate meta against their
  one-package controls; the migrate plan provider case joins its existing file.

Claude-Session: https://claude.ai/code/session_01RWZbGvPFcRKvUqASZtunCU
Co-Authored-By: Claude <noreply@anthropic.com>
@github-actions github-actions Bot added size/xl documentation Improvements or additions to documentation tests tooling labels Oct 8, 2026
@github-actions

github-actions Bot commented Oct 8, 2026

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

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

29 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 4e4111ca054fe995b5a08a07d273cad18a749ef7.

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

What this run could not see
  • 1 anchor(s) matched too much of the corpus to be a work list: os serve (command, 31 pages)
  • 3 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 4e4111ca054fe995b5a08a07d273cad18a749ef7 → packageMentionDocs.

Which tree this was computed on

This run read content/docs from 774e754eeb1e705cbcaef4d23c94dc2795b66c66 — the merge of head a58d828b0d02a9f7df5ae85aa1387e4531fba0d9 into base 4e4111ca054fe995b5a08a07d273cad18a749ef7, 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 774e754eeb1e705cbcaef4d23c94dc2795b66c66 && git checkout 774e754eeb1e705cbcaef4d23c94dc2795b66c66
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 4e4111ca054fe995b5a08a07d273cad18a749ef7 a58d828b0d02a9f7df5ae85aa1387e4531fba0d9 && git checkout -B drift-repro 4e4111ca054fe995b5a08a07d273cad18a749ef7 && git merge --no-ff a58d828b0d02a9f7df5ae85aa1387e4531fba0d9

node scripts/docs-audit/affected-docs.mjs --json 4e4111ca054fe995b5a08a07d273cad18a749ef7

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

@objectstack-fleet
objectstack-fleet Bot marked this pull request as ready for review October 8, 2026 16:40
@objectstack-fleet
objectstack-fleet Bot enabled auto-merge October 8, 2026 16:40
@objectstack-fleet
objectstack-fleet Bot added this pull request to the merge queue Oct 8, 2026
Merged via the queue into main with commit 28bff18 Oct 8, 2026
36 checks passed
@objectstack-fleet
objectstack-fleet Bot deleted the claude/issue-22288-serve-package-requires branch October 8, 2026 17:17
akarma-synetal pushed a commit to akarma-synetal/framework that referenced this pull request Oct 9, 2026
…rates its package bodies (objectstack-ai#22326)

Fixes objectstack-ai#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 objectstack-ai#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 objectstack-ai#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 objectstack-ai#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. objectstack-ai#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
28bff18`, 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.

---------

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

Projects

None yet

2 participants