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

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
hotlong merged 12 commits into
mainfrom
claude/issue-14439-multi-package-build

Conversation

@hotlong

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:

#seamanchorbefore this PR
1authoringdefineStackrefused: packages.0.manifest.objects.0: Expected string but received object
2compilecompile.tsObjectStackDefinitionSchema.safeParse(lowering.lowered)same refusal
3loadmetadata/src/plugin.ts_parseAndRegisterArtifactsame 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/clios 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/spec452 files / 12,185 tests, @objectstack/objectql257 / 4,459, @objectstack/cli229 / 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 gateArtifactPackageSchema'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-mapsa 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-actionsBot 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 7286dd58e806ed321cbdfc23a1c455db8f80b1adpackageMentionDocs.

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.

…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
@hotlongClaude

Copy link
Copy Markdown
ContributorAuthor

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
@hotlongClaude

Copy link
Copy Markdown
ContributorAuthor

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 53d36892PR head 3878a582PR head 23c209f9
Files906912906
Lines of Definitions691,580734,202 (+42,622)691,218
Types1,801,1101,973,1651,798,148
Instantiations7,342,3148,072,1097,327,734
Memory used (tsc's own count)4,473,321 K4,875,249 K4,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

@hotlongClaude

Copy link
Copy Markdown
ContributorAuthor

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 collectionsObjectStackDefinitionSchema.packages is z.array(ArtifactPackageSchema), whose body half is AssembledPackageBodySchema (packages/spec/src/stack.zod.ts). Pins read: assembled-package-body.test.tsSEAM 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 refusedArtifactPackageEntrySchema 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 transcribedAssembledPackageBodyKey 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 bodyresolveArtifactPackageOrder 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 producerlowerBody 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 preservepreservePackageEntries 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. Fixtureexamples/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

Merged via the queue into main with commit 7085f90Sep 2, 2026
40 checks passed
@hotlong
hotlong deleted the claude/issue-14439-multi-package-build branch September 2, 2026 13:46
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

dependenciesPull requests that update a dependency filedocumentationImprovements or additions to documentationsize/xlteststooling

Projects

None yet

2 participants

@hotlong@claude
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Add copy buttons to all
 blocks\n(function() {\n function addCopyButtons() {\n document.querySelectorAll('pre code').forEach(function(codeBlock) {\n if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;\n codeBlock.parentElement.setAttribute('data-copy-added', 'true');\n \n var btn = document.createElement('button');\n btn.textContent = 'Copy';\n btn.style.cssText = 'position:absolute;top:4px;right:4px;padding:2px 8px;font-size:11px;background:#4ecdc4;border:none;border-radius:4px;color:#1a1a2e;cursor:pointer;opacity:0.7;transition:opacity 0.2s;';\n btn.onmouseover = function() { this.style.opacity = '1'; };\n btn.onmouseout = function() { this.style.opacity = '0.7'; };\n btn.onclick = function() {\n navigator.clipboard.writeText(codeBlock.textContent).then(function() {\n btn.textContent = 'Copied!';\n setTimeout(function() { btn.textContent = 'Copy'; }, 1500);\n });\n };\n codeBlock.parentElement.style.position = 'relative';\n codeBlock.parentElement.appendChild(btn);\n });\n }\n \n addCopyButtons();\n \n // Re-run on dynamic content\n var observer = new MutationObserver(addCopyButtons);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Add Copy Buttons to Code Blocks");
}
} catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); }
})();
(function(){
try {
var __m = "github.com";
var __re = new RegExp('^' + "github\\.com" + '
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

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
hotlong merged 12 commits into
mainfrom
claude/issue-14439-multi-package-build

Conversation

@hotlong

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:

#seamanchorbefore this PR
1authoringdefineStackrefused: packages.0.manifest.objects.0: Expected string but received object
2compilecompile.tsObjectStackDefinitionSchema.safeParse(lowering.lowered)same refusal
3loadmetadata/src/plugin.ts_parseAndRegisterArtifactsame 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/clios 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/spec452 files / 12,185 tests, @objectstack/objectql257 / 4,459, @objectstack/cli229 / 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 gateArtifactPackageSchema'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-mapsa 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-actionsBot 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 7286dd58e806ed321cbdfc23a1c455db8f80b1adpackageMentionDocs.

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.

…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
@hotlongClaude

Copy link
Copy Markdown
ContributorAuthor

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
@hotlongClaude

Copy link
Copy Markdown
ContributorAuthor

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 53d36892PR head 3878a582PR head 23c209f9
Files906912906
Lines of Definitions691,580734,202 (+42,622)691,218
Types1,801,1101,973,1651,798,148
Instantiations7,342,3148,072,1097,327,734
Memory used (tsc's own count)4,473,321 K4,875,249 K4,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

@hotlongClaude

Copy link
Copy Markdown
ContributorAuthor

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 collectionsObjectStackDefinitionSchema.packages is z.array(ArtifactPackageSchema), whose body half is AssembledPackageBodySchema (packages/spec/src/stack.zod.ts). Pins read: assembled-package-body.test.tsSEAM 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 refusedArtifactPackageEntrySchema 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 transcribedAssembledPackageBodyKey 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 bodyresolveArtifactPackageOrder 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 producerlowerBody 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 preservepreservePackageEntries 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. Fixtureexamples/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

Merged via the queue into main with commit 7085f90Sep 2, 2026
40 checks passed
@hotlong
hotlong deleted the claude/issue-14439-multi-package-build branch September 2, 2026 13:46
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

dependenciesPull requests that update a dependency filedocumentationImprovements or additions to documentationsize/xlteststooling

Projects

None yet

2 participants

@hotlong@claude
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Force GitHub README to respect dark mode\n(function() {\n var style = document.createElement('style');\n style.textContent = '\n .markdown-body {\n color-scheme: dark light;\n }\n .markdown-body pre { background: #161b22 !important; }\n .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; }\n .markdown-body table th, .markdown-body table td { border-color: #30363d !important; }\n .markdown-body img { background: #0d1117; }\n .markdown-body blockquote { border-left-color: #8b949e; }\n .markdown-body hr { border-color: #30363d; }\n ';\n document.head.appendChild(style);\n})();", "GitHub Dark Mode README Fix"); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
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

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
hotlong merged 12 commits into
mainfrom
claude/issue-14439-multi-package-build

Conversation

@hotlong

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:

#seamanchorbefore this PR
1authoringdefineStackrefused: packages.0.manifest.objects.0: Expected string but received object
2compilecompile.tsObjectStackDefinitionSchema.safeParse(lowering.lowered)same refusal
3loadmetadata/src/plugin.ts_parseAndRegisterArtifactsame 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/clios 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/spec452 files / 12,185 tests, @objectstack/objectql257 / 4,459, @objectstack/cli229 / 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 gateArtifactPackageSchema'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-mapsa 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-actionsBot 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 7286dd58e806ed321cbdfc23a1c455db8f80b1adpackageMentionDocs.

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.

…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
@hotlongClaude

Copy link
Copy Markdown
ContributorAuthor

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
@hotlongClaude

Copy link
Copy Markdown
ContributorAuthor

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 53d36892PR head 3878a582PR head 23c209f9
Files906912906
Lines of Definitions691,580734,202 (+42,622)691,218
Types1,801,1101,973,1651,798,148
Instantiations7,342,3148,072,1097,327,734
Memory used (tsc's own count)4,473,321 K4,875,249 K4,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

@hotlongClaude

Copy link
Copy Markdown
ContributorAuthor

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 collectionsObjectStackDefinitionSchema.packages is z.array(ArtifactPackageSchema), whose body half is AssembledPackageBodySchema (packages/spec/src/stack.zod.ts). Pins read: assembled-package-body.test.tsSEAM 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 refusedArtifactPackageEntrySchema 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 transcribedAssembledPackageBodyKey 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 bodyresolveArtifactPackageOrder 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 producerlowerBody 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 preservepreservePackageEntries 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. Fixtureexamples/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

Merged via the queue into main with commit 7085f90Sep 2, 2026
40 checks passed
@hotlong
hotlong deleted the claude/issue-14439-multi-package-build branch September 2, 2026 13:46
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

dependenciesPull requests that update a dependency filedocumentationImprovements or additions to documentationsize/xlteststooling

Projects

None yet

2 participants

@hotlong@claude
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Highlight search terms from Google/DuckDuckGo/Bing referrer\n(function() {\n var ref = document.referrer;\n var terms = [];\n \n if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) {\n var url = new URL(ref);\n var q = url.searchParams.get('q') || url.searchParams.get('p');\n if (q) {\n terms = q.split(/\\s+/).filter(function(t) { return t.length > 2; });\n }\n }\n \n if (terms.length === 0) return;\n \n var style = document.createElement('style');\n style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }';\n document.head.appendChild(style);\n \n function highlight(node) {\n if (node.nodeType === 3) { // text node\n var text = node.textContent;\n var found = false;\n terms.forEach(function(term) {\n var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\') + ')', 'gi');\n if (regex.test(text)) {\n found = true;\n var frag = document.createDocumentFragment();\n var parts = text.split(regex);\n parts.forEach(function(part, i) {\n if (i % 2 === 0) {\n frag.appendChild(document.createTextNode(part));\n } else {\n var span = document.createElement('span');\n span.className = 'userscript-highlight';\n span.textContent = part;\n frag.appendChild(span);\n }\n });\n node.parentNode.replaceChild(frag, node);\n }\n });\n } else if (node.nodeType === 1 && node.childNodes) { // element\n var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT'];\n if (!skipTags.includes(node.tagName)) {\n Array.from(node.childNodes).forEach(highlight);\n }\n }\n }\n \n highlight(document.body);\n \n // Re-highlight on dynamic content\n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1 || node.nodeType === 3) highlight(node);\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Highlight Search Terms"); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
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

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
hotlong merged 12 commits into
mainfrom
claude/issue-14439-multi-package-build

Conversation

@hotlong

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:

#seamanchorbefore this PR
1authoringdefineStackrefused: packages.0.manifest.objects.0: Expected string but received object
2compilecompile.tsObjectStackDefinitionSchema.safeParse(lowering.lowered)same refusal
3loadmetadata/src/plugin.ts_parseAndRegisterArtifactsame 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/clios 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/spec452 files / 12,185 tests, @objectstack/objectql257 / 4,459, @objectstack/cli229 / 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 gateArtifactPackageSchema'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-mapsa 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-actionsBot 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 7286dd58e806ed321cbdfc23a1c455db8f80b1adpackageMentionDocs.

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.

…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
@hotlongClaude

Copy link
Copy Markdown
ContributorAuthor

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
@hotlongClaude

Copy link
Copy Markdown
ContributorAuthor

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 53d36892PR head 3878a582PR head 23c209f9
Files906912906
Lines of Definitions691,580734,202 (+42,622)691,218
Types1,801,1101,973,1651,798,148
Instantiations7,342,3148,072,1097,327,734
Memory used (tsc's own count)4,473,321 K4,875,249 K4,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

@hotlongClaude

Copy link
Copy Markdown
ContributorAuthor

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 collectionsObjectStackDefinitionSchema.packages is z.array(ArtifactPackageSchema), whose body half is AssembledPackageBodySchema (packages/spec/src/stack.zod.ts). Pins read: assembled-package-body.test.tsSEAM 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 refusedArtifactPackageEntrySchema 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 transcribedAssembledPackageBodyKey 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 bodyresolveArtifactPackageOrder 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 producerlowerBody 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 preservepreservePackageEntries 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. Fixtureexamples/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

Merged via the queue into main with commit 7085f90Sep 2, 2026
40 checks passed
@hotlong
hotlong deleted the claude/issue-14439-multi-package-build branch September 2, 2026 13:46
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

dependenciesPull requests that update a dependency filedocumentationImprovements or additions to documentationsize/xlteststooling

Projects

None yet

2 participants

@hotlong@claude
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Strip utm_, fbclid, gclid, etc. from all links on page\n(function() {\n var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content',\n 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid',\n 'ref', 'ref_src', 'source', 'medium', 'campaign'];\n \n function cleanUrl(url) {\n try {\n var u = new URL(url, window.location.origin);\n var changed = false;\n trackingParams.forEach(function(p) {\n if (u.searchParams.has(p)) {\n u.searchParams.delete(p);\n changed = true;\n }\n });\n return changed ? u.toString() : url;\n } catch (e) {\n return url;\n }\n }\n \n function cleanLinks() {\n document.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n \n cleanLinks();\n \n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1) {\n if (node.tagName === 'A') cleanLinks();\n node.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Remove Tracking Parameters from Links"); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + '
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

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
hotlong merged 12 commits into
mainfrom
claude/issue-14439-multi-package-build

Conversation

@hotlong

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:

#seamanchorbefore this PR
1authoringdefineStackrefused: packages.0.manifest.objects.0: Expected string but received object
2compilecompile.tsObjectStackDefinitionSchema.safeParse(lowering.lowered)same refusal
3loadmetadata/src/plugin.ts_parseAndRegisterArtifactsame 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/clios 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/spec452 files / 12,185 tests, @objectstack/objectql257 / 4,459, @objectstack/cli229 / 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 gateArtifactPackageSchema'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-mapsa 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-actionsBot 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 7286dd58e806ed321cbdfc23a1c455db8f80b1adpackageMentionDocs.

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.

…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
@hotlongClaude

Copy link
Copy Markdown
ContributorAuthor

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
@hotlongClaude

Copy link
Copy Markdown
ContributorAuthor

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 53d36892PR head 3878a582PR head 23c209f9
Files906912906
Lines of Definitions691,580734,202 (+42,622)691,218
Types1,801,1101,973,1651,798,148
Instantiations7,342,3148,072,1097,327,734
Memory used (tsc's own count)4,473,321 K4,875,249 K4,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

@hotlongClaude

Copy link
Copy Markdown
ContributorAuthor

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 collectionsObjectStackDefinitionSchema.packages is z.array(ArtifactPackageSchema), whose body half is AssembledPackageBodySchema (packages/spec/src/stack.zod.ts). Pins read: assembled-package-body.test.tsSEAM 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 refusedArtifactPackageEntrySchema 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 transcribedAssembledPackageBodyKey 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 bodyresolveArtifactPackageOrder 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 producerlowerBody 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 preservepreservePackageEntries 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. Fixtureexamples/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

Merged via the queue into main with commit 7085f90Sep 2, 2026
40 checks passed
@hotlong
hotlong deleted the claude/issue-14439-multi-package-build branch September 2, 2026 13:46
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

dependenciesPull requests that update a dependency filedocumentationImprovements or additions to documentationsize/xlteststooling

Projects

None yet

2 participants

@hotlong@claude
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Auto-enable theater mode on YouTube\n(function() {\n function tryTheater() {\n var btn = document.querySelector('button[aria-label=\"Theater mode\"], ytd-player #player button[title=\"Theater mode\"]');\n if (btn && !btn.classList.contains('activated')) {\n btn.click();\n }\n }\n \n // Try immediately\n tryTheater();\n \n // Try after navigation (SPA)\n var lastUrl = location.href;\n setInterval(function() {\n if (location.href !== lastUrl) {\n lastUrl = location.href;\n setTimeout(tryTheater, 500);\n }\n }, 1000);\n \n // Also try on player load\n var observer = new MutationObserver(tryTheater);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "YouTube Theater Mode Default"); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
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

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
hotlong merged 12 commits into
mainfrom
claude/issue-14439-multi-package-build

Conversation

@hotlong

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:

#seamanchorbefore this PR
1authoringdefineStackrefused: packages.0.manifest.objects.0: Expected string but received object
2compilecompile.tsObjectStackDefinitionSchema.safeParse(lowering.lowered)same refusal
3loadmetadata/src/plugin.ts_parseAndRegisterArtifactsame 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/clios 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/spec452 files / 12,185 tests, @objectstack/objectql257 / 4,459, @objectstack/cli229 / 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 gateArtifactPackageSchema'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-mapsa 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-actionsBot 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 7286dd58e806ed321cbdfc23a1c455db8f80b1adpackageMentionDocs.

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.

…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
@hotlongClaude

Copy link
Copy Markdown
ContributorAuthor

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
@hotlongClaude

Copy link
Copy Markdown
ContributorAuthor

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 53d36892PR head 3878a582PR head 23c209f9
Files906912906
Lines of Definitions691,580734,202 (+42,622)691,218
Types1,801,1101,973,1651,798,148
Instantiations7,342,3148,072,1097,327,734
Memory used (tsc's own count)4,473,321 K4,875,249 K4,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

@hotlongClaude

Copy link
Copy Markdown
ContributorAuthor

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 collectionsObjectStackDefinitionSchema.packages is z.array(ArtifactPackageSchema), whose body half is AssembledPackageBodySchema (packages/spec/src/stack.zod.ts). Pins read: assembled-package-body.test.tsSEAM 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 refusedArtifactPackageEntrySchema 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 transcribedAssembledPackageBodyKey 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 bodyresolveArtifactPackageOrder 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 producerlowerBody 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 preservepreservePackageEntries 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. Fixtureexamples/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

Merged via the queue into main with commit 7085f90Sep 2, 2026
40 checks passed
@hotlong
hotlong deleted the claude/issue-14439-multi-package-build branch September 2, 2026 13:46
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

dependenciesPull requests that update a dependency filedocumentationImprovements or additions to documentationsize/xlteststooling

Projects

None yet

2 participants

@hotlong@claude
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Remove or un-stick sticky/fixed headers that block content\n(function() {\n function unstick() {\n document.querySelectorAll('header, nav, [role=\"banner\"], .header, .navbar, .sticky, .fixed-top, [style*=\"position: fixed\"], [style*=\"position:sticky\"]').forEach(function(el) {\n if (el.style.position === 'fixed' || el.style.position === 'sticky' || \n getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') {\n el.style.position = 'static';\n el.style.top = 'auto';\n el.style.zIndex = 'auto';\n }\n });\n }\n \n unstick();\n \n var observer = new MutationObserver(unstick);\n observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] });\n})();", "Kill Sticky Headers"); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
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

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
hotlong merged 12 commits into
mainfrom
claude/issue-14439-multi-package-build

Conversation

@hotlong

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:

#seamanchorbefore this PR
1authoringdefineStackrefused: packages.0.manifest.objects.0: Expected string but received object
2compilecompile.tsObjectStackDefinitionSchema.safeParse(lowering.lowered)same refusal
3loadmetadata/src/plugin.ts_parseAndRegisterArtifactsame 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/clios 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/spec452 files / 12,185 tests, @objectstack/objectql257 / 4,459, @objectstack/cli229 / 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 gateArtifactPackageSchema'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-mapsa 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-actionsBot 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 7286dd58e806ed321cbdfc23a1c455db8f80b1adpackageMentionDocs.

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.

…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
@hotlongClaude

Copy link
Copy Markdown
ContributorAuthor

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
@hotlongClaude

Copy link
Copy Markdown
ContributorAuthor

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 53d36892PR head 3878a582PR head 23c209f9
Files906912906
Lines of Definitions691,580734,202 (+42,622)691,218
Types1,801,1101,973,1651,798,148
Instantiations7,342,3148,072,1097,327,734
Memory used (tsc's own count)4,473,321 K4,875,249 K4,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

@hotlongClaude

Copy link
Copy Markdown
ContributorAuthor

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 collectionsObjectStackDefinitionSchema.packages is z.array(ArtifactPackageSchema), whose body half is AssembledPackageBodySchema (packages/spec/src/stack.zod.ts). Pins read: assembled-package-body.test.tsSEAM 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 refusedArtifactPackageEntrySchema 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 transcribedAssembledPackageBodyKey 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 bodyresolveArtifactPackageOrder 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 producerlowerBody 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 preservepreservePackageEntries 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. Fixtureexamples/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

Merged via the queue into main with commit 7085f90Sep 2, 2026
40 checks passed
@hotlong
hotlong deleted the claude/issue-14439-multi-package-build branch September 2, 2026 13:46
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

dependenciesPull requests that update a dependency filedocumentationImprovements or additions to documentationsize/xlteststooling

Projects

None yet

2 participants

@hotlong@claude
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Universal Dark Mode - works on any site\n(function() {\n var enabled = true;\n \n function applyDarkMode() {\n if (!enabled) return;\n \n // Create style element if it doesn't exist\n var style = document.getElementById('universal-dark-mode-style');\n if (!style) {\n style = document.createElement('style');\n style.id = 'universal-dark-mode-style';\n document.head.appendChild(style);\n }\n \n // Dark mode CSS - inverts colors but preserves images/video\n style.textContent = '\n /* Invert everything except media */\n html {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #1a1a2e !important;\n }\n \n /* Restore images, videos, iframes, canvas */\n img, video, iframe, canvas, svg, picture, [style*=\"background-image\"] {\n filter: invert(1) hue-rotate(180deg) !important;\n }\n \n /* Preserve specific elements that should not be inverted */\n .no-dark-mode, .no-dark-mode *,\n [data-theme=\"light\"], [data-theme=\"light\"],\n .ace_editor, .ace_editor *,\n .CodeMirror, .CodeMirror *,\n .monaco-editor, .monaco-editor *,\n .markdown-body pre, .markdown-body pre *,\n .highlight, .highlight *,\n pre code, pre code * {\n filter: none !important;\n }\n \n /* Fix common UI elements */\n .modal, .popup, .dropdown-menu, .tooltip, .popover {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #2d2d44 !important;\n border-color: #444 !important;\n }\n \n /* Scrollbars */\n ::-webkit-scrollbar { background: #1a1a2e !important; }\n ::-webkit-scrollbar-thumb { background: #444 !important; }\n ::-webkit-scrollbar-thumb:hover { background: #555 !important; }\n \n /* Selection */\n ::selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ::-moz-selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ';\n }\n \n function removeDarkMode() {\n var style = document.getElementById('universal-dark-mode-style');\n if (style) style.remove();\n }\n \n // Toggle with Alt+Shift+D\n document.addEventListener('keydown', function(e) {\n if (e.altKey && e.shiftKey && e.key === 'D') {\n e.preventDefault();\n enabled = !enabled;\n if (enabled) {\n applyDarkMode();\n console.log('[Universal Dark Mode] Enabled');\n } else {\n removeDarkMode();\n console.log('[Universal Dark Mode] Disabled');\n }\n }\n });\n \n // Apply on load\n applyDarkMode();\n \n // Re-apply on dynamic content\n var observer = new MutationObserver(function(mutations) {\n if (enabled && !document.getElementById('universal-dark-mode-style')) {\n applyDarkMode();\n }\n });\n observer.observe(document.head, { childList: true });\n \n console.log('[Universal Dark Mode] Loaded - Press Alt+Shift+D to toggle');\n})();", "Universal Dark Mode"); } } catch(__e) { console.warn('[Userscript:Universal Dark Mode]', __e); } })(); })();
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

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
hotlong merged 12 commits into
mainfrom
claude/issue-14439-multi-package-build

Conversation

@hotlong

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:

#seamanchorbefore this PR
1authoringdefineStackrefused: packages.0.manifest.objects.0: Expected string but received object
2compilecompile.tsObjectStackDefinitionSchema.safeParse(lowering.lowered)same refusal
3loadmetadata/src/plugin.ts_parseAndRegisterArtifactsame 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/clios 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/spec452 files / 12,185 tests, @objectstack/objectql257 / 4,459, @objectstack/cli229 / 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 gateArtifactPackageSchema'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-mapsa 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-actionsBot 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 7286dd58e806ed321cbdfc23a1c455db8f80b1adpackageMentionDocs.

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.

…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
@hotlongClaude

Copy link
Copy Markdown
ContributorAuthor

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
@hotlongClaude

Copy link
Copy Markdown
ContributorAuthor

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 53d36892PR head 3878a582PR head 23c209f9
Files906912906
Lines of Definitions691,580734,202 (+42,622)691,218
Types1,801,1101,973,1651,798,148
Instantiations7,342,3148,072,1097,327,734
Memory used (tsc's own count)4,473,321 K4,875,249 K4,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

@hotlongClaude

Copy link
Copy Markdown
ContributorAuthor

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 collectionsObjectStackDefinitionSchema.packages is z.array(ArtifactPackageSchema), whose body half is AssembledPackageBodySchema (packages/spec/src/stack.zod.ts). Pins read: assembled-package-body.test.tsSEAM 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 refusedArtifactPackageEntrySchema 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 transcribedAssembledPackageBodyKey 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 bodyresolveArtifactPackageOrder 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 producerlowerBody 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 preservepreservePackageEntries 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. Fixtureexamples/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

Merged via the queue into main with commit 7085f90Sep 2, 2026
40 checks passed
@hotlong
hotlong deleted the claude/issue-14439-multi-package-build branch September 2, 2026 13:46
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

dependenciesPull requests that update a dependency filedocumentationImprovements or additions to documentationsize/xlteststooling

Projects

None yet

2 participants

@hotlong@claude