From ee607b4886984e3790d130edb6086f53b7dbf385 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 1 Sep 2026 00:48:34 +0000 Subject: [PATCH 1/3] fix(service-cluster,cli): multi-node gate fails closed when unregistered; mount it on every boot route MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Claude-Session: https://claude.ai/code/session_016ZC5rNQj3WEet5HAmmAkMs --- .../multi-node-gate-fail-closed-mount.md | 47 ++++++ packages/cli/src/commands/serve.ts | 20 ++- .../services/service-cluster/src/index.ts | 12 ++ .../src/multi-node-gate-mount.test.ts | 105 +++++++++++++ .../src/multi-node-gate-mount.ts | 143 ++++++++++++++++++ .../src/multi-node-gate.test.ts | 82 +++++++++- .../service-cluster/src/multi-node-gate.ts | 77 +++++++++- 7 files changed, 477 insertions(+), 9 deletions(-) create mode 100644 .changeset/multi-node-gate-fail-closed-mount.md create mode 100644 packages/services/service-cluster/src/multi-node-gate-mount.test.ts create mode 100644 packages/services/service-cluster/src/multi-node-gate-mount.ts diff --git a/.changeset/multi-node-gate-fail-closed-mount.md b/.changeset/multi-node-gate-fail-closed-mount.md new file mode 100644 index 0000000000..9d06da0a70 --- /dev/null +++ b/.changeset/multi-node-gate-fail-closed-mount.md @@ -0,0 +1,47 @@ +--- +"@objectstack/service-cluster": minor +"@objectstack/cli": patch +--- + +fix(service-cluster,cli): the multi-node gate fails closed when unregistered, and is mounted on every boot route (#13537) + +**BREAKING behaviour narrowing on a licensed capability, shipped as `minor` +under the repo's launch-window convention for breaking changes.** + +Multi-node clustering is a paid capability (maintainer ruling 2026-08-31, +recorded on #13537). Two defects together made its authorization gate +unenforceable by construction — measured on a real thin-extension EE +deployment, a `maxNodes: 1` trial license booted 3 replicas with full cluster +coordination and no warning (cloud#1752): + +- `checkMultiNodeAllowed` **defaulted to ALLOW when no gate was registered**, + so every boot route that skipped the one config file wiring the gate ran an + unlicensed cluster silently. +- `registerMultiNodeGate` was reachable from exactly **one** mount point (the + EE app config, cloud repo), which the thin-extension and `OS_ARTIFACT_URL` + artifact-direct boot routes never execute. + +Both halves change: + +- **Fail-closed default** (`@objectstack/service-cluster`): with no gate + registered, a DECLARED multi-node topology (`requested > 1`) is now + **refused** — `os serve` downgrades to single-node with a loud warning, + never bricks. An undeclared or single-replica count (`OS_CLUSTER_REPLICAS` + unset, `1`, or meaningless) keeps the historical allow: it declares no + multi-node topology, so there is nothing to gate. A registered gate's + verdicts are byte-identical to before — entitled deployments are untouched. + New exports: `hasMultiNodeGate()`, `MULTI_NODE_NO_GATE_REASON`. +- **Route-independent mounting** (`mountMultiNodeGateFromHost`, new): the boot + surface about to consult the gate hands over its host-anchored importer and + the helper loads the distribution packages that carry the gate + (`MULTI_NODE_GATE_CARRIER_PACKAGES`), so registration no longer depends on + one app config file executing. `os serve` now calls it before the consult + (`@objectstack/cli`), best-effort: with no distribution installed nothing + mounts and the fail-closed default answers. + +**Migration.** A deployment that ran `OS_CLUSTER_DRIVER` (non-memory) with +`OS_CLUSTER_REPLICAS > 1` and **no** registered gate was running an +unlicensed multi-node topology on the old fail-open default; it now downgrades +to single-node at boot and logs the refusal. Deploy a distribution that +registers the gate (at module load of a carrier package, so every boot route +mounts it), or remove the multi-node declaration. diff --git a/packages/cli/src/commands/serve.ts b/packages/cli/src/commands/serve.ts index f77ec4e802..f26fc4a29e 100644 --- a/packages/cli/src/commands/serve.ts +++ b/packages/cli/src/commands/serve.ts @@ -2432,9 +2432,27 @@ export default class Serve extends Command { // '@objectstack/service-cluster'` and took the whole boot down — while // app-side code loaded the very same package fine. const __clusterPkg: string = '@objectstack/service-cluster'; - const { checkMultiNodeAllowed } = (await importFromHost(__clusterPkg)) as { + const { checkMultiNodeAllowed, mountMultiNodeGateFromHost } = (await importFromHost(__clusterPkg)) as { checkMultiNodeAllowed: (requested?: number) => MultiNodeGateVerdict; + // Optional: an app may pin an older service-cluster that predates + // the mount helper (#13537); `?.` below keeps that boot walking. + mountMultiNodeGateFromHost?: ( + importer: (specifier: string) => Promise, + ) => Promise; }; + // [#13537] Mount the distribution's gate on EVERY boot route, BEFORE + // consulting it. Registration used to depend on one app config file + // executing (the EE config calling `registerMultiNodeGate`), so the + // thin-extension and artifact-direct routes booted with no gate at + // all — and the gate then defaulted to allow. The helper imports the + // gate-carrying distribution packages through this file's own + // host-anchored importer (passed as a value, so every carrier load + // resolves from the served app per #4719 — same guarantee as the + // `importFromHost` call above, just exercised inside the package that + // owns the carrier list). Best-effort: with no distribution installed + // nothing mounts, and the gate's fail-closed default answers below. + try { await mountMultiNodeGateFromHost?.(importFromHost); } + catch { /* never brick the boot for an add-on — the check below fails closed */ } // Ask the gate about the topology the operator actually DECLARED. // Calling zero-arg leaves `requested` undefined, which a cap-aware gate // has nothing to clamp against — so the licensed-overflow verdict was diff --git a/packages/services/service-cluster/src/index.ts b/packages/services/service-cluster/src/index.ts index 1b2c44eb28..0c0f2ebb41 100644 --- a/packages/services/service-cluster/src/index.ts +++ b/packages/services/service-cluster/src/index.ts @@ -76,8 +76,20 @@ export type { export { registerMultiNodeGate, checkMultiNodeAllowed, + hasMultiNodeGate, + MULTI_NODE_NO_GATE_REASON, __resetMultiNodeGate, type MultiNodeGate, type MultiNodeVerdict, type ResolvedMultiNodeVerdict, } from './multi-node-gate.js'; + +// [#13537] Route-independent gate mounting: a boot surface hands its +// host-anchored importer over so the distribution's gate is mounted on EVERY +// boot route, not only where one app config file executes. +export { + mountMultiNodeGateFromHost, + MULTI_NODE_GATE_CARRIER_PACKAGES, + type MultiNodeGateMountAttempt, + type MultiNodeGateMountReading, +} from './multi-node-gate-mount.js'; diff --git a/packages/services/service-cluster/src/multi-node-gate-mount.test.ts b/packages/services/service-cluster/src/multi-node-gate-mount.test.ts new file mode 100644 index 0000000000..08d94af46f --- /dev/null +++ b/packages/services/service-cluster/src/multi-node-gate-mount.test.ts @@ -0,0 +1,105 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. +import { describe, it, expect, afterEach } from 'vitest'; +import { PLATFORM_PLUGIN_WIRED_RUNTIMES } from '@objectstack/spec'; +import { + registerMultiNodeGate, + checkMultiNodeAllowed, + __resetMultiNodeGate, +} from './multi-node-gate.js'; +import { + mountMultiNodeGateFromHost, + MULTI_NODE_GATE_CARRIER_PACKAGES, +} from './multi-node-gate-mount.js'; + +afterEach(() => __resetMultiNodeGate()); + +/** An always-allowing gate, standing in for a distribution's licence check. */ +const FAKE_GATE = { allowMultiNode: () => ({ allowed: true, reason: 'fake licence' }) }; + +describe('mountMultiNodeGateFromHost', () => { + it('registers via a carrier whose module load registers, and stops there', async () => { + const imported: string[] = []; + const reading = await mountMultiNodeGateFromHost(async (specifier) => { + imported.push(specifier); + // The carrier contract: registration is a SIDE EFFECT of module + // load. The fake registers on first import, like a real carrier + // whose module scope calls `registerMultiNodeGate`. + registerMultiNodeGate(FAKE_GATE); + return {}; + }); + expect(reading).toEqual({ + alreadyRegistered: false, + registered: true, + attempts: [{ package: MULTI_NODE_GATE_CARRIER_PACKAGES[0], outcome: 'registered' }], + }); + // Mount done after the first carrier — the second is never imported. + expect(imported).toEqual([MULTI_NODE_GATE_CARRIER_PACKAGES[0]]); + // And the mounted gate is the one the consult now reads. + expect(checkMultiNodeAllowed(3)).toMatchObject({ allowed: true, reason: 'fake licence' }); + }); + + it('reports every carrier unavailable when none resolves, and stays unregistered', async () => { + const reading = await mountMultiNodeGateFromHost(async (specifier) => { + throw new Error(`Cannot find package '${specifier}'`); + }); + expect(reading.alreadyRegistered).toBe(false); + expect(reading.registered).toBe(false); + expect(reading.attempts).toEqual( + MULTI_NODE_GATE_CARRIER_PACKAGES.map((pkg) => ({ + package: pkg, + outcome: 'unavailable', + error: `Cannot find package '${pkg}'`, + })), + ); + // The open-core outcome: nothing mounted, so the fail-closed default + // answers the consult that follows (#13537). + expect(checkMultiNodeAllowed(3).allowed).toBe(false); + }); + + it('never throws: a rejecting importer becomes an `unavailable` attempt', async () => { + await expect( + mountMultiNodeGateFromHost(async () => { + throw 'not-an-Error'; // eslint-disable-line no-throw-literal + }), + ).resolves.toMatchObject({ + registered: false, + attempts: expect.arrayContaining([ + expect.objectContaining({ outcome: 'unavailable', error: 'not-an-Error' }), + ]), + }); + }); + + it('records a carrier that loads without registering (the #13330 split, or an old carrier)', async () => { + const reading = await mountMultiNodeGateFromHost(async () => ({})); + expect(reading.registered).toBe(false); + expect(reading.attempts).toEqual( + MULTI_NODE_GATE_CARRIER_PACKAGES.map((pkg) => ({ + package: pkg, + outcome: 'loaded-without-gate', + })), + ); + }); + + it('does not import anything when a gate is already registered', async () => { + registerMultiNodeGate(FAKE_GATE); + const imported: string[] = []; + const reading = await mountMultiNodeGateFromHost(async (specifier) => { + imported.push(specifier); + return {}; + }); + expect(reading).toEqual({ alreadyRegistered: true, registered: true, attempts: [] }); + expect(imported).toEqual([]); + }); + + it('names only real, roster-declared distribution runtimes as carriers', () => { + // Drift guard (#10921): every carrier must be a package the spec + // roster declares as a real out-of-repo `plugins[]`-wired runtime — + // a fabricated name would sit here looking identical and simply never + // resolve. The list itself is owned by the mount module (the roster + // is provenance, not a resolution registry, by its own contract). + for (const pkg of MULTI_NODE_GATE_CARRIER_PACKAGES) { + expect(PLATFORM_PLUGIN_WIRED_RUNTIMES[pkg]).toBeDefined(); + } + expect(MULTI_NODE_GATE_CARRIER_PACKAGES.length).toBeGreaterThan(0); + }); +}); diff --git a/packages/services/service-cluster/src/multi-node-gate-mount.ts b/packages/services/service-cluster/src/multi-node-gate-mount.ts new file mode 100644 index 0000000000..6e73cf40fe --- /dev/null +++ b/packages/services/service-cluster/src/multi-node-gate-mount.ts @@ -0,0 +1,143 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * Route-independent mounting of the multi-node authorization gate (#13537). + * + * ## The defect this closes + * + * `registerMultiNodeGate` used to be reachable from exactly ONE place: the EE + * app config (`apps/objectos-ee/objectstack.config.ts`, cloud repo) calling it + * while that file executes. Any boot route on which that file does not execute + * — a thin-extension host app whose own config runs instead, or the + * `OS_ARTIFACT_URL` artifact-direct route where no config executes at all — + * skipped registration entirely, and the gate's then-default of allow turned + * "the config didn't run" into "multi-node is permitted" on a licensed + * capability. Measured on a real thin-extension EE deployment: a `maxNodes: 1` + * trial license booted 3 replicas with full cluster coordination and no + * warning (cloud#1752). + * + * ## The repair, and where each half lives + * + * 1. The gate's no-registration default now fails closed for a declared + * multi-node topology (`multi-node-gate.ts`, same card). + * 2. This module makes registration reachable on EVERY route: the boot + * surface that is about to consult the gate (`os serve`; cloud's hosted + * runtime may do the same) hands over its host importer, and this helper + * loads the distribution packages that carry the gate. A carrier registers + * at its own module load, so merely being imported mounts the gate — no + * app config file needs to execute. + * + * ## Why the importer is a PARAMETER + * + * The carrier packages are distribution-shipped and app-declared: they live in + * the served app's `node_modules`, never in this package's or the CLI's own + * (#4719 — what the host root declares is the contract). The only correct way + * to reach them is the caller's host-anchored importer, so this helper takes + * that importer instead of owning any resolution of its own. It never falls + * back to a bare `import()` — an absent carrier is a normal open-core state, + * reported, not worked around. + * + * ## ⚠️ Effectiveness boundary — the dual-instance split (#13330) stands + * + * This registry is module-level singleton state, not `globalThis`-anchored. A + * carrier whose own `@objectstack/service-cluster` import resolves to a + * DIFFERENT module instance than the one the boot surface consults registers + * into the wrong registry, and `hasMultiNodeGate()` here honestly reports + * "not registered" — the attempt is recorded as `loaded-without-gate` rather + * than papered over. Fixing that split is #13330's job for the registry class + * generally; this module deliberately does not anchor one registry by hand. + * Until it lands, the fail-closed default means a mis-anchored registration + * downgrades to single-node loudly instead of running unlicensed silently. + */ + +import { hasMultiNodeGate } from './multi-node-gate.js'; + +/** + * The distribution packages a boot surface loads to mount the gate, in the + * order they are tried. Both are `plugins[]`-wired enterprise runtimes the + * spec roster declares (`PLATFORM_PLUGIN_WIRED_RUNTIMES`, + * `@objectstack/spec`) — i.e. real, closed-source, licensed-route packages, + * not fabricated names (#10921). The list is owned HERE, not derived from + * that roster: the roster records provenance and is by its own contract not a + * resolution registry; a drift test keeps the two agreeing. + * + * A carrier's obligation is: **register the gate as a side effect of module + * load** (`registerMultiNodeGate` at module scope), so that being imported — + * by any route — is sufficient to mount it. + */ +export const MULTI_NODE_GATE_CARRIER_PACKAGES: readonly string[] = Object.freeze([ + '@objectstack/security-enterprise', + '@objectstack/organizations', +]); + +/** One carrier import attempt, for the caller's diagnostics. */ +export interface MultiNodeGateMountAttempt { + /** The carrier package specifier that was imported. */ + package: string; + /** + * - `registered` — after this import, a gate is registered (mount done; + * later carriers are not tried). + * - `loaded-without-gate` — the import succeeded but no gate appeared on + * this module instance: the carrier predates module-load registration, + * or its registration landed on another instance (#13330). + * - `unavailable` — the import failed (not installed, not declared by the + * host, or it threw while evaluating); `error` carries the message. + */ + outcome: 'registered' | 'loaded-without-gate' | 'unavailable'; + /** The import failure, when `outcome` is `unavailable`. */ + error?: string; +} + +/** What {@link mountMultiNodeGateFromHost} did, and what state it left. */ +export interface MultiNodeGateMountReading { + /** A gate was already registered before any carrier import — nothing tried. */ + alreadyRegistered: boolean; + /** A gate is registered (on this module instance) as this reading returns. */ + registered: boolean; + /** The carrier imports attempted, in order. Empty when `alreadyRegistered`. */ + attempts: readonly MultiNodeGateMountAttempt[]; +} + +/** + * Mount the distribution's multi-node gate on whatever boot route is running, + * by importing the known carrier packages through the CALLER's host importer. + * + * Best-effort by contract: never throws. An absent carrier is the normal + * open-core state; with the gate's fail-closed default, "nothing mounted" + * resolves to a refused multi-node verdict at the consult that follows, so + * this helper does not need to be loud on its own. + * + * @param importFromHost - the boot surface's host-anchored importer (#4719): + * resolves a bare package specifier from the SERVED APP's declaration, the + * only base the distribution packages are installed under. + */ +export async function mountMultiNodeGateFromHost( + importFromHost: (specifier: string) => Promise, +): Promise { + if (hasMultiNodeGate()) { + return { alreadyRegistered: true, registered: true, attempts: [] }; + } + + const attempts: MultiNodeGateMountAttempt[] = []; + for (const carrier of MULTI_NODE_GATE_CARRIER_PACKAGES) { + try { + // Called one specifier at a time, deliberately — never mapped over + // the importer, whose optional extra parameters (e.g. a host root) + // must not receive an array index. + await importFromHost(carrier); + } catch (err) { + attempts.push({ + package: carrier, + outcome: 'unavailable', + error: err instanceof Error ? err.message : String(err), + }); + continue; + } + if (hasMultiNodeGate()) { + attempts.push({ package: carrier, outcome: 'registered' }); + return { alreadyRegistered: false, registered: true, attempts }; + } + attempts.push({ package: carrier, outcome: 'loaded-without-gate' }); + } + return { alreadyRegistered: false, registered: hasMultiNodeGate(), attempts }; +} diff --git a/packages/services/service-cluster/src/multi-node-gate.test.ts b/packages/services/service-cluster/src/multi-node-gate.test.ts index b8f55b16bf..20601d7140 100644 --- a/packages/services/service-cluster/src/multi-node-gate.test.ts +++ b/packages/services/service-cluster/src/multi-node-gate.test.ts @@ -3,16 +3,41 @@ import { describe, it, expect, afterEach } from 'vitest'; import { registerMultiNodeGate, checkMultiNodeAllowed, + hasMultiNodeGate, + MULTI_NODE_NO_GATE_REASON, __resetMultiNodeGate, } from './multi-node-gate.js'; afterEach(() => __resetMultiNodeGate()); describe('multi-node gate', () => { - it('allows when no gate is registered (open framework)', () => { + it('allows when no gate is registered and no count is declared', () => { + // #13537: the no-gate default is fail-closed only for a DECLARED + // multi-node topology. An undeclared count states no topology, so the + // historical allow stands (single-replica path unchanged). expect(checkMultiNodeAllowed()).toEqual({ allowed: true, refused: 0, capped: false }); }); + it('allows a single declared replica with no gate registered', () => { + // The single-replica negative (#13537): one replica declares no + // multi-node topology, so an unregistered gate must not refuse it. + expect(checkMultiNodeAllowed(1)).toEqual({ allowed: true, refused: 0, capped: false }); + }); + + it('refuses a declared multi-node topology when no gate is registered', () => { + // #13537 — the default direction itself: an unregistered gate must not + // silently mean "permitted" on the licensed capability. Smallest + // multi-node count first, same verdict shape as a registered gate's + // outright denial (no third branch for consumers). + expect(checkMultiNodeAllowed(2)).toEqual({ + allowed: false, + reason: MULTI_NODE_NO_GATE_REASON, + admitted: 0, + refused: 2, + capped: false, + }); + }); + it('honors a denying gate with reason', () => { registerMultiNodeGate({ allowMultiNode: () => ({ allowed: false, reason: 'unlicensed' }) }); expect(checkMultiNodeAllowed()).toEqual({ @@ -35,10 +60,19 @@ describe('multi-node gate', () => { expect(checkMultiNodeAllowed().allowed).toBe(true); }); - it('reset restores open default', () => { + it('reset restores the unregistered default', () => { registerMultiNodeGate({ allowMultiNode: () => ({ allowed: false }) }); __resetMultiNodeGate(); expect(checkMultiNodeAllowed()).toEqual({ allowed: true, refused: 0, capped: false }); + expect(checkMultiNodeAllowed(3).allowed).toBe(false); + }); + + it('reports whether a gate is registered on this module instance', () => { + expect(hasMultiNodeGate()).toBe(false); + registerMultiNodeGate({ allowMultiNode: () => ({ allowed: true }) }); + expect(hasMultiNodeGate()).toBe(true); + __resetMultiNodeGate(); + expect(hasMultiNodeGate()).toBe(false); }); }); @@ -117,8 +151,48 @@ describe('multi-node gate: count-carrying admission', () => { }); }); - it('treats the open framework (no gate) as uncapped for any count', () => { - expect(checkMultiNodeAllowed(9)).toEqual({ allowed: true, refused: 0, capped: false }); + it('refuses every declared count above one when no gate is registered', () => { + // #13537 flipped this pin's direction: this exact call used to be the + // "open framework is uncapped for any count" default-ALLOW pin. An + // unregistered gate now refuses the whole declared topology, and the + // refusal carries the counts (`admitted: 0`, everything refused) so + // the operator surfaces stay coherent. + expect(checkMultiNodeAllowed(9)).toEqual({ + allowed: false, + reason: MULTI_NODE_NO_GATE_REASON, + admitted: 0, + refused: 9, + capped: false, + }); + }); + + it('keeps meaningless declared counts on the allow path with no gate', () => { + // `OS_CLUSTER_REPLICAS` unset (`Number(undefined)` = NaN), zero or + // negative all normalize to "not declared" — none states a multi-node + // topology, so none may trip the fail-closed default (#13537). + for (const requested of [Number.NaN, 0, -1]) { + expect(checkMultiNodeAllowed(requested)).toEqual({ + allowed: true, + refused: 0, + capped: false, + }); + } + }); + + it('never blocks a properly-entitled deployment: the new default engages only with NO gate', () => { + // The entitled negative (#13537): a registered gate whose cap covers + // the declared count answers exactly as before — the fail-closed + // branch is unreachable the moment a gate is registered. + registerMultiNodeGate({ + allowMultiNode: () => ({ allowed: true, reason: 'licensed', admitted: 5 }), + }); + expect(checkMultiNodeAllowed(3)).toEqual({ + allowed: true, + reason: 'licensed', + admitted: 3, + refused: 0, + capped: false, + }); }); it('normalizes a degenerate admitted count from a third-party gate', () => { diff --git a/packages/services/service-cluster/src/multi-node-gate.ts b/packages/services/service-cluster/src/multi-node-gate.ts index 7acbe92137..65ffad0187 100644 --- a/packages/services/service-cluster/src/multi-node-gate.ts +++ b/packages/services/service-cluster/src/multi-node-gate.ts @@ -3,12 +3,39 @@ /** * Multi-node authorization gate (open mechanism). * - * The open framework ships **no gate** — multi-node is always allowed. A + * The open framework ships **no gate** — and with no gate registered, a + * DECLARED multi-node topology (`requested > 1`) is **refused** (#13537). A * distribution (e.g. the Enterprise Edition) registers a gate to authorize * whether the runtime may enable a multi-node (remote-driver) topology — for * example, an EE license check. The framework deliberately knows nothing about * *why* a gate allows or denies; it only consults the registered decision. * + * ## Why an unregistered gate fails CLOSED for multi-node (#13537) + * + * Multi-node is a **paid capability** (maintainer ruling 2026-08-31, recorded + * on #13537: cloud#1741 affirms multi-node as a paid EE roadmap capability, + * with the reference remote driver shipping in the licensed image). The + * previous default — allow when no gate is registered — meant every boot + * route on which the distribution's registration code did not happen to + * execute ran an unlicensed cluster silently: measured on a real + * thin-extension EE deployment, a `maxNodes: 1` trial license booted 3 + * replicas with full cluster coordination and no warning (cloud#1752). An + * unregistered gate must not silently mean "permitted" on a licensed + * capability, so the default direction is now: + * + * - `requested > 1` with no gate → **refused** (the caller downgrades to + * single-node and warns — never bricks; see below); + * - `requested` absent or `1` with no gate → allowed, exactly as before. + * A single-replica process declares no multi-node topology, so there is + * nothing for a licence to gate. This keeps the refusal keyed to what the + * operator DECLARED — the same conservative path-A posture as + * `split-brain-guard.ts`, whose `declaresMultiNode` is `replicas > 1`. + * + * The registration side of the same repair is `multi-node-gate-mount.ts`: + * a boot surface hands its host importer to `mountMultiNodeGateFromHost` so + * the distribution's gate is loaded on EVERY boot route, instead of only on + * the route where one particular app config file happens to execute. + * * When a gate denies, the caller (e.g. `os serve`) **downgrades to single-node** * rather than failing — multi-node is an add-on, not a precondition for the * runtime to serve. This is distinct from the split-brain guard, which throws @@ -170,6 +197,29 @@ export function registerMultiNodeGate(gate: MultiNodeGate): void { registered = gate; } +/** + * Whether a gate is currently registered **on this module instance**. + * + * The qualifier is load-bearing: this registry is module-level singleton + * state, so a process that holds two instances of this package (e.g. the + * CJS/ESM dual-instance split, #13330) holds two independent answers. A + * consumer asking "did registration reach the instance I will consult?" must + * ask through the same import it consults through — which is exactly how + * `mountMultiNodeGateFromHost` uses it. + */ +export function hasMultiNodeGate(): boolean { + return registered !== undefined; +} + +/** + * The `reason` a no-gate refusal carries (#13537). Exported so a distribution + * or boot surface can recognize this refusal class without string-matching a + * sentence it does not own — e.g. a container entrypoint that discloses a + * reduced surface (cloud#1752) can name the unmounted gate precisely. + */ +export const MULTI_NODE_NO_GATE_REASON = + 'no multi-node authorization gate is registered — multi-node is a licensed capability and defaults closed'; + /** A positive, finite, whole node count — or `undefined` for "not declared". */ function normalizeCount(value: number | undefined): number | undefined { if (typeof value !== 'number' || !Number.isFinite(value) || value <= 0) return undefined; @@ -177,8 +227,10 @@ function normalizeCount(value: number | undefined): number | undefined { } /** - * Resolve the multi-node decision. With no gate registered (open framework), - * multi-node is allowed and uncapped. + * Resolve the multi-node decision. With no gate registered, a declared + * multi-node topology (`requested > 1`) is **refused** (#13537 — see the + * module doc's fail-closed section); a single-replica or undeclared count + * stays allowed and uncapped. * * @param requested - How many nodes the caller intends to run, when it knows. * Omit when no count is available; the verdict is then reported uncapped @@ -191,7 +243,24 @@ function normalizeCount(value: number | undefined): number | undefined { export function checkMultiNodeAllowed(requested?: number): ResolvedMultiNodeVerdict { const wanted = normalizeCount(requested); - if (!registered) return { allowed: true, refused: 0, capped: false }; + if (!registered) { + // #13537 — fail closed on the licensed capability: a DECLARED + // multi-node topology with no gate registered is refused outright, + // in the same shape as a registered gate's outright denial so no + // consumer needs a third branch. Undeclared / single-replica input + // (`wanted` undefined or 1) keeps the historical allow: it declares + // no multi-node topology, so there is nothing to gate. + if (wanted !== undefined && wanted > 1) { + return { + allowed: false, + reason: MULTI_NODE_NO_GATE_REASON, + admitted: 0, + refused: wanted, + capped: false, + }; + } + return { allowed: true, refused: 0, capped: false }; + } const verdict = registered.allowMultiNode(wanted); From 30c2d3d02383b351ae3f34a868793dbbcffbc25e Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 1 Sep 2026 05:21:57 +0000 Subject: [PATCH 2/3] chore(changeset): ADR-0087 disposition for the gate default change Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_016ZC5rNQj3WEet5HAmmAkMs --- .changeset/multi-node-gate-fail-closed-mount.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.changeset/multi-node-gate-fail-closed-mount.md b/.changeset/multi-node-gate-fail-closed-mount.md index 9d06da0a70..7b449d425b 100644 --- a/.changeset/multi-node-gate-fail-closed-mount.md +++ b/.changeset/multi-node-gate-fail-closed-mount.md @@ -45,3 +45,5 @@ unlicensed multi-node topology on the old fail-open default; it now downgrades to single-node at boot and logs the refusal. Deploy a distribution that registers the gate (at module load of a carrier package, so every boot route mounts it), or remove the multi-node declaration. + + From 1ba418ebf41441fbda52fbef3b6e89b43437c6c0 Mon Sep 17 00:00:00 2001 From: Jack Zhuang <50353452+hotlong@users.noreply.github.com> Date: Tue, 1 Sep 2026 18:29:28 +0800 Subject: [PATCH 3/3] test(service-cluster): pin what a no-gate denial actually does at boot (#14116) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../src/multi-node-gate.test.ts | 52 +++++++++++++++++++ 1 file changed, 52 insertions(+) diff --git a/packages/services/service-cluster/src/multi-node-gate.test.ts b/packages/services/service-cluster/src/multi-node-gate.test.ts index 20601d7140..2caee92081 100644 --- a/packages/services/service-cluster/src/multi-node-gate.test.ts +++ b/packages/services/service-cluster/src/multi-node-gate.test.ts @@ -7,6 +7,11 @@ import { MULTI_NODE_NO_GATE_REASON, __resetMultiNodeGate, } from './multi-node-gate.js'; +// #14116 — the boot-outcome block at the bottom of this file needs the two +// pieces `os serve` reaches after a denial, so it can pin the real chain +// instead of restating the doc. +import { defineCluster } from './cluster.js'; +import { assertClusterDriverSafeForTopology } from './split-brain-guard.js'; afterEach(() => __resetMultiNodeGate()); @@ -258,3 +263,50 @@ describe('multi-node gate: existing boolean-shaped provider', () => { expect(verdict.reason).toBe('license does not include clustering'); }); }); + +// --------------------------------------------------------------------------- +// #14116 — what a denial ACTUALLY does at boot. +// +// The module doc used to promise "the caller downgrades to single-node — never +// bricks". It does not, and this block pins the corrected statement so the +// promise cannot quietly come back. The fail-closed default's trigger +// (`requested > 1`) is the SAME operator declaration the split-brain guard +// keys off, so on the deployment shape that can reach the new refusal at all, +// the boot outcome is a REFUSAL — which is correct, because the alternative is +// N replicas each holding a per-process lock. +// +// Composed from the real pieces `os serve` leaves behind on a denial: +// `clusterConfig` unset ⇒ `Runtime` builds `ClusterServicePlugin({})` ⇒ +// `defineCluster({})` ⇒ the `memory` driver ⇒ the guard is asked about it. +describe('#14116 — the boot outcome of a no-gate denial', () => { + it('the fail-closed refusal and the split-brain guard fire on the SAME declaration', () => { + // Gate side: refused, because a multi-node topology was declared. + expect(checkMultiNodeAllowed(3).allowed).toBe(false); + // Guard side: the in-process driver serve falls back to is refused for + // that same declaration ⇒ the boot stops. Not a silent degrade. + expect(() => + assertClusterDriverSafeForTopology(defineCluster({}).driver, { + OS_CLUSTER_REPLICAS: '3', + }), + ).toThrow(/multi-node deployment declared/); + }); + + it('one replica is the genuine downgrade case — gate allows, guard stays quiet', () => { + // With nothing declared there is no topology to gate and nothing for + // the guard to refuse, so a denial here really would degrade rather + // than refuse. Pinned so the two cases are never conflated again. + expect(checkMultiNodeAllowed(1).allowed).toBe(true); + expect(() => + assertClusterDriverSafeForTopology(defineCluster({}).driver, { + OS_CLUSTER_REPLICAS: '1', + }), + ).not.toThrow(); + }); + + it('the in-process driver serve falls back to is the one the guard refuses', () => { + // Pins the link in the chain that makes the two blocks above one story: + // `Runtime`'s default cluster options are `{}`, and `defineCluster({})` + // resolves the `memory` driver. + expect(defineCluster({}).driver).toBe('memory'); + }); +});