Skip to content

fix(cli): resolve serve's cluster driver imports from the host app, not the CLI - #10765

Merged
os-elon merged 1 commit into
mainfrom
claude/issue-10645-cli-cluster-driver-resolution
Aug 21, 2026
Merged

fix(cli): resolve serve's cluster driver imports from the host app, not the CLI#10765
os-elon merged 1 commit into
mainfrom
claude/issue-10645-cli-cluster-driver-resolution

Conversation

@os-elon

Copy link
Copy Markdown
Collaborator

Fixes#10645

The defect

With OS_CLUSTER_DRIVER=redis set, os serve died at boot:

Cannot find package '@objectstack/service-cluster' imported from
/repo/objectstack/packages/cli/dist/commands/serve.js

serve.ts reached the cluster gate and its driver through a bare dynamic
import(). Node ESM resolves a bare specifier against the importer's own
realpath
, which for the CLI is inside the framework workspace — so it can only
ever see what the framework installed. The cluster packages ship with a
distribution and are declared by the app, so they live in the app's
node_modules. The one hop that could not work was CLI to app; app-side code
loaded the very same packages fine.

Route taken: (B) host-anchored resolution — and why not (A)

The card offered two routes and stated the criterion that decides between them:
"the general fix should cover any app-declared optional package the CLI
advertises it will load, not just this driver."

Route (A) — declaring @objectstack/service-cluster* as packages/cli
dependencies — silences this driver and leaves the class open. The next
app-declared optional package breaks identically, a third-party cluster driver
could never work at all, and the open-core CLI would take a static dependency on
packages that ship with a distribution — precisely the coupling the non-literal
specifier in serve.ts exists to avoid, and which
serve-multi-node-cap-advisory.pin.test.ts documents as the reason its
hand-written cast exists.

Route (B) is what landed, and the repo already owned the mechanism:
createHostImporter from @objectstack/types/node, which serve was already
using further down for its organizations and capability loads. It resolves from
the host app's root, gated on the host's declaration rather than mere
reachability (#4719). The two cluster loads now go through it. Nothing new was
invented; two loads were moved onto the existing general path.

The ordering half — the part that actually regressed twice

importFromHost is a const inside one long boot function, so a load placed
above it is not a compile error — it is a silent fall-back to bare
resolution. It was defined at the auth block (line ~2128), while the cluster
block sits at ~1425, so the cluster loads could never have used it.

This is the second time that exact shape has cost a release: the helper
previously sat below the auth block, and the enterprise organizations load fell
back to a bare import, so every walled-posture deployment hit the ADR-0093 D5
fail-fast and exited 1 (cloud#1013). The helper is now hoisted to the top of the
boot sequence, and the comment says so, so the next optional load added above it
is a decision rather than an accident.

Verification

The asymmetry was reproduced before fixing it. In this worktree the CLI's own
node_modules/@objectstack/ holds 49 packages and neither cluster one (the EE
image measured 48, same shape). A probe placed inside packages/cli — the same
resolution base as the shipped dist/commands/serve.js — against a fixture app
that declares the packages:

LEG1 bare import(): THREW code=ERR_MODULE_NOT_FOUND
message: Cannot find package '@objectstack/service-cluster' imported from
.../packages/cli/repro-probe.mjs
LEG2 createHostImporter(app): LOADED exports checkMultiNodeAllowed=function

Same error string as the card's measured symptom, and the fix demonstrably
crosses the boundary.

New regression testpackages/cli/src/commands/serve-cluster-host-resolution.test.ts,
8 tests, in two halves:

  • the boundary, behaviourally and hermetically: a synthetic package present
    only in a fixture app's node_modules is invisible to a bare import from
    packages/cli and IS loadable through the host importer. Synthetic on purpose
    — the contract is "any app-declared optional package", not these two — and it
    needs nothing built.
  • the ordering and shape, by source scan: the cluster loads go through
    importFromHost, never through a bare import(), and the helper is defined
    above the block that consumes it.

Reverse verification (ablation). With serve.ts reverted to origin/main
and the fix otherwise intact, exactly 3 of the new tests go red — all three
source-shape assertions, including the ordering one — while the 3 behavioural
boundary tests stay green, since they exercise createHostImporter directly
rather than serve.ts:

Tests 3 failed | 8 passed (11)
FAIL ... > defines importFromHost ABOVE the cluster block that consumes it
FAIL ... > loads the cluster gate and driver through the host importer
FAIL ... > never reaches the cluster packages through a bare dynamic import

Restore leg re-run green (Test Files 2 passed, Tests 11 passed). Neither leg
required a rebuild: the mutated subject is read as source text from src, so
no dist/ sits between the mutation and the assertion.

The pinned import shape did not move.serve-multi-node-cap-advisory.pin.test.ts
is green both before and after — it passed in the ablation run against
origin/main's serve.ts too. Its assertions pin the gate call's arity, the
OS_CLUSTER_REPLICAS argument and the hand-written verdict cast; none of those
text spans is touched by swapping import( for importFromHost(. No assertion
in it was edited.
Note for whoever picks up #10514: the import expression in
that block has changed shape, so re-derive rather than inherit — and that pin's
scanner still reads serve.ts raw, comments included, which is #10514's defect.

Gates run locally, all on cba91346c2 (the final commit, clean tree). The set
was re-derived from the actual changeset with node scripts/pm/dispatch-gates.mjs
after the last commit, not inherited from the dispatch word:

check:nul-bytes · check:changeset-gate-self-tests · check:objectui-changeset ·
check:route-envelope · check:cross-package-test-inputs · check:slot-lookup ·
check:test-source-alias · check:type-source-resolution ·
check:engine-double-contract · check:where-matcher ·
check:query-options-erasure · check:type-check-coverage ·
check-adr-0087-registration · check-changeset-no-major · check-empty-changeset ·
check-cross-package-test-inputs · docs-audit/check-affected-docs — all exit 0.

pnpm --filter @objectstack/cli typecheck green (> tsc --noEmit echoed, so this is
a real run and not a zero-match). The ratchet, after a full workspace build
(70/70 tasks): check-type-check-coverage --re-measure: OK -- 33 ledger entr(ies) re-measured in 266.6s, 1912 raw tsc error(s) total, none above its recorded number.
The 12-error plugin-auth surplus it reports is pre-existing and untouched here.

Scope

Clause-② holds: this changes only where a module is resolved from. No request
is accepted or refused differently and no public surface widens — the #4719
declaration gate is unchanged, and a test asserts an undeclared package is still
refused. No gate weakened, no threshold moved, no test skipped. Nothing under
content/docs/releases/**; the changeset is the release-notes input.
objectstack-ai/cloud#1502 was not touched.


Generated by Claude Code

…10645)
`os serve` reached `@objectstack/service-cluster` and its driver through a
bare dynamic `import()`. Node ESM resolves a bare specifier against the
importer's own realpath, which for the CLI is inside the framework workspace,
so the cluster packages -- shipped with a distribution and declared by the
APP -- were invisible to it. Measured on a published EE image: the CLI's
`node_modules/@objectstack/` held 48 packages and neither cluster one, so
`OS_CLUSTER_DRIVER=redis` died at boot with `Cannot find package
'@objectstack/service-cluster'` while app-side code loaded it fine.
Both loads now go through `createHostImporter`, the host-anchored importer
`serve` already uses for its organizations and capability loads. That fixes
the class rather than these two packages: any app-declared optional package
the CLI advertises it will load now resolves as the app declares it, which
declaring the packages as CLI dependencies would not have done.
The helper is hoisted to the top of the boot sequence. It sits in one long
boot function, so a load placed above it silently falls back to bare
resolution -- the same defect that previously cost the enterprise
organizations load (cloud#1013). A new test pins both the boundary crossing
and that ordering.
No change to what `serve` accepts or refuses: an undeclared package is still
refused by the unchanged #4719 declaration gate.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019bmVFqoQPq63zhKrxdYG1r
@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

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

16 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 e2bb237e3da8cedceb3fbc7d0b220337c87d8a8d.

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

What this run could not see
  • 1 name(s) were too generic to anchor anything (single lowercase words)

Coarse fallback — 23 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 e2bb237e3da8cedceb3fbc7d0b220337c87d8a8dpackageMentionDocs.

Which tree this was computed on

This run read content/docs from bb71a209ee9477b8b9c1f6920470c1a7c9fbc076 — the merge of head cba91346c244749974a9cab7fd2e239f9b97b87b into base e2bb237e3da8cedceb3fbc7d0b220337c87d8a8d, 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 bb71a209ee9477b8b9c1f6920470c1a7c9fbc076 && git checkout bb71a209ee9477b8b9c1f6920470c1a7c9fbc076
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin e2bb237e3da8cedceb3fbc7d0b220337c87d8a8d cba91346c244749974a9cab7fd2e239f9b97b87b && git checkout -B drift-repro e2bb237e3da8cedceb3fbc7d0b220337c87d8a8d && git merge --no-ff cba91346c244749974a9cab7fd2e239f9b97b87b
node scripts/docs-audit/affected-docs.mjs --json e2bb237e3da8cedceb3fbc7d0b220337c87d8a8d

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

@github-actionsgithub-actionsBot added documentation Improvements or additions to documentation tests tooling labels Aug 21, 2026
@os-elon
os-elon marked this pull request as ready for review August 21, 2026 11:17
@os-elon
os-elon added this pull request to the merge queueAug 21, 2026
Merged via the queue into main with commit 53428b8Aug 21, 2026
32 checks passed
@os-elon
os-elon deleted the claude/issue-10645-cli-cluster-driver-resolution branch August 21, 2026 11:31
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentationImprovements or additions to documentationsize/mteststooling

Projects

None yet

2 participants

@os-elon@claude