Skip to content

Commit dfedf88

Browse files
qq9340100claude
andauthored
fix(cli): warn when the declared replica count exceeds the licensed node cap (#8697)
* fix(cli): warn when declared replicas exceed the licensed node cap (#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 * test(cli): declare the multi-node gate as a cross-package test input (#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 * test(cli): address the gate's read shape, not just its declaration (#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 * docs(kernel): document the advisory node cap in cluster semantics (#8504) 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 --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent f287435 commit dfedf88

7 files changed

Lines changed: 442 additions & 4 deletions

File tree

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
---
2+
'@objectstack/cli': patch
3+
---
4+
5+
`serve`: warn when the declared replica count exceeds the licensed node cap (#8504)
6+
7+
The 2026-08-13 `max_nodes` ruling requires a licensed overflow to refuse the excess,
8+
run up to the paid limit, and **warn loudly**. The gate learned to express the first
9+
two — `admitted` / `refused` / `capped` — but the only program that consults it, `os
10+
serve`, called it zero-arg and typed the result with a hand-written
11+
`{ allowed, reason }` cast. So the partial-cap verdict was unreachable *and* unread:
12+
the gate could say "3 admitted, 2 refused" and nothing rendered it.
13+
14+
`serve` now passes the operator-declared `OS_CLUSTER_REPLICAS` into the gate and
15+
emits an advisory on `capped`:
16+
17+
```
18+
[cluster] licensed node cap exceeded: the licence admits 3 node(s), but
19+
OS_CLUSTER_REPLICAS declares 5 — 2 beyond the cap.
20+
[cluster] This cap is ADVISORY and is not enforced yet: nothing is refused, and all
21+
5 replicas will still join the cluster.
22+
[cluster] Reduce OS_CLUSTER_REPLICAS to 3, or raise the licensed node limit.
23+
```
24+
25+
⚠️ The wording is deliberately advisory. Enforcement needs an atomic slot claim
26+
across replicas and is tracked separately; until it lands **nothing is actually
27+
refused** — every replica computes the same verdict at boot and none can tell whether
28+
it is one of the admitted ones, so all of them join. A message claiming "2 replicas
29+
refused" would be false in exactly the declared-vs-delivered way this warning exists
30+
to close.
31+
32+
An outright `allowed: false` denial is untouched: it keeps reporting as a
33+
single-node downgrade, and is deliberately not reported as a cap.

content/docs/kernel/cluster.mdx

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -532,6 +532,38 @@ string. A distribution may register a multi-node gate; when it denies,
532532
`os serve` logs a warning and downgrades to the single-node in-memory cluster
533533
rather than failing.
534534

535+
A gate may instead admit **fewer** nodes than the deployment declares — a
536+
licensed node-cap overflow. That is a different verdict from a denial and is
537+
deliberately *not* a downgrade: the cluster is entitled to run, it simply asked
538+
for more nodes than it paid for. `os serve` passes the declared
539+
`OS_CLUSTER_REPLICAS` to the gate and, when the declaration exceeds what the
540+
gate admits, prints an advisory naming both counts:
541+
542+
```
543+
[cluster] licensed node cap exceeded: the licence admits 3 node(s), but
544+
OS_CLUSTER_REPLICAS declares 5 — 2 beyond the cap.
545+
[cluster] This cap is ADVISORY and is not enforced yet: nothing is refused, and
546+
all 5 replicas will still join the cluster.
547+
[cluster] Reduce OS_CLUSTER_REPLICAS to 3, or raise the licensed node limit.
548+
```
549+
550+
<Callout type="warn">
551+
**The cap is advisory today — it does not bind.** Nothing is refused, and every
552+
declared replica joins. The gate is consulted once per process at boot, so each
553+
replica computes the *same* verdict and none can tell whether it is one of the
554+
admitted ones: acting on it locally would mean either "all join" (what happens)
555+
or "all refuse" (a whole-cluster outage, which is worse than the overflow).
556+
Making it bind needs an atomic slot claim across replicas — each booting replica
557+
claims a seat, the excess downgrades itself, and seats are released on shutdown
558+
or TTL expiry — which does not exist yet. Treat the warning as a prompt to fix
559+
the configuration or the licence, not as evidence that the limit is enforced.
560+
</Callout>
561+
562+
Note this reads the operator-**declared** count, not live membership: no
563+
membership view exists at boot. It is the right input for telling an operator
564+
about the configuration they wrote, and is deliberately not sufficient for
565+
enforcement.
566+
535567
Every cluster primitive selects its implementation from `cluster.driver`.
536568
When the field is absent, `Runtime` auto-registers the in-memory, single-node
537569
driver (pass `cluster: false` to skip registration entirely). No production
Lines changed: 139 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,139 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* THE PIN: `os serve` asks the multi-node gate a counted question, and its local
5+
* copy of the gate's verdict shape still matches the gate's own.
6+
*
7+
* The defect this guards is authoring-time and invisible to any behavioural
8+
* test. `serve.ts` reaches `@objectstack/service-cluster` through a dynamic,
9+
* non-literal specifier — deliberately, so the CLI carries no static dependency
10+
* on a package that ships with a distribution — and types the result with a
11+
* hand-written cast. That cast is the ONLY place the two shapes meet, so when
12+
* the gate widened to express a licensed node cap (`admitted` / `refused` /
13+
* `capped`), nothing propagated to the consumer: the cast still said
14+
* `{ allowed, reason }` and the call was still zero-arg. The gate could express
15+
* "3 admitted, 2 refused" and the only program that consults it could neither
16+
* ask the question nor read the answer — with every package building, every
17+
* test passing and every type-check green.
18+
*
19+
* Both halves are pinned because either one alone reproduces the silence:
20+
*
21+
* 1. a zero-arg call leaves `requested` undefined, so a cap-aware gate has
22+
* nothing to clamp against and the partial-cap verdict is *unreachable*;
23+
* 2. a narrow local cast means the fields are *unreadable* even when set.
24+
*
25+
* The shape assertion derives BOTH sides from the file that owns each, rather
26+
* than checking either against a list written out here — a hard-coded expected
27+
* list would just relocate the divergence into this file, where it would be
28+
* equally silent. So the producer widening again turns this red, which makes
29+
* the next widening a decision (does `serve` need the new field?) instead of a
30+
* silent divergence.
31+
*/
32+
33+
import { describe, it, expect } from 'vitest';
34+
import { readFileSync } from 'node:fs';
35+
import { dirname, join, resolve } from 'node:path';
36+
import { fileURLToPath } from 'node:url';
37+
38+
const HERE = dirname(fileURLToPath(import.meta.url));
39+
40+
/** `packages/cli/src/commands` → four levels up. */
41+
const REPO_ROOT = resolve(HERE, '../../../..');
42+
43+
/** `packages/cli/src/commands/serve.ts` — the consumer. */
44+
const SERVE_SOURCE = readFileSync(resolve(HERE, 'serve.ts'), 'utf8');
45+
46+
/**
47+
* The producer, read from source rather than imported: the CLI has no
48+
* dependency on this package (that is the whole reason the cast exists), and
49+
* reading `src` also means the pin does not depend on anything being built.
50+
*
51+
* ⚠️ Addressed as a repo-relative literal off an escaping `REPO_ROOT` binding
52+
* on purpose — that is the shape the repo's `check:cross-package-test-inputs`
53+
* gate can follow. (Its script is named without a repo-relative path here: that
54+
* gate collects path literals out of a test's source, comments included, and
55+
* would then require a glob for a file this test never reads.) Spellings it cannot follow (a `new URL('…', import.meta.url)`
56+
* seed, or a `resolve()` nested straight into the `readFileSync` call) read
57+
* identically at runtime but produce no binding and therefore no flag, which
58+
* would leave this read **undeclared**: `@objectstack/cli` would then be absent
59+
* from `turbo ls --affected` for a cluster-only change and its `test` cache
60+
* would not hash this file, so the pin below would sit green through exactly
61+
* the drift it exists to catch. The declaration it needs lives in that script's
62+
* `CROSS_PACKAGE_TEST_INPUTS` and in `turbo.json`'s `@objectstack/cli#test`
63+
* inputs; removing either turns this file's own gate red.
64+
*/
65+
const GATE_SOURCE = readFileSync(
66+
join(REPO_ROOT, 'packages/services/service-cluster/src/multi-node-gate.ts'),
67+
'utf8',
68+
);
69+
70+
/**
71+
* The property names of an `export interface`, each suffixed with `?` when
72+
* optional — optionality is part of the contract, so a field that quietly
73+
* becomes required must not read as agreement.
74+
*
75+
* Brace-matched rather than line-counted, and comment-stripped before the
76+
* property scan so TSDoc prose cannot be mistaken for a field. (A property
77+
* whose type is an inline object literal would over-collect its nested keys;
78+
* neither interface has one, and one appearing is itself worth a look.)
79+
*/
80+
function interfaceFields(source: string, name: string): string[] {
81+
const declaration = `export interface ${name} {`;
82+
const start = source.indexOf(declaration);
83+
expect(start, `${name} not found — did the declaration move or get renamed?`).toBeGreaterThan(-1);
84+
85+
let depth = 1;
86+
let i = start + declaration.length;
87+
for (; i < source.length && depth > 0; i++) {
88+
if (source[i] === '{') depth++;
89+
else if (source[i] === '}') depth--;
90+
}
91+
const body = source
92+
.slice(start + declaration.length, i - 1)
93+
.replace(/\/\*[\s\S]*?\*\//g, '')
94+
.replace(/\/\/[^\n]*/g, '');
95+
96+
return [...body.matchAll(/^\s*(\w+)(\??):/gm)].map((m) => `${m[1]}${m[2]}`).sort();
97+
}
98+
99+
describe('os serve ↔ multi-node gate', () => {
100+
it('calls the gate WITH a requested node count', () => {
101+
// The exact regression: `checkMultiNodeAllowed()`. Passing nothing makes the
102+
// licensed-overflow verdict unreachable rather than merely unread.
103+
expect(SERVE_SOURCE).not.toMatch(/checkMultiNodeAllowed\(\s*\)/);
104+
expect(SERVE_SOURCE).toMatch(/checkMultiNodeAllowed\(\s*[^)\s]/);
105+
});
106+
107+
it('passes the operator-declared replica count', () => {
108+
// A stated decision, not an accident: `OS_CLUSTER_REPLICAS` is a *declared*
109+
// desired count, identical in every replica, not a live membership count —
110+
// which is right for an advisory message about the operator's own
111+
// configuration, and is NOT sufficient for enforcement.
112+
expect(SERVE_SOURCE).toMatch(/checkMultiNodeAllowed\(\s*Number\(process\.env\.OS_CLUSTER_REPLICAS\)\s*\)/);
113+
});
114+
115+
it('types the dynamic import with the mirrored verdict, not an inline literal', () => {
116+
expect(SERVE_SOURCE).toMatch(
117+
/checkMultiNodeAllowed:\s*\(requested\?:\s*number\)\s*=>\s*MultiNodeGateVerdict/,
118+
);
119+
});
120+
121+
it("serve's local verdict mirror matches the gate's own resolved verdict", () => {
122+
const producer = interfaceFields(GATE_SOURCE, 'ResolvedMultiNodeVerdict');
123+
const consumer = interfaceFields(SERVE_SOURCE, 'MultiNodeGateVerdict');
124+
125+
// Guard the extractor itself: two empty lists would agree vacuously and pin
126+
// nothing at all.
127+
expect(producer).toContain('capped');
128+
expect(producer).toContain('refused');
129+
expect(producer.length).toBeGreaterThan(3);
130+
131+
expect(
132+
consumer,
133+
'packages/services/service-cluster/src/multi-node-gate.ts changed its resolved verdict shape. '
134+
+ "serve.ts mirrors it by hand (no static dependency), so update `MultiNodeGateVerdict` in "
135+
+ 'packages/cli/src/commands/serve.ts to match — and decide whether the operator warning '
136+
+ 'should now read the new field.',
137+
).toEqual(producer);
138+
});
139+
});
Lines changed: 117 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,117 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* The operator-facing half of the 2026-08-13 `max_nodes` ruling (cloud#1275).
5+
*
6+
* The ruling has three clauses — refuse the excess, run up to the paid limit,
7+
* warn loudly. The first two need an atomic slot claim across replicas and are
8+
* tracked as their own mechanism. The third needs nothing but the verdict the
9+
* gate already returns, and had no owner: `os serve` is the sole runtime
10+
* consumer of that gate, and it neither asked for a count nor rendered one.
11+
*
12+
* ⚠️ These assertions are about **wording**, and that is deliberate. While
13+
* enforcement is open, nothing is actually refused — the gate is consulted once
14+
* per process at boot, every replica computes the same verdict, and none can
15+
* tell whether it is one of the admitted ones, so all of them join. A message
16+
* claiming "2 replicas refused" would be false in exactly the
17+
* declared-vs-delivered way this warning exists to close. The tests below pin
18+
* the honest shape: the cap is advisory, and the excess replicas still join.
19+
*/
20+
21+
import { describe, it, expect } from 'vitest';
22+
import { formatMultiNodeCapAdvisory, type MultiNodeGateVerdict } from './serve.js';
23+
24+
/**
25+
* The four verdicts the producer can hand this consumer, spelled the way
26+
* `checkMultiNodeAllowed` builds them (`multi-node-gate.ts`). The pin test
27+
* beside this one is what keeps that claim true; here they are fixtures.
28+
*/
29+
const VERDICTS = {
30+
/** No gate registered, or an allowing gate that declared no cap. */
31+
uncapped: { allowed: true, refused: 0, capped: false },
32+
/** A cap exists and the declared topology fits inside it. */
33+
withinCap: { allowed: true, admitted: 3, refused: 0, capped: false },
34+
/** The licensed-overflow case: 5 declared, 3 paid for. */
35+
overflow: { allowed: true, admitted: 3, refused: 2, capped: true },
36+
/** Unlicensed: the whole cluster is denied. `capped` stays false by design. */
37+
denied: { allowed: false, reason: 'no clustering entitlement', admitted: 0, refused: 5, capped: false },
38+
} satisfies Record<string, MultiNodeGateVerdict>;
39+
40+
describe('formatMultiNodeCapAdvisory', () => {
41+
it('says nothing when no cap is configured', () => {
42+
expect(formatMultiNodeCapAdvisory(VERDICTS.uncapped)).toBeNull();
43+
});
44+
45+
it('says nothing when a cap is configured and the declared topology fits', () => {
46+
expect(formatMultiNodeCapAdvisory(VERDICTS.withinCap)).toBeNull();
47+
});
48+
49+
it('warns on a licensed overflow, naming both numbers', () => {
50+
expect(formatMultiNodeCapAdvisory(VERDICTS.overflow)).toBe(
51+
'[cluster] licensed node cap exceeded: the licence admits 3 node(s), '
52+
+ 'but OS_CLUSTER_REPLICAS declares 5 — 2 beyond the cap.\n'
53+
+ '[cluster] This cap is ADVISORY and is not enforced yet: nothing is refused, '
54+
+ 'and all 5 replicas will still join the cluster.\n'
55+
+ '[cluster] Reduce OS_CLUSTER_REPLICAS to 3, or raise the licensed node limit.',
56+
);
57+
});
58+
59+
it('⚠️ never claims replicas were refused — nothing is refused today', () => {
60+
const text = formatMultiNodeCapAdvisory(VERDICTS.overflow) ?? '';
61+
62+
// The false sentences, in the shapes they would plausibly be written.
63+
expect(text).not.toMatch(/\d+\s+(replicas?\s+)?(were\s+|was\s+|are\s+)?refused/i);
64+
expect(text).not.toMatch(/refus(ed|ing)\s+\d+/i);
65+
expect(text).not.toMatch(/(rejected|denied|dropped|will not join|won't join)/i);
66+
67+
// ...and the true ones it must carry instead.
68+
expect(text).toMatch(/advisory/i);
69+
expect(text).toMatch(/not enforced/i);
70+
expect(text).toMatch(/nothing is refused/i);
71+
expect(text).toMatch(/still join/i);
72+
});
73+
74+
it('names the declared count, the admitted count and the remedy', () => {
75+
const text = formatMultiNodeCapAdvisory(VERDICTS.overflow) ?? '';
76+
expect(text).toContain('admits 3');
77+
expect(text).toContain('declares 5');
78+
expect(text).toContain('2 beyond the cap');
79+
expect(text).toContain('OS_CLUSTER_REPLICAS');
80+
expect(text).toContain('Reduce OS_CLUSTER_REPLICAS to 3');
81+
});
82+
83+
it('stays silent on an outright denial — that one is a downgrade, not a cap', () => {
84+
// `capped: false` on a denial is the producer's deliberate choice so the
85+
// unlicensed case cannot be conflated with the licensed-overflow one. The
86+
// call site already prints its own downgrade warning; a second message here
87+
// would report one event twice, and would call a full denial a partial cap.
88+
expect(formatMultiNodeCapAdvisory(VERDICTS.denied)).toBeNull();
89+
});
90+
91+
it('surfaces the gate reason when the cap carries one', () => {
92+
const text = formatMultiNodeCapAdvisory({
93+
allowed: true,
94+
reason: 'plan: team (3 nodes)',
95+
admitted: 3,
96+
refused: 2,
97+
capped: true,
98+
});
99+
expect(text).toContain('(plan: team (3 nodes))');
100+
});
101+
102+
it('scales with the numbers rather than hard-coding the 3/5 case', () => {
103+
const text = formatMultiNodeCapAdvisory({ allowed: true, admitted: 10, refused: 7, capped: true }) ?? '';
104+
expect(text).toContain('admits 10');
105+
expect(text).toContain('declares 17');
106+
expect(text).toContain('7 beyond the cap');
107+
});
108+
109+
it('prints no number it was not given: a countless `capped` verdict stays silent', () => {
110+
// Unreachable from the shipped producer (`capped: true` always arrives with
111+
// a numeric `admitted`), so this pins the choice rather than a behaviour:
112+
// faced with a stale or foreign build, silence beats a warning with an
113+
// invented count in it.
114+
expect(formatMultiNodeCapAdvisory({ allowed: true, refused: 2, capped: true })).toBeNull();
115+
expect(formatMultiNodeCapAdvisory({ allowed: true, admitted: 3, refused: 0, capped: true })).toBeNull();
116+
});
117+
});

0 commit comments

Comments
 (0)