fix(cli): warn when the declared replica count exceeds the licensed node cap - #8697
Conversation
…8504) The 2026-08-13 max_nodes ruling's third clause — warn loudly on a licensed overflow — had no owner. `os serve` is the gate's sole runtime consumer and called `checkMultiNodeAllowed()` zero-arg, typing the result with a local `{ allowed, reason }` cast, so the partial-cap verdict was both unreachable (no `requested` to clamp against) and unread. serve now passes the operator-declared OS_CLUSTER_REPLICAS and emits an advisory on `capped`. The wording is advisory on purpose: enforcement is a separate mechanism, so nothing is refused today and every declared replica still joins — claiming otherwise would be the same declared-vs-delivered gap this warning closes. A source-level pin derives serve's local verdict mirror from the gate's own declaration, so the next producer widening is a decision rather than a silent divergence. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MX1qcBzfwZb5wkRrJTNbhH
…8504) The shape pin reads the gate's own declaration from packages/services/service-cluster/src. Undeclared, that read is invisible to both layers that decide whether cli's tests run: turbo's affected-subset filter and the test task's input hashing. A cluster-only change would then skip cli's suite entirely and the pin would stay green through exactly the drift it exists to catch. Declared in CROSS_PACKAGE_TEST_INPUTS + turbo.json, and the read is spelled as resolve() off a dirname(fileURLToPath(import.meta.url)) seed — the shape check-cross-package-test-inputs.mjs can follow. The new URL() form it had reads identically at runtime but is invisible to that gate, so the gate's green said nothing about this file. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MX1qcBzfwZb5wkRrJTNbhH
…8504) Two corrections proved by ablating the declaration and watching the gate: - a resolve() nested straight into readFileSync produces no binding, so escapingBindings never saw the read and removing the declaration left the gate green. Bound REPO_ROOT and addressed the producer by repo-relative literal — the shape the scanner follows. Ablation now fails loudly, naming this file. - the gate collects path literals out of comments too, so naming its own script by repo-relative path demanded a glob for a file this test never reads. Named without the path instead. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MX1qcBzfwZb5wkRrJTNbhH
…-nodes-advisory-warning
|
The latest updates on your projects. Learn more about Vercel for GitHub. 1 Skipped Deployment
|
📓 Docs Drift CheckThis PR changes 1 package(s): 17 hand-written doc(s) reference the affected code and may need an implementation-accuracy re-verification:
⛔ 3 release-owned page(s) also reference the affected code. These are read-only:
|
) cluster.mdx described only the deny/downgrade path, so an operator had no place to learn whether a node cap binds — the gap #8504 names. Documents the partial-cap verdict as what it is: a different verdict from a denial, not a downgrade, and advisory today — nothing is refused and every declared replica joins. Also records that the input is the operator-declared OS_CLUSTER_REPLICAS rather than live membership, and why that is insufficient for enforcement. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MX1qcBzfwZb5wkRrJTNbhH
Fixes #8504
What was missing
The 2026-08-13
max_nodesruling (recorded onobjectstack-ai/cloud#1275) has threeclauses: refuse the excess, run up to the paid limit, and warn loudly. The gate
learned to express the first two (
admitted/refused/capped). The third had noowner, and
os serveis the gate's sole runtime consumer.Both of the card's asserted causes were still live on
origin/mainand both wereconfirmed independently — either one alone reproduces the silence:
serve.ts:1404calledcheckMultiNodeAllowed()zero-arg, sorequestedwasundefinedand a cap-aware gate had nothing to clamp against.serve.ts:1402re-declared the return type locally, inside thedynamic-import cast, as
{ allowed: boolean; reason?: string }. The widened shape isnot inherited implicitly, so
refusedandcappedwere invisible even when set.What this changes
servepasses the resolved replica count into the gate, widens the local mirror, andemits an advisory on
capped:Scope is clause 3 only. Enforcement (clauses 1 and 2) needs an atomic slot claim across
replicas and is #8501's; nothing here refuses anything, and the gate was not changed.
Two decisions, stated rather than assumed
1. The wording is advisory, and that is the substance of the deliverable. While
enforcement is open, nothing is actually refused: the gate is consulted once per process
at boot, every replica computes the same verdict, and none can tell whether it is one of
the admitted ones — so all of them join. A message phrased as "2 replicas refused" would
be false, and would recreate the declared-vs-delivered gap this whole family of cards
exists to close. A test asserts the false phrasings stay absent, not merely that the true
ones are present.
2.
OS_CLUSTER_REPLICASis the input, as a deliberate choice. It is an operatordeclared desired count, identical in every replica — not a live membership count, and
no membership count exists at boot. For an advisory message that is exactly right: the
operator is being told about the configuration they wrote. It is explicitly not
sufficient input for enforcement, which is why enforcement is a separate mechanism rather
than a stricter reading of this value.
Number(undefined)isNaN, which the gate normalizes to "not declared" — normalizationlives at the seam by design, so there is deliberately no
?? 0or pre-parse at the callsite.
An outright
allowed: falsedenial is untouched and is deliberately not reported as acap: the producer keeps
cappedfalse for it precisely so the unlicensed case cannot beconflated with the licensed-overflow one, and the call site already reports it as a
single-node downgrade.
Docs
content/docs/kernel/cluster.mdxdocumented only the deny/downgrade path — the gap thecard itself names ("an operator has no other place to learn whether a node cap binds").
It now describes the partial-cap verdict as what it is: a different verdict from a
denial, not a downgrade, and advisory today, with a callout stating plainly that
nothing is refused, that every declared replica joins, and why acting on the verdict
locally is not possible yet. It also records that the input is the declared count rather
than live membership.
Doc verdicts, one line each — see the report comment on #8504 for the full accounting of
the
docs-drift-checklist. The threecontent/docs/releases/**pages are release-ownedand were not touched.
The pin — why this drifted silently, and what stops it next time
The local cast is the only place the two shapes meet, so the producer's widening
propagated nowhere while every package built, every test passed and type-check stayed
green. A source-level pin now derives both sides from the file that owns each — the
gate's own
ResolvedMultiNodeVerdictdeclaration and serve's mirror — rather thanchecking either against a list written out in the test, which would only relocate the
divergence into the test file. It also pins that the call passes a count at all.
That pin only works if a cluster-only change re-runs cli's tests, so the read is declared
in
CROSS_PACKAGE_TEST_INPUTSand inturbo.json's@objectstack/cli#testinputs.Getting that right took two corrections, both found by ablating the declaration and
watching the gate rather than by trusting its green:
resolve()nested straight intoreadFileSyncproduces no binding, so the scannernever saw the read and removing the declaration left the gate green. The read is now
a repo-relative literal off an escaping
REPO_ROOTbinding — the shape the scannerfollows — and ablation fails loudly, naming the file.
repo-relative path demanded a glob for a file this test never reads.
The detector gap itself is filed as #8698 (observation-class, unassigned).
Verification
All at
913680342— the final commit, re-run after the docs commit.pnpm --filter @objectstack/cli testpnpm --filter @objectstack/cli typecheckcheck:type-check-debt(--re-measure)check:type-check-coveragecheck:cross-package-test-inputscheck:docs-audit-scopecheck:role-wordcheck:nul-bytescheck:query-options-erasurecheck:changeset-gate-self-tests,check:objectui-changeset+ the three changeset scansGate families were re-derived with
scripts/pm/dispatch-gates.mjsagainst the actualchanged paths rather than predicted, and re-derived again after the docs commit — which
is what surfaced
check:docs-audit-scopeandcheck:role-word. The earlier derivationsurfaced
check:cross-package-test-inputs, the gate that mattered most here.(The
@objectstack/lintTEST_DEBT surplus the ratchet reports is pre-existing, untouched,and not this PR's to lower.)
Reverse verification (direction predicted before running, observed as predicted):
its guidance message; restored byte-identically.
serve.tsto itsorigin/mainstate ⇒ all 13 tests red across both files;restored and proved identity by blob hash against
HEAD.path.
These ablations read
.tssource, notdist, by design — so there is no artifact torebuild and no stale-
distfalse green.mainwas merged (clean, no generated-artifact obligation) before the final runs.Generated by Claude Code