Skip to content

fix(cli): warn when the declared replica count exceeds the licensed node cap - #8697

Merged
qq9340100 merged 5 commits into
mainfrom
claude/issue-8504-max-nodes-advisory-warning
Aug 14, 2026
Merged

fix(cli): warn when the declared replica count exceeds the licensed node cap#8697
qq9340100 merged 5 commits into
mainfrom
claude/issue-8504-max-nodes-advisory-warning

Conversation

@qq9340100

@qq9340100 qq9340100 commented Aug 14, 2026

Copy link
Copy Markdown
Collaborator

Fixes #8504

What was missing

The 2026-08-13 max_nodes ruling (recorded on objectstack-ai/cloud#1275) has three
clauses: 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 no
owner, and os serve is the gate's sole runtime consumer.

Both of the card's asserted causes were still live on origin/main and both were
confirmed independently — either one alone reproduces the silence:

  1. Unreachableserve.ts:1404 called checkMultiNodeAllowed() zero-arg, so
    requested was undefined and a cap-aware gate had nothing to clamp against.
  2. Unreadserve.ts:1402 re-declared the return type locally, inside the
    dynamic-import cast, as { allowed: boolean; reason?: string }. The widened shape is
    not inherited implicitly, so refused and capped were invisible even when set.

What this changes

serve passes the resolved replica count into the gate, widens the local mirror, and
emits an advisory on capped:

[cluster] licensed node cap exceeded: the licence admits 3 node(s), but
OS_CLUSTER_REPLICAS declares 5 — 2 beyond the cap.
[cluster] This cap is ADVISORY and is not enforced yet: nothing is refused, and all
5 replicas will still join the cluster.
[cluster] Reduce OS_CLUSTER_REPLICAS to 3, or raise the licensed node limit.

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_REPLICAS is the input, as a deliberate choice. It is an operator
declared 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) is NaN, which the gate normalizes to "not declared" — normalization
lives at the seam by design, so there is deliberately no ?? 0 or pre-parse at the call
site.

An outright allowed: false denial is untouched and is deliberately not reported as a
cap: the producer keeps capped false for it precisely so the unlicensed case cannot be
conflated with the licensed-overflow one, and the call site already reports it as a
single-node downgrade.

Docs

content/docs/kernel/cluster.mdx documented only the deny/downgrade path — the gap the
card 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-check list. The three content/docs/releases/** pages are release-owned
and 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 ResolvedMultiNodeVerdict declaration and serve's mirror — rather than
checking 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_INPUTS and in turbo.json's @objectstack/cli#test inputs.
Getting that right took two corrections, both found by ablating the declaration and
watching the gate rather than by trusting its green:

  • a resolve() nested straight into readFileSync produces no binding, so the scanner
    never saw the read and removing the declaration left the gate green. The read is now
    a repo-relative literal off an escaping REPO_ROOT binding — the shape the scanner
    follows — and ablation fails loudly, naming the file.
  • the scanner 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.

The detector gap itself is filed as #8698 (observation-class, unassigned).

Verification

All at 913680342 — the final commit, re-run after the docs commit.

what result
pnpm --filter @objectstack/cli test PASS — 122 files, 1329 tests
pnpm --filter @objectstack/cli typecheck PASS
check:type-check-debt (--re-measure) PASS — 33 ledger entries, none above its recorded number
check:type-check-coverage PASS
check:cross-package-test-inputs PASS
check:docs-audit-scope PASS
check:role-word PASS
check:nul-bytes PASS
check:query-options-erasure PASS
check:changeset-gate-self-tests, check:objectui-changeset + the three changeset scans PASS

Gate families were re-derived with scripts/pm/dispatch-gates.mjs against the actual
changed paths rather than predicted, and re-derived again after the docs commit — which
is what surfaced check:docs-audit-scope and check:role-word. The earlier derivation
surfaced check:cross-package-test-inputs, the gate that mattered most here.
(The @objectstack/lint TEST_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):

  • widened the producer's interface with a probe field ⇒ exactly the shape pin red, with
    its guidance message; restored byte-identically.
  • restored serve.ts to its origin/main state ⇒ all 13 tests red across both files;
    restored and proved identity by blob hash against HEAD.
  • removed the cross-package declaration ⇒ the gate red, naming this test and the producer
    path.

These ablations read .ts source, not dist, by design — so there is no artifact to
rebuild and no stale-dist false green.

main was merged (clean, no generated-artifact obligation) before the final runs.


Generated by Claude Code

claude added 4 commits August 14, 2026 13:30
…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
@vercel

vercel Bot commented Aug 14, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

1 Skipped Deployment
Project Deployment Actions Updated (UTC)
objectstack Ignored Ignored Aug 14, 2026 2:50pm

Request Review

@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 1 package(s): @objectstack/cli.

17 hand-written doc(s) reference the affected code and may need an implementation-accuracy re-verification:

  • content/docs/ai/skills-reference.mdx (via packages/cli)
  • content/docs/api/client-sdk.mdx (via @objectstack/cli)
  • content/docs/api/data-flow.mdx (via @objectstack/cli)
  • content/docs/api/environment-routing.mdx (via @objectstack/cli)
  • content/docs/api/error-catalog.mdx (via @objectstack/cli)
  • content/docs/automation/hook-bodies.mdx (via packages/cli)
  • content/docs/deployment/backup-restore.mdx (via @objectstack/cli)
  • content/docs/deployment/cli.mdx (via @objectstack/cli)
  • content/docs/deployment/self-hosting.mdx (via @objectstack/cli)
  • content/docs/getting-started/your-first-project.mdx (via @objectstack/cli)
  • content/docs/kernel/runtime-services/data-service.mdx (via @objectstack/cli)
  • content/docs/kernel/runtime-services/index.mdx (via packages/cli)
  • content/docs/permissions/authentication.mdx (via @objectstack/cli)
  • content/docs/plugins/index.mdx (via @objectstack/cli)
  • content/docs/plugins/packages.mdx (via @objectstack/cli)
  • content/docs/protocol/kernel/plugin-spec.mdx (via @objectstack/cli)
  • content/docs/protocol/kernel/realtime-protocol.mdx (via @objectstack/cli)

3 release-owned page(s) also reference the affected code. These are read-only:

  • content/docs/releases/implementation-status.mdx (via @objectstack/cli)
  • content/docs/releases/v16.mdx (via @objectstack/cli)
  • content/docs/releases/v17.mdx (via @objectstack/cli)

content/docs/releases/ is RELEASE-OWNED (AGENTS.md "Documentation Guardrails"): release
notes are written centrally at release time, and a code PR that edits them is the exact PR
that guardrail exists to stop. They are still audited — read-only. If one of them is actually
wrong, file an issue or open a dedicated docs-only PR; do not edit it here.

Advisory only. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs origin/main → pass the list as args.docs.

)

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
@qq9340100
qq9340100 marked this pull request as ready for review August 14, 2026 15:01
@qq9340100
qq9340100 added this pull request to the merge queue Aug 14, 2026
Merged via the queue into main with commit dfedf88 Aug 14, 2026
30 checks passed
@qq9340100
qq9340100 deleted the claude/issue-8504-max-nodes-advisory-warning branch August 14, 2026 15:19
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentation Improvements or additions to documentation size/m tests tooling

Projects

None yet

2 participants