Skip to content

feat(cli+spec): compile a project of N packages into one packages[] artifact, with the assembled package body declared (ADR-0130 D4 producer, #14242 B) - #14513

Merged
hotlong merged 12 commits into
mainfrom
claude/issue-14439-multi-package-build
Sep 2, 2026
Merged

hotlong merged 12 commits into
mainfrom
claude/issue-14439-multi-package-build

Conversation

@hotlong

@hotlong hotlong commented Sep 2, 2026

Copy link
Copy Markdown
Contributor

Fixes #14439
Fixes #14242
Part of #14122

ADR-0130 D4's producer side — the last platform piece before a product can be split into modules. A project is now N ordinary defineStack packages plus one project-level composeStacks([...], { manifest: 'preserve' }), compiled into one artifact carrying packages[], and the load gate parses each entry against a declaration that can actually describe it.

The problem, as three parse seams

An assembled packages[] body has to survive three parses, and every one of them is ObjectStackDefinitionSchema:

# seam anchor before this PR
1 authoring defineStack refused: packages.0.manifest.objects.0: Expected string but received object
2 compile compile.ts ObjectStackDefinitionSchema.safeParse(lowering.lowered) same refusal
3 load metadata/src/plugin.ts _parseAndRegisterArtifact same refusal; collections not on ManifestSchema were silently STRIPPED

ArtifactPackageEntrySchema wraps its body as manifest: ManifestSchema, whose objects is z.array(z.string()) — glob patterns, the authoring-time shape. What the ADR-0130 load path registers is an assembled body whose objects are definitions. One schema was describing two lifecycle stages of one noun, so #14240 could only gate the wrapper.

#14242 recorded three roads; the maintainer took B on 2026-09-02. Road C (widen ManifestSchema.objects into a union of both spellings) was rejected by name: a union that accepts both stages makes neither stage checkable.

What changed

@objectstack/spec — the assembled stage is declared.

  • AssembledPackageBodySchema = the manifest's fields plus every metadata collection the stack schema declares. The key set is DERIVED from COMPOSE_KEY_DISPOSITIONS (the total table that already refuses to compile when a top-level key has no composition rule) and typed as a Pick over STACK_DEFINITION_COLLECTIONS_SHAPE keyed by AssembledPackageBodyKey — so a collection declared in that table but missing from the collections shape is a compile error, not a key that quietly goes missing from every package body.
  • Where the two halves declare the same key (objects, datasources, permissions), the collection wins. That precedence is not chosen here: it is AppPlugin's flatten order ({ ...manifest, ...bundle }) stated as a declaration instead of re-derived at three seams.
  • ArtifactPackageSchema is the artifact-layer entry ({ manifest: ASSEMBLED_BODY }, same reserved wrapper position as D4's { ref, integrity } future), and ObjectStackDefinitionSchema.packages refers to it — which is what makes seams 2 and 3 accept the shape.
  • ArtifactPackageEntrySchema stays exactly as it was: the authoring entry, manifest-only. It needed no widening, because one packages key can serve both stages without a union — the authoring form is an instance of the assembled form (a package identity carrying no collections), not a second branch of it. The one place they genuinely disagree is the glob spelling, and there the assembled meaning wins.
  • The collections literal moved into STACK_DEFINITION_COLLECTIONS_SHAPE and is spread back into ObjectStackDefinitionSchema. Two surfaces need that exact key set and must not be able to disagree; it is also what breaks the declaration cycle (packages' element schema is built FROM the shape, so it cannot live in it). Key order is preserved, and AssembledPackageBodySchema carries an explicit type annotation because inferring it emitted the manifest plus ~35 collections a second time inside the stack schema's own .d.ts and tsc refused to serialize it (TS7056).
  • composeStacks(..., { manifest: 'preserve' }) now folds each input stack's own metadata onto its manifest. Composition is the last moment per-package attribution exists — the composed stack flattens every collection to the top level, and a flattened array cannot say which package each item came from. Reconstructing the split downstream is not a harder version of this; it is impossible.

@objectstack/cli — os build / os compile read packages[]. When the loaded definition carries one:

  • the SAME lowering walks every package body — an un-lowered handler is a function value that JSON.stringify drops without a word, and a packages-carrying artifact is registered THROUGH that list, so the hook would simply not exist at boot. Callables are de-duplicated by function IDENTITY, so the artifact's two copies of one handler name the same ref.
  • the SAME author-time rule table (runAuthoringRules('build', …), @objectstack/lint's one registry) runs once per package, de-duplicated against the union run. Composition flattens, so the union is strictly more permissive than the packages it was built from; the artifact registers per package, so the per-package answer is the one the runtime lives with.
  • one artifact JSON is written whose packages[i] are assembled bodies. os dev boots the same shape from source.

@objectstack/objectql — the load gate is a full parse. resolveArtifactPackageOrder applies ArtifactPackageSchema to the whole entry instead of filtering the verdict down to wrapper-level issues. The body handed to registerApp is still the caller's original — the parse is a gate, and a parsed clone would carry ManifestSchema's defaults and drop undeclared keys, which is what would make the two D4 branches disagree (D7). The module header's record of the mismatch is rewritten.

Accept-set change, in one direction (Clause-② = YES). A packages[] entry whose body carries authoring globs where the assembled stage carries definitions is now refused — at defineStack, at os build, and at load. Nothing in the field produces that shape: packages[] had no producer at all before this PR. needs:contract-review attached; PR left draft for the PM.

Fixture

examples/app-multi-package — two packages, one namespace, one artifact: com.example.multi.core (type: 'app', owns crm_account and the app) and com.example.multi.orders (type: 'module', no scope key, owns crm_order whose account field looks up crm_account). The module is listed FIRST in the composition on purpose: it declares dependencies on the core package and the load path sorts through resolvePluginOrder, so array order is not what decides — and the 'last' manifest pick then gives the artifact its App's identity.

pnpm --filter @objectstack/example-multi-package build emits dist/objectstack.json with packages[] (7.2 KB, artifact manifest com.example.multi.core, packages [orders, core], per-package objects [crm_order] / [crm_account]).

GET /api/v1/packages on the booted fixture, the artifact's own two rows (manifest bodies trimmed to their identity fields here; the full rows carry the assembled objects / apps):

[
  { "id": "com.example.multi.core",   "type": "app",    "namespace": "crm", "scope": "project",
    "version": "1.0.0", "objects": ["crm_account"], "apps": ["multi_crm"], "dependencies": null,
    "writable": false },
  { "id": "com.example.multi.orders", "type": "module", "namespace": "crm", "scope": "project",
    "version": "1.0.0", "objects": ["crm_order"],   "apps": [],
    "dependencies": { "com.example.multi.core": "^1.0.0" }, "writable": false }
]

writable: false on both rows is asserted directly — PR #14430 merged into main at 07:59 UTC while this branch was in flight, so no TODO was left. One honest note: scope reads project rather than being absent, because ManifestSchema.scope carries .default('project') and both defineStack and the compile parse materialise it. The fixture authors no scope key; the pipeline fills it. That is exactly why #14430's verdict has to come from engine.manifests rather than from the row.

Verification

Exit codes captured before any pipe, on 060414178 and re-run after the last commit.

  • Full suites (dependency closure built first): @objectstack/spec 452 files / 12,185 tests, @objectstack/objectql 257 / 4,459, @objectstack/cli 229 / 2,623 — all passing.
  • pnpm --filter @objectstack/spec --filter @objectstack/cli --filter @objectstack/objectql run typecheck — green, both check:test-typecheck ledgers included. @objectstack/example-multi-package and @objectstack/dogfood typecheck green too.
  • New pins: packages/spec/src/assembled-package-body.test.ts (11 — the derived key set, both stages refusing the other's spelling, and one test per parse seam), packages/cli/test/build-multi-package-artifact.e2e.test.ts (6, real os build in a temp project), packages/qa/dogfood/test/multi-package-artifact.dogfood.test.ts (5, real boot + real HTTP door), plus 3 added to packages/objectql/src/artifact-load-path.test.ts (14 total).
  • ADR-0130 D7 — single-package bit-identity, measured rather than argued. examples/app-showcase compiled through the identical command on this branch (9508c9a4) and on its merge base (1d8ad0ff, a separate worktree, its own install and closure build): normalized sha256 44edc51248fa15ea204e383be66ebcd939528fe7433d5244ddebaa91be6d6fa8 on both. Two worktree-derived variations are normalized and named rather than hidden — the absolute paths the compile bakes in from its cwd, and the esbuild bundle hash, which is content-addressed over those same paths; the raw sizes differ by exactly the 20 bytes of path-length difference. The artifact carries no packages key on either side. The compile door's own negative half is pinned in the e2e file (no packages key minted, ref names unchanged, the per-package leg never announced).

Ablations — predicted red set stated before running, both legs, restores by bytes

(a) delete the CLI packages[] branch (the lowering's package walk + the per-package rule leg, both if (false && …)). Predicted: the two build assertions red, the four schema/shape ones green, the dogfood boot unaffected. Observed: 2 failed / 4 passed — exactly the fixture reaches the code path … per-package leg ran and LOWERS the callables inside a package body — under the SAME ref as the top level. The CLI e2e runs the CLI from src through bin/run-dev.js, so there is no dist leg for the subject; asserted in the script rather than assumed. Restore leg: both blobs equal to HEAD by git hash-object, git diff HEAD empty, 0 markers left, suite 6/6 green.

The dogfood boot staying green under (a) is a real finding, not a gap: os dev / bootStack register from source where the callables are still live functions, so the dropped-handler defect is artifact-only.

(b) delete the assembled-form gate — ArtifactPackageSchema's body member replaced with an ungated record, which removes the judgment at BOTH the compile parse and the load gate at once. Every consumer resolves spec through dist, so both legs rebuilt @objectstack/spec and ran scripts/ablation-dist-preflight.mjs. Predicted: spec 1 of 11, objectql 2 of 14, cli 1 of 6. Observed exactly that: the ASSEMBLED entry refuses authoring GLOBS where definitions belong, refuses a body carrying authoring GLOBS (#14242 B) + refuses a body whose collection is malformed at the item level, and refuses a package body carrying authoring GLOBS, naming the path. Mutation confirmed on disk by anchor counts (before 1 / after 0 / injected 1) and in dist by the preflight (marker in 12 built files). Restore leg: source blob equal to HEAD, whole-tree git status --porcelain clean, rebuilt, preflight --absent green over 221 built files, all three suites green again (11/11, 14/14, 6/6).

Gates

node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commands, re-derived against the actual diff (90 commands after the last commits, 80 at first derivation). Findings found and fixed:

  • check:api-surface — 6 added exports; regenerated with gen:api-surface.
  • check:export-origins — regenerated.
  • check:stack-collection-maps — a real break my restructure caused: the gate extracts the stack-collection set from the schema's inline shape literal, and the collections now arrive through a spread. Taught it to follow a line-anchored ...IDENT spread to a const IDENT = { in the same source, and to return null (its loud refusal) rather than an empty set when a spread cannot be resolved — an empty set reconciles perfectly against every site, which is the failure the refusal exists for. 3 self-test assertions added for both directions, and the pass line's hand-written assertion count (already one below the truth) is now computed. Re-run: 8 enumerations reconciled against 31 declared collections, 0 failures — the same set as before the move, which is what proves the restructure changed no key.
  • check:i18n-coverage — the new example was unbaselined; --update added it at 0 untranslated strings.
  • check:type-source-resolution — the new example resolved spec's types through dist; fixed with paths at spec's source (plus the lib/types/noEmit consequences packages/qa/downstream-contract documents for the same reason), not with a registry entry.
  • One unrelated repair the change forced into view: packages/objectql/src/registry-invalidate.test.ts imported type ServiceObject from the package root, where it has never been exported. It was masked as a TS2459 ledger entry; the annotation shrink pushed it to TS2305 and check:test-typecheck refused. Fixed at the import (@objectstack/spec/data, the spelling its 20-odd siblings use) and the ledger re-recorded.

NOT MEASURED (prerequisite unmet, none related to this diff; CI measures all of them): check-test-completeness (needs a saved turbo run test log), pm/check-half-states (no GitHub route from this seat), check:type-check-debt (OOMs here — needs a full-repo build), check:pm-dispatch-gates (green earlier in this run; a later re-run hit the container's 10-minute foreground cap). check-engine-split-ratio refused on the shallow clone until git fetch --shallow-since, then measured green. check-system-context-census green, no repair needed. Everything else in the derived set is green.

Boundaries held

⛔ No second authoring spelling. ⛔ validateSingleApp untouched — each package is still a single-app stack; the project is iterated by packages[]. ⛔ ManifestSchema.objects not widened. ⛔ No ADR edited. ⛔ Marketplace / install surfaces untouched (ADR-0019 D2/D3). ⛔ Cross-artifact co-ownership (D8) not attempted.

One cost, stated rather than hidden: a multi-package artifact carries its definitions twice — flattened at the top level (which the metadata service's artifact door iterates) and again inside packages[i] (which ObjectQL.registerApp iterates). Dropping the flattened half would leave a booted instance with no views, flows or permission sets, since that door reads only the top level. De-duplicating it means teaching the metadata door to read packages[], which is its own decision and its own card — filed as #14512 (three roads, with the measurement) rather than smuggled in here.


🤖 Generated with Claude Code

https://claude.ai/code/session_01UHvF5hyiZjnCyExFnfQB8m


Generated by Claude Code

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UHvF5hyiZjnCyExFnfQB8m
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UHvF5hyiZjnCyExFnfQB8m
@github-actions

github-actions Bot commented Sep 2, 2026 •

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 4 package(s): @objectstack/cli, @objectstack/objectql, @objectstack/dogfood, @objectstack/spec, touching 39 documentable anchor(s). ⚠️ 4 changed file(s) yielded no anchor (packages/objectql/test-typecheck-debt.json, packages/qa/dogfood/package.json, packages/spec/api-surface/root.json, …), so the pages documenting them are NOT COVERED by this run — this is not a clean bill of health for those files.

32 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 7286dd58e806ed321cbdfc23a1c455db8f80b1ad.

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

What this run could not see
  • 4 changed file(s) yielded no anchor (packages/objectql/test-typecheck-debt.json, packages/qa/dogfood/package.json, packages/spec/api-surface/root.json, …) — pages documenting those are invisible to this run
  • 34 name(s) were too generic to anchor anything (single lowercase words)
  • the SDK route bridge reached 47 of 219 client-bound route-ledger rows — the other 172 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 172: 14 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 56 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 102 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.

Coarse fallback — 136 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 7286dd58e806ed321cbdfc23a1c455db8f80b1ad → packageMentionDocs.

Which tree this was computed on

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

node scripts/docs-audit/affected-docs.mjs --json 7286dd58e806ed321cbdfc23a1c455db8f80b1ad

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

@github-actions github-actions Bot added dependencies Pull requests that update a dependency file documentation Improvements or additions to documentation tests tooling labels Sep 2, 2026
…ose — the per-key mapped alias emitted the collections shape a second time and OOM'd the type-check-debt re-measure (#14439)

`AssembledPackageBody` / `AssembledPackageBodyParsed` referenced
`(typeof STACK_DEFINITION_COLLECTIONS_SHAPE)[K]` per collection key. Because
those aliases are exported, the declaration emit wrote the shape const into
`stack.zod-*.d.ts` a second time (21,443 lines beside the 42,449 the stack
schema already inlines), and every consumer program re-inferred all ~35
collection input/output types once more. `Type Check · debt ledger` on the
PR went red: the `qa/http-conformance` TEST_DEBT re-measure exceeded the
4096 MB ceiling the gate pins as CI's, while the merge-queue run on the base
passed the same step. Reproduced locally (exit 3) on b4b9732.

The aliases now keep the DERIVED key set (`AssembledPackageBodyKey`, still
read off `COMPOSE_KEY_DISPOSITIONS`) and type each collection as `unknown`.
Nothing exported references the shape const any more, so the second copy is
not emitted and the per-key inference does not run. `assembledPackageBodyShape()`
keeps its `Pick<typeof STACK_DEFINITION_COLLECTIONS_SHAPE, K>` return type
(internal, not emitted), so a disposition key missing from the collections
shape is still a compile error, and the RUNTIME schema still carries every
collection's full declaration — refusals are unchanged. One spec pin narrows
its element access at the point of use, as every reader of an assembled
body already does.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UHvF5hyiZjnCyExFnfQB8m
…lti-package-build

# Conflicts:
#	scripts/check-stack-collection-maps.mjs

hotlong commented Sep 2, 2026

Copy link
Copy Markdown
Contributor Author

CI red: Type Check · debt ledger — this PR's, being root-caused; not the base's

What fails: pnpm check:type-check-debt exits 3 — PREREQUISITE NOT MET: one re-measure tsc dies with Reached heap limit … JavaScript heap out of memory at the 4096 MB ceiling the gate pins as CI's. The merge-queue run on this PR's base passed the same step (2m49s), and every merge-queue run since is green, so the failure is this PR's.

Reproduced locally on b4b97320: same exit 3, on the packages/qa/http-conformance TEST_DEBT program (the ten programs before it finish in under 30 s each; this one climbs past 3.3 GB and dies).

First fix, pushed as e167d438 (then origin/main merged as 3878a582): the exported AssembledPackageBody / AssembledPackageBodyParsed aliases referenced (typeof STACK_DEFINITION_COLLECTIONS_SHAPE)[K] per key, which made the declaration emit write the collections shape a second time into stack.zod-*.d.ts (21,443 lines beside the 42,449 the stack schema already inlines) and every consumer re-infer ~35 collection types. The aliases now keep the derived key set and type each collection as unknown; stack.zod-*.d.ts went from 64,351 to 42,879 lines and no longer declares the shape const. Runtime validation is unchanged (the schema still carries every collection); one spec pin narrows at its point of use. All fast checks are green on the merged tree (spec/cli typecheck incl. the test-typecheck ledgers, the three pin files 11/6/14, api-surface, export-origins, ADR-0122 alias gate, stack-collection maps).

Still red on 3878a582 — the same lane, same OOM shape, at the same point in the sequence. So the declaration-size doubling was not the whole driver. Now measuring the http-conformance program with --extendedDiagnostics on the PR tree against a base worktree at 53d36892 to see exactly which of this PR's spec changes inflates its heap, then a second fix. No test will be skipped, no ledger raised, no ceiling touched.


Generated by Claude Code

… so the stack schema's printed declaration carries no named alias (#14439)

A named type alias inside `ObjectStackDefinitionSchema`'s printed type
(`manifest: z.ZodType<AssembledPackageBodyParsed, AssembledPackageBody>` on
the `packages` element) can only be IMPORTED by the declaration bundler, never
inlined. `system/environment-artifact.zod.ts` embeds the stack type, so the
bundler turned `stack.zod` into a shared chunk and gave the
`environment-artifact` chunk an import edge into it: every consumer of
`@objectstack/spec/system` started loading the entire stack schema
declaration it never loaded before.

Measured on the `qa/http-conformance` TEST_DEBT re-measure program, same
shape as the gate builds it, 8 GB cap so the peak is measured rather than
hit: base `53d36892` 691,580 lines of definitions / 4,473,321 K heap; PR
head `3878a582` 734,202 (+42,622 — the size of the stack schema's
declaration) / 4,875,249 K — over the 4096 MB ceiling
`scripts/check-type-check-coverage.mjs` pins as CI's, which is the red
`Type Check · debt ledger` lane on both earlier pushes.

`AssembledPackageBodySchema` is now annotated `z.ZodType<Record<string,
unknown>, Record<string, unknown>>` and the two ADR-0122 aliases are derived
FROM the schema (`z.input` / `z.infer`), so nothing named can re-enter the
stack schema's printed type. The runtime schema is unchanged: manifest fields
plus every collection, key set still derived from `COMPOSE_KEY_DISPOSITIONS`.
What consumers lose is static field typing inside an assembled body, which the
PR's readers (`compile.ts`, `artifact-packages.ts`) never relied on.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UHvF5hyiZjnCyExFnfQB8m

hotlong commented Sep 2, 2026

Copy link
Copy Markdown
Contributor Author

CI red → root cause measured, second fix pushed as 23c209f9

The failing lane (Type Check · debt ledger, exit 3 — tsc OOM at the 4096 MB ceiling on the packages/qa/http-conformance TEST_DEBT program) is this PR's. Same program, same shape as the gate builds it (remeasureProject: package tsconfig, test exclusion lifted, default typeRoots), measured on a base worktree at 53d36892 and on this PR's heads with an 8 GB cap so the peak is measured rather than hit:

base 53d36892 PR head 3878a582 PR head 23c209f9
Files 906 912 906
Lines of Definitions 691,580 734,202 (+42,622) 691,218
Types 1,801,110 1,973,165 1,798,148
Instantiations 7,342,314 8,072,109 7,327,734
Memory used (tsc's own count) 4,473,321 K 4,875,249 K 4,434,453 K

Two things follow from that table. First, the base already needs more than 4 GB of heap by tsc's own count and clears the 4096 MB ceiling only through GC pressure — that program has almost no headroom on main, independent of this PR (filed as #14569 for the maintainer). Second, the PR had added +42,622 lines of definitions to a program that imports @objectstack/spec/system, not @objectstack/spec — and 42,622 is the size of ObjectStackDefinitionSchema's declaration.

Mechanism. system/environment-artifact.zod.ts embeds the stack type. On base the declaration bundler inlines that expansion into the environment-artifact chunk, so consumers of @objectstack/spec/system never load the stack schema's declaration. This PR had put a named type alias inside the stack schema's printed type — packages: z.ZodArray<z.ZodObject<{ manifest: z.ZodType<AssembledPackageBodyParsed, AssembledPackageBody, …> }>> — and a named alias can only be imported from the chunk that declares it. So stack.zod became a shared chunk, the environment-artifact chunk grew an import { AssembledPackageBodyParsed, AssembledPackageBody } from './stack.zod-*.js' edge, and every consumer of @objectstack/spec/system started loading the entire 2 MB / 42k-line stack declaration. (e167d438 had removed a second, additive cost — the alias's (typeof STACK_DEFINITION_COLLECTIONS_SHAPE)[K] mapping emitted the collections shape a second time, 21,443 lines — but left the named-alias edge in place, which is why that push stayed red.)

Fix (23c209f9). AssembledPackageBodySchema is annotated with a structural type, z.ZodType<Record<string, unknown>, Record<string, unknown>>, and the two ADR-0122 aliases are derived from the schema (z.input / z.infer) rather than the schema from them. Nothing named appears inside the stack schema's printed type any more; the built dist is back to the base topology (63 declaration files, no stack.zod chunk, the environment-artifact chunk's imports identical to base). The runtime schema is untouched — manifest fields plus every collection, key set still derived from COMPOSE_KEY_DISPOSITIONS — so refusals are unchanged. What consumers lose is static field typing inside an assembled body, which this PR's readers (compile.ts, artifact-packages.ts) never relied on; two pins narrow at their point of use.

Verification on 23c209f9 (exit codes captured before any pipe, dependency closure rebuilt): @objectstack/spec typecheck incl. check:scripts-typecheck and check:test-typecheck 0; @objectstack/cli typecheck 0; pins — spec assembled-package-body 11 + stack-artifact-packages 17, cli build-multi-package-artifact e2e 6, objectql artifact-load-path 14; check:api-surface 0; check:export-origins 0; check:spec-parsed-alias 0; check-stack-collection-maps 0 (self-test 18 assertions). CI's Type Check · debt ledger lane on this head is the authoritative re-measure.


Generated by Claude Code

hotlong commented Sep 2, 2026

Copy link
Copy Markdown
Contributor Author

Contract review — PASS (Clause-② YES, accept-set narrows in one direction)

Disclosure: reviewer and dispatcher are the same session (session_01UHvF5hyiZjnCyExFnfQB8m); the implementing agent was dispatched from this session, and the two CI-fix commits on this head are the reviewer's own. Maintainer authorized same-session self-review for this epic on 2026-09-02. Every claim below was re-verified against the tree, not taken from the PR body — at b4b97320 for the contract itself, and at 23c209f9 for the type-surface change in item 12.

What was checked

  1. Three parse seams accept the assembled shape, and the third no longer strips collections — ObjectStackDefinitionSchema.packages is z.array(ArtifactPackageSchema), whose body half is AssembledPackageBodySchema (packages/spec/src/stack.zod.ts). Pins read: assembled-package-body.test.ts SEAM 1 — defineStack accepts the composed project and SEAM 2/3 — the artifact schema parses it, and no collection is STRIPPED; load seam pinned in packages/objectql/src/artifact-load-path.test.ts (+3).
  2. ArtifactPackageEntrySchema's body half cannot describe the payload the load path actually registers #14242 road B held, C refused — ArtifactPackageEntrySchema byte-unchanged as the authoring entry; ManifestSchema.objects not widened; assembled entry refuses globs (the ASSEMBLED entry refuses authoring GLOBS where definitions belong), authoring entry still refuses definitions.
  3. Key set derived, not transcribed — AssembledPackageBodyKey mapped over COMPOSE_KEY_DISPOSITIONS; the runtime half assembledPackageBodyShape() returns Pick<typeof STACK_DEFINITION_COLLECTIONS_SHAPE, AssembledPackageBodyKey>, so a disposition key absent from the collections shape is a compile error. packages excluded (no nested boundary, D1).
  4. Load gate is a full parse but registers the ORIGINAL body — resolveArtifactPackageOrder throws on !verdict.success and still hands entry.manifest (not verdict.data.manifest) to registerApp; D7 reasoning intact. Issue cap 5 with (+N more) is a reporting change only.
  5. CLI producer — lowerBody is one walk reused for the top level and each packages[i].manifest; register() dedups by function identity so both copies of a handler share one ref; per-package runAuthoringRules('build', …) is the same table, de-duplicated against the union run by findingKey. Malformed entries ride through to the parse that names them.
  6. composeStacks preserve — preservePackageEntries passes an existing packages[] through untouched and assembles { ...manifest, ...ownCollections } for each single-manifest stack; the composed top level stays flattened (additive — the metadata service's artifact door sees what it saw before).
  7. scripts/check-stack-collection-maps.mjs is a tightening — follows only line-anchored ...IDENT spreads to const IDENT = { in the same source; an unresolvable spread returns null (the loud refusal) rather than an empty set; packages stays in NON_COLLECTION_ARRAY_KEYS; 3 self-tests cover resolved / unresolvable / nested-spread-ignored; the assertion count is computed (18) and the Verdict handshake for 134 scripts/** self-tests that exit 0 on an early return #14479 verdict handshake is kept after the merge.
  8. D7 single-package identity — measured in the PR body (same normalized sha256 on branch and merge base, showcase); the negative half is pinned in the e2e file (no packages key minted, ref names unchanged).
  9. Fixture — examples/app-multi-package composes [orders, core] with manifest: 'preserve'; the module has no scope key; GET /api/v1/packages rows carry writable: false on both (feat(packages): GET /packages and GET /packages/:id rows carry the server's own writable verdict (isWritablePackage) #14430), asserted in the dogfood pin.
  10. Changesets — @objectstack/spec minor, @objectstack/cli minor, @objectstack/objectql patch. No governed surface touched. needs:contract-review was attached at open.
  11. Gates — the six required contexts are green on 23c209f9; the agent's NOT MEASURED list (check-test-completeness, pm/check-half-states, check:type-check-debt, check:pm-dispatch-gates) is covered by CI — check:type-check-debt in particular, which was the red lane and is green on this head.
  12. Two CI-fix commits by the reviewer (e167d438, 23c209f9; root cause and measurements in the comment above) — the runtime contract is unchanged (every accept/refuse pin still passes: spec 11 + 17, cli e2e 6, objectql 14), but the STATIC type of an assembled body is now Record<string, unknown> rather than the manifest-plus-collections mapped type: a named alias inside the stack schema's printed declaration had made every consumer of @objectstack/spec/system load the whole stack declaration and OOM'd the debt re-measure. That narrowing touches no accept set (Clause-② stays YES for the reason the PR states, not for this), and readers of assembled bodies already narrow at the point of use; recorded so the trade is explicit. origin/main was merged in (3878a582) for the one conflicting line in scripts/check-stack-collection-maps.mjs.

Non-blocking observations (recorded, not asked)

Removing needs:contract-review, flipping ready, arming auto-merge (MERGE).


Generated by Claude Code

@hotlong
hotlong marked this pull request as ready for review September 2, 2026 13:11
@hotlong
hotlong enabled auto-merge September 2, 2026 13:11
@hotlong
hotlong added this pull request to the merge queue Sep 2, 2026
Merged via the queue into main with commit 7085f90 Sep 2, 2026
40 checks passed
@hotlong
hotlong deleted the claude/issue-14439-multi-package-build branch September 2, 2026 13:46
os-project-manager pushed a commit that referenced this pull request Sep 20, 2026
… type-level gap (#17536)

The at-tier contract review of PR #19323 FAILed this diff on its TEXT, not its
types: four passages asserted a stage discrimination and a closed shape the
published declaration measurably does not provide. The declarations stay exactly
as ruled; the prose around them is corrected, and the gap it used to hide is
pinned.

Measured at this head with `tsc` against the built spec, in a private worktree:

  AssembledInstalledPackage['manifest']            Record<string, unknown>
  (union).manifest.objects                         unknown
  { ...authoringRow, manifest: { bogus: 1, objects: 'not-even-an-array' } }
      against the union AND against
      Awaited<ReturnType<typeof client.packages.get>>          compiles
  `if (Array.isArray(pkg.manifest.objects))`       same union in BOTH branches
  manifest: 'com.acme.crm@1.0.0' (a string)        refused by both branches

  runtime control, built spec:
  InstalledPackageAtEitherStageSchema.safeParse(bogusRow).success   false
  ... .safeParse(rowWithNoObjects) against each stage schema        both true

So: the runtime parse is strict, the TYPE is tolerant of any object manifest,
and `Array.isArray` separates the stages on neither level — at runtime both
stages' `objects` are arrays.

- changeset: drops the "not a tolerant shape" and "the compiler now says so"
  claims; states the runtime/type asymmetry, names #19324 and its root cause
  (`packages/spec/src/stack.zod.ts:1283`, #14513), and replaces the prescribed
  discriminator with a worked `packages/spec` parse.
- `ObjectStackClient.packages.list` docblock: same correction, at the door a
  consumer actually reads; `Array.isArray` is now the documented wrong answer.
- the import-site comment: the closedness belongs to the RUNTIME declaration.
- direction-2 test docblock: says what that pin measures — a string primitive —
  and what it does not.
- new pin `objectToleranceGap19324`: the object tolerance recorded as the
  behaviour it is, with no suppression, so tsc reds on it the day #19324 closes.
- the WRITE-member count was wrong in three places: four members stayed, not
  three (`install`, `enable`, `disable`, `update`).

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QCdUBjM47SxioST9z5Zwdf
akarma-synetal pushed a commit to akarma-synetal/framework that referenced this pull request Sep 28, 2026
…y an index signature, and the runtime schema is the contract (objectstack-ai#20191)

Fixes objectstack-ai#19324

Clause-②: no

Ruling-ref: 5805795339 (batch objectstack-ai#218 item 4, letter 丙). This PR carries
out ruling item 1. It adds one docblock on
`RecordStagePackageBodySchema`, at its `ZodRawShape` cast, and one
beside `AssembledInstalledPackageSchema.manifest`. Both record the four
points the ruling names. ⛔ No type change, ⛔ no schema change. Ruling
item 2 holds: the client gap pin stays, and only its comment text moves.
Per ruling item 3, the card is done when this lands.

## What changed

- **`packages/spec/src/stack.zod.ts`, `RecordStagePackageBodySchema`**
- Its published docblock gains a section, "Its published type is
deliberately an index signature", which carries all four points.
- The internal note above the declaration now says what the `as unknown
as z.ZodObject(z.ZodRawShape)` cast costs, and that the cast emits
nothing.
- **`packages/spec/src/api/package-api-assembled.zod.ts`,
`AssembledInstalledPackageSchema`**
- The docblock ends with a new section, "`manifest`'s published type is
deliberately an index signature", directly above the `manifest` line. It
replaces the old one-paragraph pointer.
- Since `c23cfb346a` the assembled declarations live in this file, not
in `package-api.zod.ts`.
- **`packages/client/src/return-type-precision.test.ts`**
- The two comments that cited `packages/spec/src/stack.zod.ts:1283` now
cite `RecordStagePackageBodySchema` by name.
- They no longer say the gap is this card's to close: it is the accepted
static contract, and the pin reddens the day the body is typed
precisely.
- Comment text only. No assertion, no `@ts-expect-error` and no binding
moved.
- **`packages/client/src/index.ts`, `ObjectStackClient.packages.list`**
(patch round 2, `a2be2ff440`)
- The TSDoc paragraph that called the asymmetry "a KNOWN GAP rather than
a design", tracked on objectstack-ai#19324 and citing `stack.zod.ts:1283`, is
rewritten to the settled reading. The index signature is the accepted
static contract (letter 丙), the runtime Zod schema is the enforced one
so a row is narrowed by parsing, and A2 is recorded beside
`RecordStagePackageBodySchema`.
- Comment text only: every changed line of the file is a docblock line.
Nothing in the `automation` namespace is touched.
- **`.changeset/19324-record-stage-index-signature-docblock.md`**:
`@objectstack/spec` patch and `@objectstack/client` patch
(`b9b0d32d2f`).

## Which of the four points were already there

Read at `3bd28e2b2e`, before the edit.

| Ruling item 1 point | `RecordStagePackageBodySchema` |
`AssembledInstalledPackageSchema` |
|---|---|---|
| ① the published type is deliberately an index signature | Partial.
Only an internal "ANNOTATED structurally" comment, which the published
`.d.ts` drops, pointing up the file. | Partial. "The body half is
deliberately typed Record(string, unknown)". No consequence stated, and
not called the accepted contract. |
| ② the runtime Zod schema is the enforced contract | Absent at this
site. It was said only in `AssembledPackageBodySchema`'s internal note.
| Present in substance: "a wrong-shaped body is refused exactly as it is
there". Kept, restated in the new section. |
| ③ why (TS7056 / objectstack-ai#14513) | Pointer only, in the unpublished comment. |
Pointer only, and it named `AssembledPackageBodySchema` rather than the
schema `manifest` is built from. |
| ④ A2 is the precise form | Absent | Absent |

## Premise re-measured against the BUILT declarations

Spec built at `3bd28e2b2e` (lock verdict `command-exit 0`).

- `dist/api-assembled/index.d.ts` and `.d.mts` declare `manifest` as
`z.ZodType` of Record(string, unknown) on both sides.
- `dist/index.d.ts` and `.d.mts` declare `RecordStagePackageBodySchema`
the same way, at line 23236.
- A probe program ran tsc 6.0.3 (strict, NodeNext) through the package
`exports`. `--listFiles` shows 9 spec `dist` files and 0 spec `src`
files.
  - **Readings, exit 0 (all five compile):**
    - R1: `InstalledPackage` assigns to `AssembledInstalledPackage`.
    - R2: the whole union assigns to the assembled arm.
    - R3: any object assigns to the manifest.
    - R4: `string extends keyof` the manifest (an index signature).
- R5: a `{ bogus: 1, objects: 'not-an-array' }` manifest compiles
against the union.
- **Lit controls, exit 2, TS2322 twice:** the reverse assignment, and a
string manifest.
- **Runtime control:** `InstalledPackageAtEitherStageSchema.safeParse`
answers true for a valid authoring row, and false for the bogus row,
both on the union and on the assembled arm.
- The same readings hold at head `17f1e3d41c` after the edit, so the
types did not move.

## The TS7056 reading behind the "why", re-measured after the split

M1 is the decision round's reading at `d1ca8741dd`. This PR re-ran it at
`3bd28e2b2e`.

- **Mutation:** drop the artifact and record stages' structural
annotations and the `ZodRawShape` cast.
- **Result:** spec build exit 1 with exactly one error,
`src/api/package-api-assembled.zod.ts(222,14): error TS7056`. Line 222
is `PackageApiContracts`.
- **How:** through `scripts/ablation-replace.mjs`, with each anchor
hitting 1 then 0, and blob `3c09282f16` → `ac2e5f6b0b` → `7823e5e634`.
- **Restore proven:** blob == HEAD `3c09282f16`, `git diff HEAD` empty,
`git status --porcelain` empty.

The objectstack-ai#14513 history was read from commit `7085f90531`. It covers TS7056
on the inferred type, and a named alias that turned `stack.zod` into a
shared chunk. That chunk added 42,622 definition lines to the
`qa/http-conformance` type-check program and pushed it past the then
4096 MB ceiling.

The docblock says plainly that the named-alias reading is inherited for
the record stage and was not re-measured. A2's cost is quoted with its
commit (`d1ca8741dd`), as is the fact that it was not measured on the
http-conformance program.

## One bounded fix in the same docblock

The `AssembledInstalledPackageSchema` docblock said the assembled stage
was "built from `AssembledPackageBodySchema`". That has been false since
objectstack-ai#19373. The row's `manifest` is `RecordStagePackageBodySchema`, which
extends the artifact stage, and the artifact stage is
`ManifestSchema.extend({ ...assembledPackageBodyShape(), … })`. It now
reads "built from the same body shape as `AssembledPackageBodySchema` …
at the record stage the next section describes".

This was the decision round's own carrier note (comment 5800545221), and
it names this PR. Same docblock, same class (the text beside `manifest`
misdescribing its declaration), inside the claimed file surface, with no
new gate.

## Changeset, not `skip-changeset`: the edit ships

Measured on the rebuilt `dist` at `17f1e3d41c`:

| probe | files |
|---|---|
| new `RecordStagePackageBodySchema` section heading |
`dist/index.d.ts`, `dist/index.d.mts` |
| new `AssembledInstalledPackageSchema` section heading |
`dist/api-assembled/index.d.ts`, `dist/api-assembled/index.d.mts` |
| lit control: an existing line of the `RecordStagePackageBodySchema`
docblock | the same 2 files |
| the replaced site-2 sentence | 0 |
| the internal cast note (not TSDoc) | 0, dropped from `dist` as
expected |

Both edited files also ship as source, because `@objectstack/spec`'s
`files[]` carries `src/**/*.zod.ts`. So the edit is published and takes
a `patch` changeset, carrying `Clause-②: no`. `packages/client`
publishes only `dist`, `README.md` and `CHANGELOG.md`, so its test-file
comment ships nothing. The `packages.list` paragraph does ship: measured
on the rebuilt client `dist` at `b9b0d32d2f`, the new phrase is in
`index.d.ts`, `index.d.mts`, `index.js` and `index.mjs`. 'Tracked as
objectstack-ai#19324' and `stack.zod.ts:1283` are in 0 files, and the lit control, the
unchanged `Array.isArray` warning, is in the same 4 files. So the
changeset also carries `@objectstack/client`: patch (`b9b0d32d2f`).

## Verification

The head is `b9b0d32d2f`. `packages/spec` did not move between
`17f1e3d41c` and `b9b0d32d2f` (`git diff` on it is empty), so the spec
readings below, taken at `17f1e3d41c`, hold at the head. The client and
gate readings were re-taken at `b9b0d32d2f`.


- **`@objectstack/spec` build:** exit 0. `pnpm --filter
@objectstack/spec check:generated`: all 15 generated artifacts up to
date.
- **`@objectstack/spec` typecheck:** exit 0 (`tsc`, scripts and test
layer).
- **`@objectstack/spec` tests, targeted:**
- 16 files / 613 tests in `--project local` and 7 files / 99 tests in
`--project repo`, all passed.
- The set: every test on the edited declarations (`package-api`,
`stack-json-stage-package-body`, `assembled-package-body`,
`api-entry-graph.pin`, `split-entries`), the tests that read
`stack.zod.ts` as text, and every spec test that walks and reads source
files.
- ⊘ NOT MEASURED locally: the full spec suite. It passed the 560 s
foreground ceiling with no verdict (exit 124). CI runs it.
- **`@objectstack/client`** (at `b9b0d32d2f`): build exit 0
(`check-dts-emitted` 1/1).
- typecheck exit 0, with `check:test-typecheck` OK at 0 files / 0 errors
/ 0 pinned. Its test program compiles `return-type-precision.test.ts`
(`--listFiles`: 1 hit, against `spec/dist/api-assembled`).
  - tests: 50 files / 635 passed.
- **Cross-package tests on the assembled row:** `runtime`
`packages-read-delete-response-conformance` 17 passed, `objectql`
`registry-package-manifest-serializable` 16 passed.
- **`node scripts/pm/dispatch-gates.mjs --ran`** (re-derived and re-run
at `b9b0d32d2f`; the new path added no family): 85 families derived. 83
run, every one exit 0.
- ⊘ NOT MEASURED: `check:dual-build-cjs-loads`, which exits 3 until
every workspace package is built.
- ⊘ UNRUN: `check:type-check-debt`, which re-measures `tsc` per ledger
entry over the whole built workspace. The only test-file edit is comment
text, and the client test layer holds 0 errors.
  - CI runs both.
- **ESLint, narrowed to the 4 changed `.ts` files** (at `b9b0d32d2f`):
- Scope: `--print-config` applies 6 rules to `index.ts` and 5 to each of
the other three. `--format json` reports 4 files, 0 errors, 0 warnings.
- Why untouched files cannot change: `eslint.config.mjs` never enables
type-aware linting (no `parserOptions.project`), so this diff cannot
move the verdict on any other file.

## Acceptance notes

- **`.changeset/17536-client-packages-read-doors-either-stage.md` still
calls the gap "tracked as objectstack-ai#19324" and cites `stack.zod.ts:1283`.**
- It is a foreign changeset, and `check-empty-changeset` rule 2 forbids
editing it.
- This PR's changeset states the settled reading instead, for the same
release text.
- **Not merged with `main`.** `origin/main` is at `560b724c95`, 10
commits past the base, and none touches the five changed paths (empty
`git diff --stat` on them). CI validates the merge ref.

_Body corrected by `domain:spec` seat 2 after patch round 2
(`b9b0d32d2f`), from the dev's reported deviations: the client
paragraph, the client changeset line, the stale Acceptance note removed,
and the verification anchor._

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

---------

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

dependencies Pull requests that update a dependency file documentation Improvements or additions to documentation size/xl tests tooling

Projects

None yet

2 participants