Skip to content

fix(cli): read a multi-package artifact's package bodies in the capability preflight and the translation readers - #22285

Merged
objectstack-fleet[bot] merged 5 commits into
mainfrom
claude/issue-22238-package-union-readers
Oct 8, 2026
Merged

objectstack-fleet[bot] merged 5 commits into
mainfrom
claude/issue-22238-package-union-readers

Conversation

@objectstack-fleet

Copy link
Copy Markdown
Contributor

Fixes #22238
Clause-②: no

Fixes #22189

Folded into one PR by the seat's claim (comment 6058224414, posted on both cards). #22190, the producer-side member of the same family, remains open and is not touched here.

What was wrong

composeStacks([a, b], { manifest: 'preserve' }) emits each definition once, inside the body of the package that owns it (ADR-0130 D4, 2026-09-22 addendum). The artifact's top level keeps manifest, packages and i18n, and nothing a package owns. Five @objectstack/cli readers looked at that top level alone, so on a two-package app they judged nothing and answered clean.

What changed

PM readings, measured

All readings use real fixtures: two defineStack packages under composeStacks(…, { manifest: 'preserve' }), a service module plus an app, and the same content in one defineStack as the control. Readings were taken through the built bin/run.js, before on 8cbe255e and after on this branch.

H1, reproduced before the fix.

probe one package (control) two packages, before two packages, after
requires: ['ai'], os validate exit 1, Capability provider check failed exit 0 exit 1, package 'com.probe.svc' — Capability "ai" …
same, os build exit 1 exit 0 exit 1, same line
missing zh-CN pluralLabel, os lint --strict exit 1, i18n/missing-object exit 0, All checks passed exit 1, the same finding at the same path
same, os i18n check zh-CN 7/8, 1 missing 0/0 keys, 100% zh-CN 7/8, 1 missing
same, os i18n check --strict exit 1 exit 0 exit 1
os i18n extract --json 6 app keys per locale 0 6 per locale
undeclared connector key (strict: false package), os validate --json / os build --json warnings 1 0 1

The emitted two-package artifact's top level was manifest,packages, with requires: ["ai"] only in packages[0] (com.probe.svc).

H2. authoringRuleUnionStack measured on the fixtures. It keeps i18n, an envelope key it never touches. It folds translations and every collection back in, so coverage, extraction and the key walk take it as is. It also folds requires, but as one concatenated list with the package lost, which cannot name a package. The preflight therefore reads the per-package bodies instead. A single-package stack comes back by identity (identity: true on both controls).

H3. A refusal on a two-package artifact names its package: package 'com.probe.svc' — Capability "ai" resolves to @objectstack/service-ai, …, in the text face and in --json errors[].message. The { token, message } record shape is unchanged. The single-package text is byte-identical (H6).

H4. The hierarchy-security advisory is the same reader: it is the preflight's installable warning for @objectstack/security-enterprise. Before the fix it was printed for one package and absent (count 0) for two. After, it appears on both doors as package 'com.probe.app' — Capability "hierarchy-security" is provided by …. It is fixed by the same change.

H6, single-package parity. 17 outputs (os validate / os build, text and --json, over the capability, advisory and i18n controls; os lint --strict text and --json; os i18n check plain, --json and --strict). Each output was compared before and after with only the timing tokens normalized: 17 of 17 identical, same exit codes. The only bytes that moved were "duration": N.

Malformed packages (found while measuring). A non-array packages (strict: false) on os validate was refused before by the schema parse (invalid_type); it is now refused one step earlier by the resolver's INVALID_ARTIFACT_PACKAGES, still exit 1. os i18n check and os i18n extract used to accept it with exit 0 and zero keys; they now refuse it with exit 1 and INVALID_ARTIFACT_PACKAGES, the ruling-A direction the other CLI readers already follow. os build is unchanged: its parse runs first.

H5: every (normalized call site in packages/cli/src/commands

There were 19 hits on 8cbe255e (git grep -n "(normalized"). Two are in test files: dev-default-db.test.ts:43 is a string in a test title, and migrate/meta.report-order.test.ts:138 is a test calling applyMetaMigrations on its own fixture. The 17 non-test sites, at this branch's head:

site class reads why
compile.ts:330 lowerCallables(normalized (a) packages lowers the top level and each packages[i].manifest body itself (lower-callables.ts)
compile.ts:541 authoringRuleUnionStack(normalized (a) union the rule table's normalized tier: it is the fold
compile.ts:828 lintUnknownStackKeys(normalized (c) top level judges the envelope's own keys; a package body is closed (inherits the manifest's strict close), so an undeclared key there is refused by the parse, never dropped
compile.ts:829 lintUnknownAuthoringKeys(… (b) union measured blind on two packages (table above); moved here
validate.ts:289 lintUnknownStackKeys(normalized (c) top level as compile.ts
validate.ts:290 lintUnknownAuthoringKeys(… (b) union as compile.ts
validate.ts:346 lowerCallables(normalized (a) packages as compile.ts
validate.ts:529 authoringRuleUnionStack(normalized (a) union as compile.ts
lint.ts:1005 resolveJsxGateManifest(normalized (a) union counts pages over the fold and each body (sdui-manifest.ts)
lint.ts:1006 lintConfig(normalized (a) union folds authoringRuleUnionStack on entry
lint.ts:1031 computeI18nCoverage(… (b) union the defect of #22238
lint.ts:1044 scoreMetadata(normalized (a) union a whole-stack parse plus lintConfig, which folds
i18n/check.ts:161 computeI18nCoverage(… (b) union the defect of #22238
i18n/extract.ts:259 (normalized …).i18n (c) top level i18n is an envelope key (compose disposition single): kept at the top level, carried by no body
i18n/extract.ts:294 extractTranslations(… (b) union measured blind on two packages; moved here
migrate/meta.ts:1062 applyMetaMigrations(normalized (c) top level replays the chain over the AUTHORED stack; --write edits source at the paths it reports, which a folded union would not hold. Which bodies a conversion reaches is the chain's own walk (see Acceptance notes)
migrate/meta.ts:1072 planProtocolRange(normalized (c) top level reads the handshake range off manifest, where the load seam reads it (AppPlugin: bundle.manifest || bundle); a preserve artifact keeps manifest at the top level

(a) already read the union or the bodies; (b) moved onto the union here; (c) top level on purpose. The capability preflight reads config.requires, not normalized, so it is not a row; its per-package reading is pinned separately.

test/normalized-call-sites.test.ts holds the table. It names each site by file and by the callee chain wrapping normalized (for example computeI18nCoverage(authoringRuleUnionStack(normalized), never by line or count. It fails on an unclassified site, on a stale row, and on a row whose verdict disagrees with its spelling. Comments and string bodies are masked; the scanner's own cases cover a spread call, a grouping paren, a method call and prose.

Ablation (run from the committed fix, restored to the HEAD blob)

Each leg used scripts/ablation-replace.mjs in wrap mode: the anchor had to hit, the on-disk counts went 1 → 0 and 0 → 1, the blob changed, then the restore was proven by blob equality and an empty git diff HEAD. No dist/ leg applies: the integration pins spawn the CLI from src/ through bin/run-dev.js, and the enumeration pin reads source text.

  1. lint.ts coverage routed back to computeI18nCoverage(normalized, {, blob dad7d827 → 2acc4f7d. Result: 4 red, 8 green. Red were the os lint --strict pin (lintTwo back to "passed": true, "issues": []) and three enumeration assertions (lint.ts :: computeI18nCoverage(normalized unclassified, its row stale, the positive control). Restored: blob dad7d827 == HEAD.
  2. validate.ts preflight handed packages: [], blob 3f2d706b → 7818f8b0. Result: 1 red, 6 green. Red was the os validate --json pin (exit 0 where 1 is pinned). The os build pin stayed green because its door was not ablated, so the pins are door-specific. Restored: blob 3f2d706b == HEAD.

Verification (head 7bba74ec)

  • pnpm --filter @objectstack/cli exec vitest run --project unit --maxWorkers=2: 265 files, 3925 passed.
  • pnpm --filter @objectstack/cli typecheck: exit 0. check:test-typecheck holds 3 files / 28 errors / 6 pinned signatures, unchanged.
  • vitest run --project integration test/package-union-readers.test.ts: 7 passed (46s). This is the integration file this PR adds. The rest of the integration tier is CI's.
  • The 65 gate commands node scripts/pm/dispatch-gates.mjs --commands derives (identical to the dispatch's list): 65 of 65 exit 0. check:i18n-coverage (13 configs, 621 baselined, none new) and check:dual-build-cjs-loads first exited 3 (PREREQUISITE NOT MET, unbuilt packages); after the prerequisite builds, both reran and exited 0. dispatch-gates --ran: 65 derived, 65 run, 0 NOT-MEASURED, 0 UNRUN.
  • Full pnpm lint: exit 0.
  • origin/main moved 3 commits during the run (spec flow value slots, docs, one unrelated CLI integration test). None touches a file here. git merge-tree is clean, and CI's merge ref covers the rest.

Acceptance notes

  • os serve / os dev read requires at the top level only (serve.ts:2847), the same family outside this card's surface. Measured with a bounded boot: one package with requires: ['automation'] logs Plugin loaded: com.objectstack.service-automation, while the two-package app whose service package declares it logs Optional service not present: automation. The same top-level read sits in utils/schema-migration-plugins.ts:1453 (os migrate host-config capability providers) and utils/scaffold-wiring.ts:118 (the os generate missing-capability hint); those two were not measured. Reported to the seat for filing, not fixed here.
  • os migrate meta on a composed project whose package carries a retired spelling exits 1 with STACK_PROVENANCE_MISSING, naming a stack that is wrapped in defineStack. The authored-source load hands composeStacks an unbuilt stack. The one-package control exits 0 and applies the conversion. So applyMetaMigrations is never reached there, and whether the chain's conversions reach package bodies (the shared walker in spec's conversions/walk.ts does not descend into packages[]; the manifest-level entries do) could not be measured through the door. Reported to the seat.
  • The package 'ID' — prefix is now spelled in two places: artifact-packages.ts's prefixer for author-time findings, and capability-preflight.ts for capability messages. They are separate surfaces, so the module stays free of @objectstack/lint.
  • os i18n extract writes one bundle for all packages: the union, as the pre-addendum flattened artifact gave. Per-package bundle emission is not attempted.

Generated by Claude Code

claude added 4 commits October 8, 2026 11:19
…ility preflight and the translation readers

A `composeStacks(..., { manifest: 'preserve' })` artifact carries its
collections, `translations` and `requires` only inside `packages[]`. Five
readers looked at the top level alone and so judged nothing there:

- the capability preflight (`os validate` / `os build`) now reads each
  package body's `requires` when the top level carries none, naming the
  package in every finding; a top-level `requires` still wins, unchanged;
- translation coverage (`os lint`, `os i18n check`), `os i18n extract` and
  the undeclared-authoring-key walk (`os validate` / `os build`) read the
  package-union stack the author-time rules already use.

Co-authored-by: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RWZbGvPFcRKvUqASZtunCU
…ized` call site

- `normalized-call-sites.test.ts` names each command call site that hands
  `normalized` to an analysis and records which stack it reads; a new
  unclassified site, or a stale row, fails.
- `package-union-readers.e2e.test.ts` runs the real commands over a
  two-package `preserve` app and its one-package control.
- `capability-preflight.test.ts` pins the per-package attribution and the
  unchanged top-level reading.

Co-authored-by: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RWZbGvPFcRKvUqASZtunCU
A `*.e2e.test.ts` name moves a file to the nightly tier, so the pins would
not have gated the merge. Renamed into the per-PR integration tier, and cut
to the nine spawns the pins need.

Co-authored-by: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RWZbGvPFcRKvUqASZtunCU
@github-actions github-actions Bot added the size/l label Oct 8, 2026
@github-actions github-actions Bot added 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 10 documentable anchor(s).

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 fbcbcf124f2aa49259b837bc74cdbf4d08b46b3c.

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

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

Coarse fallback — 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 fbcbcf124f2aa49259b837bc74cdbf4d08b46b3c → packageMentionDocs.

Which tree this was computed on

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

node scripts/docs-audit/affected-docs.mjs --json fbcbcf124f2aa49259b837bc74cdbf4d08b46b3c

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

…narrow

The closing sentence claimed no accepted input changes, while the paragraph
above it records that `os i18n check` and `os i18n extract` now refuse a
non-array `packages` they used to accept. It now names that exception.

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 12:43
@objectstack-fleet
objectstack-fleet Bot enabled auto-merge October 8, 2026 12:44
@objectstack-fleet
objectstack-fleet Bot added this pull request to the merge queue Oct 8, 2026
Merged via the queue into main with commit 6d2da32 Oct 8, 2026
36 checks passed
@objectstack-fleet
objectstack-fleet Bot deleted the claude/issue-22238-package-union-readers branch October 8, 2026 13:24
akarma-synetal pushed a commit to akarma-synetal/framework that referenced this pull request Oct 9, 2026
…fig's package-owned keys through its package bodies (objectstack-ai#22321)

Fixes objectstack-ai#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 objectstack-ai#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 objectstack-ai#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.
- **objectstack-ai#22190**, the producer-side member of this family, is not addressed
here and remains open.

---

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

---------

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