Skip to content

Commit 4d672c4

Browse files
os-steveclaudehotlong
authored
fix(service-cluster,cli): multi-node gate fails closed when unregistered, and mounts on every boot route (#14114)
* fix(service-cluster,cli): multi-node gate fails closed when unregistered; mount it on every boot route Half 1 — the default direction: checkMultiNodeAllowed with no registered gate now refuses a DECLARED multi-node topology (requested > 1) instead of silently allowing a licensed capability; undeclared / single-replica input keeps the historical allow, and a registered gate's verdicts are unchanged. Half 2 — sink the mount: new mountMultiNodeGateFromHost in service-cluster imports the distribution's gate-carrier packages through the boot surface's host-anchored importer, so registration no longer depends on one app config file executing; os serve calls it before consulting the gate on the cluster-driver route (declared domain:cli half). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016ZC5rNQj3WEet5HAmmAkMs * chore(changeset): ADR-0087 disposition for the gate default change Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016ZC5rNQj3WEet5HAmmAkMs * test(service-cluster): pin what a no-gate denial actually does at boot (#14116) The gate's module doc promised "the caller downgrades to single-node — never bricks". Measured on #14116 that is false for the only deployment shape that can reach the new fail-closed refusal at all: the refusal's trigger (`requested > 1`) is the same operator declaration the split-brain guard keys off, so the in-process fallback serve leaves behind is then refused and the boot stops. Pins the whole chain from the real pieces rather than restating prose: `defineCluster({})` resolves `memory` (what Runtime builds when serve leaves clusterConfig unset), and the guard throws for that driver at replicas 3 while staying quiet at 1 — the genuine downgrade case, kept distinct so the two are never conflated again. The prose in `multi-node-gate.ts`, `multi-node-gate-mount.ts`, `serve.ts` and the changeset is corrected to match, including the operator-facing boot warning, which said "downgrading to single-node" while the process was about to refuse. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> --------- Co-authored-by: Claude <noreply@anthropic.com> Co-authored-by: Jack Zhuang <50353452+hotlong@users.noreply.github.com>
1 parent 987fe37 commit 4d672c4

7 files changed

Lines changed: 588 additions & 16 deletions

File tree

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
---
2+
"@objectstack/service-cluster": minor
3+
"@objectstack/cli": patch
4+
---
5+
6+
fix(service-cluster,cli): the multi-node gate fails closed when unregistered, and is mounted on every boot route (#13537)
7+
8+
**BREAKING behaviour narrowing on a licensed capability, shipped as `minor`
9+
under the repo's launch-window convention for breaking changes.**
10+
11+
Multi-node clustering is a paid capability (maintainer ruling 2026-08-31,
12+
recorded on #13537). Two defects together made its authorization gate
13+
unenforceable by construction — measured on a real thin-extension EE
14+
deployment, a `maxNodes: 1` trial license booted 3 replicas with full cluster
15+
coordination and no warning (cloud#1752):
16+
17+
- `checkMultiNodeAllowed` **defaulted to ALLOW when no gate was registered**,
18+
so every boot route that skipped the one config file wiring the gate ran an
19+
unlicensed cluster silently.
20+
- `registerMultiNodeGate` was reachable from exactly **one** mount point (the
21+
EE app config, cloud repo), which the thin-extension and `OS_ARTIFACT_URL`
22+
artifact-direct boot routes never execute.
23+
24+
Both halves change:
25+
26+
- **Fail-closed default** (`@objectstack/service-cluster`): with no gate
27+
registered, a DECLARED multi-node topology (`requested > 1`) is now
28+
**refused**`os serve` drops the remote driver and warns loudly.
29+
⛔ Read the boot outcome precisely: with a multi-node topology declared, the
30+
in-process fallback then trips the split-brain guard and the boot is
31+
**REFUSED**, not quietly degraded (measured on #14116; the guard's trigger
32+
and this default's trigger are the same declaration). The refusal is the
33+
correct outcome — N replicas on per-process locks is the silent split-brain
34+
that guard exists to stop — but it is a refusal, and an operator upgrading
35+
into this default must be told so. An undeclared or single-replica count (`OS_CLUSTER_REPLICAS`
36+
unset, `1`, or meaningless) keeps the historical allow: it declares no
37+
multi-node topology, so there is nothing to gate. A registered gate's
38+
verdicts are byte-identical to before — entitled deployments are untouched.
39+
New exports: `hasMultiNodeGate()`, `MULTI_NODE_NO_GATE_REASON`.
40+
- **Route-independent mounting** (`mountMultiNodeGateFromHost`, new): the boot
41+
surface about to consult the gate hands over its host-anchored importer and
42+
the helper loads the distribution packages that carry the gate
43+
(`MULTI_NODE_GATE_CARRIER_PACKAGES`), so registration no longer depends on
44+
one app config file executing. `os serve` now calls it before the consult
45+
(`@objectstack/cli`), best-effort: with no distribution installed nothing
46+
mounts and the fail-closed default answers.
47+
48+
**Migration.** A deployment that ran `OS_CLUSTER_DRIVER` (non-memory) with
49+
`OS_CLUSTER_REPLICAS > 1` and **no** registered gate was running an
50+
unlicensed multi-node topology on the old fail-open default; it now downgrades
51+
to single-node at boot and logs the refusal. Deploy a distribution that
52+
registers the gate (at module load of a carrier package, so every boot route
53+
mounts it), or remove the multi-node declaration.
54+
55+
<!-- adr-0087: not-required (no-migration-prescription) A runtime default-direction change on the multi-node authorization gate: no spec key is removed, renamed or re-shaped, so there is no tombstone and nothing mechanical for `objectstack migrate meta` to rewrite. The channel that reaches an affected operator is the boot-time refusal itself (`os serve` logs `MULTI_NODE_NO_GATE_REASON` with the remedy, and a declared multi-node topology then stops the boot at the split-brain guard rather than degrading silently); whether to deploy a gate-registering distribution or drop the multi-node declaration is a deployment decision no migration entry can perform. -->

packages/cli/src/commands/serve.ts

Lines changed: 43 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2422,8 +2422,19 @@ export default class Serve extends Command {
24222422
const __clusterDriver = process.env.OS_CLUSTER_DRIVER?.trim();
24232423
if (__clusterDriver && __clusterDriver !== 'memory') {
24242424
// Multi-node authorization gate (open mechanism): a distribution (e.g.
2425-
// an EE license) may deny multi-node. On denial, downgrade to
2426-
// single-node rather than fail — multi-node is an add-on, never brick.
2425+
// an EE license) may deny multi-node. On denial this file drops the
2426+
// remote driver and leaves `clusterConfig` unset, so the Runtime falls
2427+
// back to the in-process driver.
2428+
//
2429+
// ⛔ That is NOT the same as "the boot survives", and this comment used
2430+
// to say it was ("never brick"). Measured on #14116: when the operator
2431+
// ALSO declared a multi-node topology (`OS_CLUSTER_REPLICAS > 1` /
2432+
// `OS_EXPECT_MULTI_NODE=true`), the in-process fallback then trips the
2433+
// split-brain guard in `ClusterServicePlugin.init` and the boot is
2434+
// REFUSED. The refusal is correct — N replicas on per-process locks is
2435+
// exactly the silent corruption that guard exists to stop — but a
2436+
// denial and a declared topology together mean refuse, not degrade.
2437+
// The warning below says so.
24272438
// Dynamic, non-literal specifier so the CLI does not statically depend
24282439
// on the cluster package (mirrors the remote-driver import below).
24292440
//
@@ -2443,8 +2454,32 @@ export default class Serve extends Command {
24432454
checkMultiNodeAllowed: (requested?: number) => MultiNodeGateVerdict;
24442455
/** Optional: an app on a pre-#13330 `service-cluster` does not have it. */
24452456
listClusterDrivers?: () => string[];
2457+
// Optional: an app may pin an older service-cluster that predates
2458+
// the mount helper (#13537); `?.` below keeps that boot walking.
2459+
mountMultiNodeGateFromHost?: (
2460+
importer: (specifier: string) => Promise<unknown>,
2461+
) => Promise<unknown>;
24462462
};
2447-
const { checkMultiNodeAllowed } = __clusterModule;
2463+
const { checkMultiNodeAllowed, mountMultiNodeGateFromHost } = __clusterModule;
2464+
// [#13537] Mount the distribution's gate on EVERY boot route, BEFORE
2465+
// consulting it. Registration used to depend on one app config file
2466+
// executing (the EE config calling `registerMultiNodeGate`), so the
2467+
// thin-extension and artifact-direct routes booted with no gate at
2468+
// all — and the gate then defaulted to allow. The helper imports the
2469+
// gate-carrying distribution packages through this file's own
2470+
// host-anchored importer (passed as a value, so every carrier load
2471+
// resolves from the served app per #4719 — same guarantee as the
2472+
// `importFromHost` call above, just exercised inside the package that
2473+
// owns the carrier list). Best-effort: with no distribution installed
2474+
// nothing mounts, and the gate's fail-closed default answers below.
2475+
//
2476+
// ⚠️ Resolved against #13330's namespace read on main: the mount helper
2477+
// is destructured from THE SAME `__clusterModule`, so the instance that
2478+
// registers is provably the instance `checkMultiNodeAllowed` and
2479+
// `listClusterDrivers` are read from. Re-importing the package for the
2480+
// mount would have re-opened the split this file just closed.
2481+
try { await mountMultiNodeGateFromHost?.(importFromHost); }
2482+
catch { /* never brick the boot for an add-on — the check below fails closed */ }
24482483
// Ask the gate about the topology the operator actually DECLARED.
24492484
// Calling zero-arg leaves `requested` undefined, which a cap-aware gate
24502485
// has nothing to clamp against — so the licensed-overflow verdict was
@@ -2468,7 +2503,11 @@ export default class Serve extends Command {
24682503
if (!__gate.allowed) {
24692504
console.warn(
24702505
`[cluster] multi-node not authorized (${__gate.reason ?? 'denied'}) — ` +
2471-
`downgrading to single-node (in-memory cluster). Remove OS_CLUSTER_DRIVER to silence.`,
2506+
`falling back to the in-process cluster driver. If this deployment ALSO ` +
2507+
`declared a multi-node topology (OS_CLUSTER_REPLICAS>1 / OS_EXPECT_MULTI_NODE=true), ` +
2508+
`the split-brain guard refuses the boot rather than run replicas on per-process ` +
2509+
`locks — drop the declaration to run single-node, or license the capability. ` +
2510+
`Remove OS_CLUSTER_DRIVER to silence.`,
24722511
);
24732512
} else {
24742513
// Licensed-overflow advisory: the cluster IS entitled to run, it just

packages/services/service-cluster/src/index.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -77,8 +77,20 @@ export type {
7777
export {
7878
registerMultiNodeGate,
7979
checkMultiNodeAllowed,
80+
hasMultiNodeGate,
81+
MULTI_NODE_NO_GATE_REASON,
8082
__resetMultiNodeGate,
8183
type MultiNodeGate,
8284
type MultiNodeVerdict,
8385
type ResolvedMultiNodeVerdict,
8486
} from './multi-node-gate.js';
87+
88+
// [#13537] Route-independent gate mounting: a boot surface hands its
89+
// host-anchored importer over so the distribution's gate is mounted on EVERY
90+
// boot route, not only where one app config file executes.
91+
export {
92+
mountMultiNodeGateFromHost,
93+
MULTI_NODE_GATE_CARRIER_PACKAGES,
94+
type MultiNodeGateMountAttempt,
95+
type MultiNodeGateMountReading,
96+
} from './multi-node-gate-mount.js';
Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,105 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
import { describe, it, expect, afterEach } from 'vitest';
3+
import { PLATFORM_PLUGIN_WIRED_RUNTIMES } from '@objectstack/spec';
4+
import {
5+
registerMultiNodeGate,
6+
checkMultiNodeAllowed,
7+
__resetMultiNodeGate,
8+
} from './multi-node-gate.js';
9+
import {
10+
mountMultiNodeGateFromHost,
11+
MULTI_NODE_GATE_CARRIER_PACKAGES,
12+
} from './multi-node-gate-mount.js';
13+
14+
afterEach(() => __resetMultiNodeGate());
15+
16+
/** An always-allowing gate, standing in for a distribution's licence check. */
17+
const FAKE_GATE = { allowMultiNode: () => ({ allowed: true, reason: 'fake licence' }) };
18+
19+
describe('mountMultiNodeGateFromHost', () => {
20+
it('registers via a carrier whose module load registers, and stops there', async () => {
21+
const imported: string[] = [];
22+
const reading = await mountMultiNodeGateFromHost(async (specifier) => {
23+
imported.push(specifier);
24+
// The carrier contract: registration is a SIDE EFFECT of module
25+
// load. The fake registers on first import, like a real carrier
26+
// whose module scope calls `registerMultiNodeGate`.
27+
registerMultiNodeGate(FAKE_GATE);
28+
return {};
29+
});
30+
expect(reading).toEqual({
31+
alreadyRegistered: false,
32+
registered: true,
33+
attempts: [{ package: MULTI_NODE_GATE_CARRIER_PACKAGES[0], outcome: 'registered' }],
34+
});
35+
// Mount done after the first carrier — the second is never imported.
36+
expect(imported).toEqual([MULTI_NODE_GATE_CARRIER_PACKAGES[0]]);
37+
// And the mounted gate is the one the consult now reads.
38+
expect(checkMultiNodeAllowed(3)).toMatchObject({ allowed: true, reason: 'fake licence' });
39+
});
40+
41+
it('reports every carrier unavailable when none resolves, and stays unregistered', async () => {
42+
const reading = await mountMultiNodeGateFromHost(async (specifier) => {
43+
throw new Error(`Cannot find package '${specifier}'`);
44+
});
45+
expect(reading.alreadyRegistered).toBe(false);
46+
expect(reading.registered).toBe(false);
47+
expect(reading.attempts).toEqual(
48+
MULTI_NODE_GATE_CARRIER_PACKAGES.map((pkg) => ({
49+
package: pkg,
50+
outcome: 'unavailable',
51+
error: `Cannot find package '${pkg}'`,
52+
})),
53+
);
54+
// The open-core outcome: nothing mounted, so the fail-closed default
55+
// answers the consult that follows (#13537).
56+
expect(checkMultiNodeAllowed(3).allowed).toBe(false);
57+
});
58+
59+
it('never throws: a rejecting importer becomes an `unavailable` attempt', async () => {
60+
await expect(
61+
mountMultiNodeGateFromHost(async () => {
62+
throw 'not-an-Error'; // eslint-disable-line no-throw-literal
63+
}),
64+
).resolves.toMatchObject({
65+
registered: false,
66+
attempts: expect.arrayContaining([
67+
expect.objectContaining({ outcome: 'unavailable', error: 'not-an-Error' }),
68+
]),
69+
});
70+
});
71+
72+
it('records a carrier that loads without registering (the #13330 split, or an old carrier)', async () => {
73+
const reading = await mountMultiNodeGateFromHost(async () => ({}));
74+
expect(reading.registered).toBe(false);
75+
expect(reading.attempts).toEqual(
76+
MULTI_NODE_GATE_CARRIER_PACKAGES.map((pkg) => ({
77+
package: pkg,
78+
outcome: 'loaded-without-gate',
79+
})),
80+
);
81+
});
82+
83+
it('does not import anything when a gate is already registered', async () => {
84+
registerMultiNodeGate(FAKE_GATE);
85+
const imported: string[] = [];
86+
const reading = await mountMultiNodeGateFromHost(async (specifier) => {
87+
imported.push(specifier);
88+
return {};
89+
});
90+
expect(reading).toEqual({ alreadyRegistered: true, registered: true, attempts: [] });
91+
expect(imported).toEqual([]);
92+
});
93+
94+
it('names only real, roster-declared distribution runtimes as carriers', () => {
95+
// Drift guard (#10921): every carrier must be a package the spec
96+
// roster declares as a real out-of-repo `plugins[]`-wired runtime —
97+
// a fabricated name would sit here looking identical and simply never
98+
// resolve. The list itself is owned by the mount module (the roster
99+
// is provenance, not a resolution registry, by its own contract).
100+
for (const pkg of MULTI_NODE_GATE_CARRIER_PACKAGES) {
101+
expect(PLATFORM_PLUGIN_WIRED_RUNTIMES[pkg]).toBeDefined();
102+
}
103+
expect(MULTI_NODE_GATE_CARRIER_PACKAGES.length).toBeGreaterThan(0);
104+
});
105+
});

0 commit comments

Comments
 (0)