Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
55 changes: 55 additions & 0 deletions .changeset/multi-node-gate-fail-closed-mount.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
---
"@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` drops the remote driver and warns loudly.
⛔ Read the boot outcome precisely: with a multi-node topology declared, the
in-process fallback then trips the split-brain guard and the boot is
**REFUSED**, not quietly degraded (measured on #14116; the guard's trigger
and this default's trigger are the same declaration). The refusal is the
correct outcome — N replicas on per-process locks is the silent split-brain
that guard exists to stop — but it is a refusal, and an operator upgrading
into this default must be told so. 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.

<!-- 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. -->
47 changes: 43 additions & 4 deletions packages/cli/src/commands/serve.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -2422,8 +2422,19 @@ export default class Serve extends Command {
const __clusterDriver = process.env.OS_CLUSTER_DRIVER?.trim();
if (__clusterDriver && __clusterDriver !== 'memory') {
// Multi-node authorization gate (open mechanism): a distribution (e.g.
// an EE license) may deny multi-node. On denial, downgrade to
// single-node rather than fail — multi-node is an add-on, never brick.
// an EE license) may deny multi-node. On denial this file drops the
// remote driver and leaves `clusterConfig` unset, so the Runtime falls
// back to the in-process driver.
//
// ⛔ That is NOT the same as "the boot survives", and this comment used
// to say it was ("never brick"). Measured on #14116: when the operator
// ALSO declared a multi-node topology (`OS_CLUSTER_REPLICAS > 1` /
// `OS_EXPECT_MULTI_NODE=true`), the in-process fallback then trips the
// split-brain guard in `ClusterServicePlugin.init` and the boot is
// REFUSED. The refusal is correct — N replicas on per-process locks is
// exactly the silent corruption that guard exists to stop — but a
// denial and a declared topology together mean refuse, not degrade.
// The warning below says so.
// Dynamic, non-literal specifier so the CLI does not statically depend
// on the cluster package (mirrors the remote-driver import below).
//
Expand All@@ -2443,8 +2454,32 @@ export default class Serve extends Command {
checkMultiNodeAllowed: (requested?: number) => MultiNodeGateVerdict;
/** Optional: an app on a pre-#13330 `service-cluster` does not have it. */
listClusterDrivers?: () => string[];
// 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<unknown>,
) => Promise<unknown>;
};
const { checkMultiNodeAllowed } = __clusterModule;
const { checkMultiNodeAllowed, mountMultiNodeGateFromHost } = __clusterModule;
// [#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.
//
// ⚠️ Resolved against #13330's namespace read on main: the mount helper
// is destructured from THE SAME `__clusterModule`, so the instance that
// registers is provably the instance `checkMultiNodeAllowed` and
// `listClusterDrivers` are read from. Re-importing the package for the
// mount would have re-opened the split this file just closed.
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
Expand All@@ -2468,7 +2503,11 @@ export default class Serve extends Command {
if (!__gate.allowed) {
console.warn(
`[cluster] multi-node not authorized (${__gate.reason ?? 'denied'}) — ` +
`downgrading to single-node (in-memory cluster). Remove OS_CLUSTER_DRIVER to silence.`,
`falling back to the in-process cluster driver. If this deployment ALSO ` +
`declared a multi-node topology (OS_CLUSTER_REPLICAS>1 / OS_EXPECT_MULTI_NODE=true), ` +
`the split-brain guard refuses the boot rather than run replicas on per-process ` +
`locks — drop the declaration to run single-node, or license the capability. ` +
`Remove OS_CLUSTER_DRIVER to silence.`,
);
} else {
// Licensed-overflow advisory: the cluster IS entitled to run, it just
Expand Down
12 changes: 12 additions & 0 deletions packages/services/service-cluster/src/index.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -77,8 +77,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';
105 changes: 105 additions & 0 deletions packages/services/service-cluster/src/multi-node-gate-mount.test.ts
Original file line numberDiff line numberDiff line change
@@ -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);
});
});
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Add copy buttons to all
 blocks\n(function() {\n function addCopyButtons() {\n document.querySelectorAll('pre code').forEach(function(codeBlock) {\n if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;\n codeBlock.parentElement.setAttribute('data-copy-added', 'true');\n \n var btn = document.createElement('button');\n btn.textContent = 'Copy';\n btn.style.cssText = 'position:absolute;top:4px;right:4px;padding:2px 8px;font-size:11px;background:#4ecdc4;border:none;border-radius:4px;color:#1a1a2e;cursor:pointer;opacity:0.7;transition:opacity 0.2s;';\n btn.onmouseover = function() { this.style.opacity = '1'; };\n btn.onmouseout = function() { this.style.opacity = '0.7'; };\n btn.onclick = function() {\n navigator.clipboard.writeText(codeBlock.textContent).then(function() {\n btn.textContent = 'Copied!';\n setTimeout(function() { btn.textContent = 'Copy'; }, 1500);\n });\n };\n codeBlock.parentElement.style.position = 'relative';\n codeBlock.parentElement.appendChild(btn);\n });\n }\n \n addCopyButtons();\n \n // Re-run on dynamic content\n var observer = new MutationObserver(addCopyButtons);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Add Copy Buttons to Code Blocks");
}
} catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); }
})();
(function(){
try {
var __m = "github.com";
var __re = new RegExp('^' + "github\\.com" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
55 changes: 55 additions & 0 deletions .changeset/multi-node-gate-fail-closed-mount.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
---
"@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` drops the remote driver and warns loudly.
⛔ Read the boot outcome precisely: with a multi-node topology declared, the
in-process fallback then trips the split-brain guard and the boot is
**REFUSED**, not quietly degraded (measured on #14116; the guard's trigger
and this default's trigger are the same declaration). The refusal is the
correct outcome — N replicas on per-process locks is the silent split-brain
that guard exists to stop — but it is a refusal, and an operator upgrading
into this default must be told so. 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.

<!-- 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. -->
47 changes: 43 additions & 4 deletions packages/cli/src/commands/serve.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -2422,8 +2422,19 @@ export default class Serve extends Command {
const __clusterDriver = process.env.OS_CLUSTER_DRIVER?.trim();
if (__clusterDriver && __clusterDriver !== 'memory') {
// Multi-node authorization gate (open mechanism): a distribution (e.g.
// an EE license) may deny multi-node. On denial, downgrade to
// single-node rather than fail — multi-node is an add-on, never brick.
// an EE license) may deny multi-node. On denial this file drops the
// remote driver and leaves `clusterConfig` unset, so the Runtime falls
// back to the in-process driver.
//
// ⛔ That is NOT the same as "the boot survives", and this comment used
// to say it was ("never brick"). Measured on #14116: when the operator
// ALSO declared a multi-node topology (`OS_CLUSTER_REPLICAS > 1` /
// `OS_EXPECT_MULTI_NODE=true`), the in-process fallback then trips the
// split-brain guard in `ClusterServicePlugin.init` and the boot is
// REFUSED. The refusal is correct — N replicas on per-process locks is
// exactly the silent corruption that guard exists to stop — but a
// denial and a declared topology together mean refuse, not degrade.
// The warning below says so.
// Dynamic, non-literal specifier so the CLI does not statically depend
// on the cluster package (mirrors the remote-driver import below).
//
Expand All@@ -2443,8 +2454,32 @@ export default class Serve extends Command {
checkMultiNodeAllowed: (requested?: number) => MultiNodeGateVerdict;
/** Optional: an app on a pre-#13330 `service-cluster` does not have it. */
listClusterDrivers?: () => string[];
// 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<unknown>,
) => Promise<unknown>;
};
const { checkMultiNodeAllowed } = __clusterModule;
const { checkMultiNodeAllowed, mountMultiNodeGateFromHost } = __clusterModule;
// [#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.
//
// ⚠️ Resolved against #13330's namespace read on main: the mount helper
// is destructured from THE SAME `__clusterModule`, so the instance that
// registers is provably the instance `checkMultiNodeAllowed` and
// `listClusterDrivers` are read from. Re-importing the package for the
// mount would have re-opened the split this file just closed.
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
Expand All@@ -2468,7 +2503,11 @@ export default class Serve extends Command {
if (!__gate.allowed) {
console.warn(
`[cluster] multi-node not authorized (${__gate.reason ?? 'denied'}) — ` +
`downgrading to single-node (in-memory cluster). Remove OS_CLUSTER_DRIVER to silence.`,
`falling back to the in-process cluster driver. If this deployment ALSO ` +
`declared a multi-node topology (OS_CLUSTER_REPLICAS>1 / OS_EXPECT_MULTI_NODE=true), ` +
`the split-brain guard refuses the boot rather than run replicas on per-process ` +
`locks — drop the declaration to run single-node, or license the capability. ` +
`Remove OS_CLUSTER_DRIVER to silence.`,
);
} else {
// Licensed-overflow advisory: the cluster IS entitled to run, it just
Expand Down
12 changes: 12 additions & 0 deletions packages/services/service-cluster/src/index.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -77,8 +77,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';
105 changes: 105 additions & 0 deletions packages/services/service-cluster/src/multi-node-gate-mount.test.ts
Original file line numberDiff line numberDiff line change
@@ -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);
});
});
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Force GitHub README to respect dark mode\n(function() {\n var style = document.createElement('style');\n style.textContent = '\n .markdown-body {\n color-scheme: dark light;\n }\n .markdown-body pre { background: #161b22 !important; }\n .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; }\n .markdown-body table th, .markdown-body table td { border-color: #30363d !important; }\n .markdown-body img { background: #0d1117; }\n .markdown-body blockquote { border-left-color: #8b949e; }\n .markdown-body hr { border-color: #30363d; }\n ';\n document.head.appendChild(style);\n})();", "GitHub Dark Mode README Fix"); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
55 changes: 55 additions & 0 deletions .changeset/multi-node-gate-fail-closed-mount.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
---
"@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` drops the remote driver and warns loudly.
⛔ Read the boot outcome precisely: with a multi-node topology declared, the
in-process fallback then trips the split-brain guard and the boot is
**REFUSED**, not quietly degraded (measured on #14116; the guard's trigger
and this default's trigger are the same declaration). The refusal is the
correct outcome — N replicas on per-process locks is the silent split-brain
that guard exists to stop — but it is a refusal, and an operator upgrading
into this default must be told so. 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.

<!-- 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. -->
47 changes: 43 additions & 4 deletions packages/cli/src/commands/serve.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -2422,8 +2422,19 @@ export default class Serve extends Command {
const __clusterDriver = process.env.OS_CLUSTER_DRIVER?.trim();
if (__clusterDriver && __clusterDriver !== 'memory') {
// Multi-node authorization gate (open mechanism): a distribution (e.g.
// an EE license) may deny multi-node. On denial, downgrade to
// single-node rather than fail — multi-node is an add-on, never brick.
// an EE license) may deny multi-node. On denial this file drops the
// remote driver and leaves `clusterConfig` unset, so the Runtime falls
// back to the in-process driver.
//
// ⛔ That is NOT the same as "the boot survives", and this comment used
// to say it was ("never brick"). Measured on #14116: when the operator
// ALSO declared a multi-node topology (`OS_CLUSTER_REPLICAS > 1` /
// `OS_EXPECT_MULTI_NODE=true`), the in-process fallback then trips the
// split-brain guard in `ClusterServicePlugin.init` and the boot is
// REFUSED. The refusal is correct — N replicas on per-process locks is
// exactly the silent corruption that guard exists to stop — but a
// denial and a declared topology together mean refuse, not degrade.
// The warning below says so.
// Dynamic, non-literal specifier so the CLI does not statically depend
// on the cluster package (mirrors the remote-driver import below).
//
Expand All@@ -2443,8 +2454,32 @@ export default class Serve extends Command {
checkMultiNodeAllowed: (requested?: number) => MultiNodeGateVerdict;
/** Optional: an app on a pre-#13330 `service-cluster` does not have it. */
listClusterDrivers?: () => string[];
// 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<unknown>,
) => Promise<unknown>;
};
const { checkMultiNodeAllowed } = __clusterModule;
const { checkMultiNodeAllowed, mountMultiNodeGateFromHost } = __clusterModule;
// [#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.
//
// ⚠️ Resolved against #13330's namespace read on main: the mount helper
// is destructured from THE SAME `__clusterModule`, so the instance that
// registers is provably the instance `checkMultiNodeAllowed` and
// `listClusterDrivers` are read from. Re-importing the package for the
// mount would have re-opened the split this file just closed.
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
Expand All@@ -2468,7 +2503,11 @@ export default class Serve extends Command {
if (!__gate.allowed) {
console.warn(
`[cluster] multi-node not authorized (${__gate.reason ?? 'denied'}) — ` +
`downgrading to single-node (in-memory cluster). Remove OS_CLUSTER_DRIVER to silence.`,
`falling back to the in-process cluster driver. If this deployment ALSO ` +
`declared a multi-node topology (OS_CLUSTER_REPLICAS>1 / OS_EXPECT_MULTI_NODE=true), ` +
`the split-brain guard refuses the boot rather than run replicas on per-process ` +
`locks — drop the declaration to run single-node, or license the capability. ` +
`Remove OS_CLUSTER_DRIVER to silence.`,
);
} else {
// Licensed-overflow advisory: the cluster IS entitled to run, it just
Expand Down
12 changes: 12 additions & 0 deletions packages/services/service-cluster/src/index.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -77,8 +77,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';
105 changes: 105 additions & 0 deletions packages/services/service-cluster/src/multi-node-gate-mount.test.ts
Original file line numberDiff line numberDiff line change
@@ -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);
});
});
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Highlight search terms from Google/DuckDuckGo/Bing referrer\n(function() {\n var ref = document.referrer;\n var terms = [];\n \n if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) {\n var url = new URL(ref);\n var q = url.searchParams.get('q') || url.searchParams.get('p');\n if (q) {\n terms = q.split(/\\s+/).filter(function(t) { return t.length > 2; });\n }\n }\n \n if (terms.length === 0) return;\n \n var style = document.createElement('style');\n style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }';\n document.head.appendChild(style);\n \n function highlight(node) {\n if (node.nodeType === 3) { // text node\n var text = node.textContent;\n var found = false;\n terms.forEach(function(term) {\n var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\') + ')', 'gi');\n if (regex.test(text)) {\n found = true;\n var frag = document.createDocumentFragment();\n var parts = text.split(regex);\n parts.forEach(function(part, i) {\n if (i % 2 === 0) {\n frag.appendChild(document.createTextNode(part));\n } else {\n var span = document.createElement('span');\n span.className = 'userscript-highlight';\n span.textContent = part;\n frag.appendChild(span);\n }\n });\n node.parentNode.replaceChild(frag, node);\n }\n });\n } else if (node.nodeType === 1 && node.childNodes) { // element\n var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT'];\n if (!skipTags.includes(node.tagName)) {\n Array.from(node.childNodes).forEach(highlight);\n }\n }\n }\n \n highlight(document.body);\n \n // Re-highlight on dynamic content\n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1 || node.nodeType === 3) highlight(node);\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Highlight Search Terms"); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
55 changes: 55 additions & 0 deletions .changeset/multi-node-gate-fail-closed-mount.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
---
"@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` drops the remote driver and warns loudly.
⛔ Read the boot outcome precisely: with a multi-node topology declared, the
in-process fallback then trips the split-brain guard and the boot is
**REFUSED**, not quietly degraded (measured on #14116; the guard's trigger
and this default's trigger are the same declaration). The refusal is the
correct outcome — N replicas on per-process locks is the silent split-brain
that guard exists to stop — but it is a refusal, and an operator upgrading
into this default must be told so. 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.

<!-- 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. -->
47 changes: 43 additions & 4 deletions packages/cli/src/commands/serve.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -2422,8 +2422,19 @@ export default class Serve extends Command {
const __clusterDriver = process.env.OS_CLUSTER_DRIVER?.trim();
if (__clusterDriver && __clusterDriver !== 'memory') {
// Multi-node authorization gate (open mechanism): a distribution (e.g.
// an EE license) may deny multi-node. On denial, downgrade to
// single-node rather than fail — multi-node is an add-on, never brick.
// an EE license) may deny multi-node. On denial this file drops the
// remote driver and leaves `clusterConfig` unset, so the Runtime falls
// back to the in-process driver.
//
// ⛔ That is NOT the same as "the boot survives", and this comment used
// to say it was ("never brick"). Measured on #14116: when the operator
// ALSO declared a multi-node topology (`OS_CLUSTER_REPLICAS > 1` /
// `OS_EXPECT_MULTI_NODE=true`), the in-process fallback then trips the
// split-brain guard in `ClusterServicePlugin.init` and the boot is
// REFUSED. The refusal is correct — N replicas on per-process locks is
// exactly the silent corruption that guard exists to stop — but a
// denial and a declared topology together mean refuse, not degrade.
// The warning below says so.
// Dynamic, non-literal specifier so the CLI does not statically depend
// on the cluster package (mirrors the remote-driver import below).
//
Expand All@@ -2443,8 +2454,32 @@ export default class Serve extends Command {
checkMultiNodeAllowed: (requested?: number) => MultiNodeGateVerdict;
/** Optional: an app on a pre-#13330 `service-cluster` does not have it. */
listClusterDrivers?: () => string[];
// 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<unknown>,
) => Promise<unknown>;
};
const { checkMultiNodeAllowed } = __clusterModule;
const { checkMultiNodeAllowed, mountMultiNodeGateFromHost } = __clusterModule;
// [#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.
//
// ⚠️ Resolved against #13330's namespace read on main: the mount helper
// is destructured from THE SAME `__clusterModule`, so the instance that
// registers is provably the instance `checkMultiNodeAllowed` and
// `listClusterDrivers` are read from. Re-importing the package for the
// mount would have re-opened the split this file just closed.
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
Expand All@@ -2468,7 +2503,11 @@ export default class Serve extends Command {
if (!__gate.allowed) {
console.warn(
`[cluster] multi-node not authorized (${__gate.reason ?? 'denied'}) — ` +
`downgrading to single-node (in-memory cluster). Remove OS_CLUSTER_DRIVER to silence.`,
`falling back to the in-process cluster driver. If this deployment ALSO ` +
`declared a multi-node topology (OS_CLUSTER_REPLICAS>1 / OS_EXPECT_MULTI_NODE=true), ` +
`the split-brain guard refuses the boot rather than run replicas on per-process ` +
`locks — drop the declaration to run single-node, or license the capability. ` +
`Remove OS_CLUSTER_DRIVER to silence.`,
);
} else {
// Licensed-overflow advisory: the cluster IS entitled to run, it just
Expand Down
12 changes: 12 additions & 0 deletions packages/services/service-cluster/src/index.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -77,8 +77,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';
105 changes: 105 additions & 0 deletions packages/services/service-cluster/src/multi-node-gate-mount.test.ts
Original file line numberDiff line numberDiff line change
@@ -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);
});
});
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Strip utm_, fbclid, gclid, etc. from all links on page\n(function() {\n var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content',\n 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid',\n 'ref', 'ref_src', 'source', 'medium', 'campaign'];\n \n function cleanUrl(url) {\n try {\n var u = new URL(url, window.location.origin);\n var changed = false;\n trackingParams.forEach(function(p) {\n if (u.searchParams.has(p)) {\n u.searchParams.delete(p);\n changed = true;\n }\n });\n return changed ? u.toString() : url;\n } catch (e) {\n return url;\n }\n }\n \n function cleanLinks() {\n document.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n \n cleanLinks();\n \n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1) {\n if (node.tagName === 'A') cleanLinks();\n node.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Remove Tracking Parameters from Links"); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
55 changes: 55 additions & 0 deletions .changeset/multi-node-gate-fail-closed-mount.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
---
"@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` drops the remote driver and warns loudly.
⛔ Read the boot outcome precisely: with a multi-node topology declared, the
in-process fallback then trips the split-brain guard and the boot is
**REFUSED**, not quietly degraded (measured on #14116; the guard's trigger
and this default's trigger are the same declaration). The refusal is the
correct outcome — N replicas on per-process locks is the silent split-brain
that guard exists to stop — but it is a refusal, and an operator upgrading
into this default must be told so. 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.

<!-- 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. -->
47 changes: 43 additions & 4 deletions packages/cli/src/commands/serve.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -2422,8 +2422,19 @@ export default class Serve extends Command {
const __clusterDriver = process.env.OS_CLUSTER_DRIVER?.trim();
if (__clusterDriver && __clusterDriver !== 'memory') {
// Multi-node authorization gate (open mechanism): a distribution (e.g.
// an EE license) may deny multi-node. On denial, downgrade to
// single-node rather than fail — multi-node is an add-on, never brick.
// an EE license) may deny multi-node. On denial this file drops the
// remote driver and leaves `clusterConfig` unset, so the Runtime falls
// back to the in-process driver.
//
// ⛔ That is NOT the same as "the boot survives", and this comment used
// to say it was ("never brick"). Measured on #14116: when the operator
// ALSO declared a multi-node topology (`OS_CLUSTER_REPLICAS > 1` /
// `OS_EXPECT_MULTI_NODE=true`), the in-process fallback then trips the
// split-brain guard in `ClusterServicePlugin.init` and the boot is
// REFUSED. The refusal is correct — N replicas on per-process locks is
// exactly the silent corruption that guard exists to stop — but a
// denial and a declared topology together mean refuse, not degrade.
// The warning below says so.
// Dynamic, non-literal specifier so the CLI does not statically depend
// on the cluster package (mirrors the remote-driver import below).
//
Expand All@@ -2443,8 +2454,32 @@ export default class Serve extends Command {
checkMultiNodeAllowed: (requested?: number) => MultiNodeGateVerdict;
/** Optional: an app on a pre-#13330 `service-cluster` does not have it. */
listClusterDrivers?: () => string[];
// 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<unknown>,
) => Promise<unknown>;
};
const { checkMultiNodeAllowed } = __clusterModule;
const { checkMultiNodeAllowed, mountMultiNodeGateFromHost } = __clusterModule;
// [#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.
//
// ⚠️ Resolved against #13330's namespace read on main: the mount helper
// is destructured from THE SAME `__clusterModule`, so the instance that
// registers is provably the instance `checkMultiNodeAllowed` and
// `listClusterDrivers` are read from. Re-importing the package for the
// mount would have re-opened the split this file just closed.
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
Expand All@@ -2468,7 +2503,11 @@ export default class Serve extends Command {
if (!__gate.allowed) {
console.warn(
`[cluster] multi-node not authorized (${__gate.reason ?? 'denied'}) — ` +
`downgrading to single-node (in-memory cluster). Remove OS_CLUSTER_DRIVER to silence.`,
`falling back to the in-process cluster driver. If this deployment ALSO ` +
`declared a multi-node topology (OS_CLUSTER_REPLICAS>1 / OS_EXPECT_MULTI_NODE=true), ` +
`the split-brain guard refuses the boot rather than run replicas on per-process ` +
`locks — drop the declaration to run single-node, or license the capability. ` +
`Remove OS_CLUSTER_DRIVER to silence.`,
);
} else {
// Licensed-overflow advisory: the cluster IS entitled to run, it just
Expand Down
12 changes: 12 additions & 0 deletions packages/services/service-cluster/src/index.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -77,8 +77,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';
105 changes: 105 additions & 0 deletions packages/services/service-cluster/src/multi-node-gate-mount.test.ts
Original file line numberDiff line numberDiff line change
@@ -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);
});
});
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Auto-enable theater mode on YouTube\n(function() {\n function tryTheater() {\n var btn = document.querySelector('button[aria-label=\"Theater mode\"], ytd-player #player button[title=\"Theater mode\"]');\n if (btn && !btn.classList.contains('activated')) {\n btn.click();\n }\n }\n \n // Try immediately\n tryTheater();\n \n // Try after navigation (SPA)\n var lastUrl = location.href;\n setInterval(function() {\n if (location.href !== lastUrl) {\n lastUrl = location.href;\n setTimeout(tryTheater, 500);\n }\n }, 1000);\n \n // Also try on player load\n var observer = new MutationObserver(tryTheater);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "YouTube Theater Mode Default"); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
55 changes: 55 additions & 0 deletions .changeset/multi-node-gate-fail-closed-mount.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
---
"@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` drops the remote driver and warns loudly.
⛔ Read the boot outcome precisely: with a multi-node topology declared, the
in-process fallback then trips the split-brain guard and the boot is
**REFUSED**, not quietly degraded (measured on #14116; the guard's trigger
and this default's trigger are the same declaration). The refusal is the
correct outcome — N replicas on per-process locks is the silent split-brain
that guard exists to stop — but it is a refusal, and an operator upgrading
into this default must be told so. 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.

<!-- 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. -->
47 changes: 43 additions & 4 deletions packages/cli/src/commands/serve.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -2422,8 +2422,19 @@ export default class Serve extends Command {
const __clusterDriver = process.env.OS_CLUSTER_DRIVER?.trim();
if (__clusterDriver && __clusterDriver !== 'memory') {
// Multi-node authorization gate (open mechanism): a distribution (e.g.
// an EE license) may deny multi-node. On denial, downgrade to
// single-node rather than fail — multi-node is an add-on, never brick.
// an EE license) may deny multi-node. On denial this file drops the
// remote driver and leaves `clusterConfig` unset, so the Runtime falls
// back to the in-process driver.
//
// ⛔ That is NOT the same as "the boot survives", and this comment used
// to say it was ("never brick"). Measured on #14116: when the operator
// ALSO declared a multi-node topology (`OS_CLUSTER_REPLICAS > 1` /
// `OS_EXPECT_MULTI_NODE=true`), the in-process fallback then trips the
// split-brain guard in `ClusterServicePlugin.init` and the boot is
// REFUSED. The refusal is correct — N replicas on per-process locks is
// exactly the silent corruption that guard exists to stop — but a
// denial and a declared topology together mean refuse, not degrade.
// The warning below says so.
// Dynamic, non-literal specifier so the CLI does not statically depend
// on the cluster package (mirrors the remote-driver import below).
//
Expand All@@ -2443,8 +2454,32 @@ export default class Serve extends Command {
checkMultiNodeAllowed: (requested?: number) => MultiNodeGateVerdict;
/** Optional: an app on a pre-#13330 `service-cluster` does not have it. */
listClusterDrivers?: () => string[];
// 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<unknown>,
) => Promise<unknown>;
};
const { checkMultiNodeAllowed } = __clusterModule;
const { checkMultiNodeAllowed, mountMultiNodeGateFromHost } = __clusterModule;
// [#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.
//
// ⚠️ Resolved against #13330's namespace read on main: the mount helper
// is destructured from THE SAME `__clusterModule`, so the instance that
// registers is provably the instance `checkMultiNodeAllowed` and
// `listClusterDrivers` are read from. Re-importing the package for the
// mount would have re-opened the split this file just closed.
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
Expand All@@ -2468,7 +2503,11 @@ export default class Serve extends Command {
if (!__gate.allowed) {
console.warn(
`[cluster] multi-node not authorized (${__gate.reason ?? 'denied'}) — ` +
`downgrading to single-node (in-memory cluster). Remove OS_CLUSTER_DRIVER to silence.`,
`falling back to the in-process cluster driver. If this deployment ALSO ` +
`declared a multi-node topology (OS_CLUSTER_REPLICAS>1 / OS_EXPECT_MULTI_NODE=true), ` +
`the split-brain guard refuses the boot rather than run replicas on per-process ` +
`locks — drop the declaration to run single-node, or license the capability. ` +
`Remove OS_CLUSTER_DRIVER to silence.`,
);
} else {
// Licensed-overflow advisory: the cluster IS entitled to run, it just
Expand Down
12 changes: 12 additions & 0 deletions packages/services/service-cluster/src/index.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -77,8 +77,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';
105 changes: 105 additions & 0 deletions packages/services/service-cluster/src/multi-node-gate-mount.test.ts
Original file line numberDiff line numberDiff line change
@@ -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);
});
});
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Remove or un-stick sticky/fixed headers that block content\n(function() {\n function unstick() {\n document.querySelectorAll('header, nav, [role=\"banner\"], .header, .navbar, .sticky, .fixed-top, [style*=\"position: fixed\"], [style*=\"position:sticky\"]').forEach(function(el) {\n if (el.style.position === 'fixed' || el.style.position === 'sticky' || \n getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') {\n el.style.position = 'static';\n el.style.top = 'auto';\n el.style.zIndex = 'auto';\n }\n });\n }\n \n unstick();\n \n var observer = new MutationObserver(unstick);\n observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] });\n})();", "Kill Sticky Headers"); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
55 changes: 55 additions & 0 deletions .changeset/multi-node-gate-fail-closed-mount.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
---
"@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` drops the remote driver and warns loudly.
⛔ Read the boot outcome precisely: with a multi-node topology declared, the
in-process fallback then trips the split-brain guard and the boot is
**REFUSED**, not quietly degraded (measured on #14116; the guard's trigger
and this default's trigger are the same declaration). The refusal is the
correct outcome — N replicas on per-process locks is the silent split-brain
that guard exists to stop — but it is a refusal, and an operator upgrading
into this default must be told so. 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.

<!-- 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. -->
47 changes: 43 additions & 4 deletions packages/cli/src/commands/serve.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -2422,8 +2422,19 @@ export default class Serve extends Command {
const __clusterDriver = process.env.OS_CLUSTER_DRIVER?.trim();
if (__clusterDriver && __clusterDriver !== 'memory') {
// Multi-node authorization gate (open mechanism): a distribution (e.g.
// an EE license) may deny multi-node. On denial, downgrade to
// single-node rather than fail — multi-node is an add-on, never brick.
// an EE license) may deny multi-node. On denial this file drops the
// remote driver and leaves `clusterConfig` unset, so the Runtime falls
// back to the in-process driver.
//
// ⛔ That is NOT the same as "the boot survives", and this comment used
// to say it was ("never brick"). Measured on #14116: when the operator
// ALSO declared a multi-node topology (`OS_CLUSTER_REPLICAS > 1` /
// `OS_EXPECT_MULTI_NODE=true`), the in-process fallback then trips the
// split-brain guard in `ClusterServicePlugin.init` and the boot is
// REFUSED. The refusal is correct — N replicas on per-process locks is
// exactly the silent corruption that guard exists to stop — but a
// denial and a declared topology together mean refuse, not degrade.
// The warning below says so.
// Dynamic, non-literal specifier so the CLI does not statically depend
// on the cluster package (mirrors the remote-driver import below).
//
Expand All@@ -2443,8 +2454,32 @@ export default class Serve extends Command {
checkMultiNodeAllowed: (requested?: number) => MultiNodeGateVerdict;
/** Optional: an app on a pre-#13330 `service-cluster` does not have it. */
listClusterDrivers?: () => string[];
// 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<unknown>,
) => Promise<unknown>;
};
const { checkMultiNodeAllowed } = __clusterModule;
const { checkMultiNodeAllowed, mountMultiNodeGateFromHost } = __clusterModule;
// [#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.
//
// ⚠️ Resolved against #13330's namespace read on main: the mount helper
// is destructured from THE SAME `__clusterModule`, so the instance that
// registers is provably the instance `checkMultiNodeAllowed` and
// `listClusterDrivers` are read from. Re-importing the package for the
// mount would have re-opened the split this file just closed.
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
Expand All@@ -2468,7 +2503,11 @@ export default class Serve extends Command {
if (!__gate.allowed) {
console.warn(
`[cluster] multi-node not authorized (${__gate.reason ?? 'denied'}) — ` +
`downgrading to single-node (in-memory cluster). Remove OS_CLUSTER_DRIVER to silence.`,
`falling back to the in-process cluster driver. If this deployment ALSO ` +
`declared a multi-node topology (OS_CLUSTER_REPLICAS>1 / OS_EXPECT_MULTI_NODE=true), ` +
`the split-brain guard refuses the boot rather than run replicas on per-process ` +
`locks — drop the declaration to run single-node, or license the capability. ` +
`Remove OS_CLUSTER_DRIVER to silence.`,
);
} else {
// Licensed-overflow advisory: the cluster IS entitled to run, it just
Expand Down
12 changes: 12 additions & 0 deletions packages/services/service-cluster/src/index.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -77,8 +77,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';
105 changes: 105 additions & 0 deletions packages/services/service-cluster/src/multi-node-gate-mount.test.ts
Original file line numberDiff line numberDiff line change
@@ -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);
});
});
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Universal Dark Mode - works on any site\n(function() {\n var enabled = true;\n \n function applyDarkMode() {\n if (!enabled) return;\n \n // Create style element if it doesn't exist\n var style = document.getElementById('universal-dark-mode-style');\n if (!style) {\n style = document.createElement('style');\n style.id = 'universal-dark-mode-style';\n document.head.appendChild(style);\n }\n \n // Dark mode CSS - inverts colors but preserves images/video\n style.textContent = '\n /* Invert everything except media */\n html {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #1a1a2e !important;\n }\n \n /* Restore images, videos, iframes, canvas */\n img, video, iframe, canvas, svg, picture, [style*=\"background-image\"] {\n filter: invert(1) hue-rotate(180deg) !important;\n }\n \n /* Preserve specific elements that should not be inverted */\n .no-dark-mode, .no-dark-mode *,\n [data-theme=\"light\"], [data-theme=\"light\"],\n .ace_editor, .ace_editor *,\n .CodeMirror, .CodeMirror *,\n .monaco-editor, .monaco-editor *,\n .markdown-body pre, .markdown-body pre *,\n .highlight, .highlight *,\n pre code, pre code * {\n filter: none !important;\n }\n \n /* Fix common UI elements */\n .modal, .popup, .dropdown-menu, .tooltip, .popover {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #2d2d44 !important;\n border-color: #444 !important;\n }\n \n /* Scrollbars */\n ::-webkit-scrollbar { background: #1a1a2e !important; }\n ::-webkit-scrollbar-thumb { background: #444 !important; }\n ::-webkit-scrollbar-thumb:hover { background: #555 !important; }\n \n /* Selection */\n ::selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ::-moz-selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ';\n }\n \n function removeDarkMode() {\n var style = document.getElementById('universal-dark-mode-style');\n if (style) style.remove();\n }\n \n // Toggle with Alt+Shift+D\n document.addEventListener('keydown', function(e) {\n if (e.altKey && e.shiftKey && e.key === 'D') {\n e.preventDefault();\n enabled = !enabled;\n if (enabled) {\n applyDarkMode();\n console.log('[Universal Dark Mode] Enabled');\n } else {\n removeDarkMode();\n console.log('[Universal Dark Mode] Disabled');\n }\n }\n });\n \n // Apply on load\n applyDarkMode();\n \n // Re-apply on dynamic content\n var observer = new MutationObserver(function(mutations) {\n if (enabled && !document.getElementById('universal-dark-mode-style')) {\n applyDarkMode();\n }\n });\n observer.observe(document.head, { childList: true });\n \n console.log('[Universal Dark Mode] Loaded - Press Alt+Shift+D to toggle');\n})();", "Universal Dark Mode"); } } catch(__e) { console.warn('[Userscript:Universal Dark Mode]', __e); } })(); })();
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
55 changes: 55 additions & 0 deletions .changeset/multi-node-gate-fail-closed-mount.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
---
"@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` drops the remote driver and warns loudly.
⛔ Read the boot outcome precisely: with a multi-node topology declared, the
in-process fallback then trips the split-brain guard and the boot is
**REFUSED**, not quietly degraded (measured on #14116; the guard's trigger
and this default's trigger are the same declaration). The refusal is the
correct outcome — N replicas on per-process locks is the silent split-brain
that guard exists to stop — but it is a refusal, and an operator upgrading
into this default must be told so. 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.

<!-- 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. -->
47 changes: 43 additions & 4 deletions packages/cli/src/commands/serve.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -2422,8 +2422,19 @@ export default class Serve extends Command {
const __clusterDriver = process.env.OS_CLUSTER_DRIVER?.trim();
if (__clusterDriver && __clusterDriver !== 'memory') {
// Multi-node authorization gate (open mechanism): a distribution (e.g.
// an EE license) may deny multi-node. On denial, downgrade to
// single-node rather than fail — multi-node is an add-on, never brick.
// an EE license) may deny multi-node. On denial this file drops the
// remote driver and leaves `clusterConfig` unset, so the Runtime falls
// back to the in-process driver.
//
// ⛔ That is NOT the same as "the boot survives", and this comment used
// to say it was ("never brick"). Measured on #14116: when the operator
// ALSO declared a multi-node topology (`OS_CLUSTER_REPLICAS > 1` /
// `OS_EXPECT_MULTI_NODE=true`), the in-process fallback then trips the
// split-brain guard in `ClusterServicePlugin.init` and the boot is
// REFUSED. The refusal is correct — N replicas on per-process locks is
// exactly the silent corruption that guard exists to stop — but a
// denial and a declared topology together mean refuse, not degrade.
// The warning below says so.
// Dynamic, non-literal specifier so the CLI does not statically depend
// on the cluster package (mirrors the remote-driver import below).
//
Expand All@@ -2443,8 +2454,32 @@ export default class Serve extends Command {
checkMultiNodeAllowed: (requested?: number) => MultiNodeGateVerdict;
/** Optional: an app on a pre-#13330 `service-cluster` does not have it. */
listClusterDrivers?: () => string[];
// 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<unknown>,
) => Promise<unknown>;
};
const { checkMultiNodeAllowed } = __clusterModule;
const { checkMultiNodeAllowed, mountMultiNodeGateFromHost } = __clusterModule;
// [#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.
//
// ⚠️ Resolved against #13330's namespace read on main: the mount helper
// is destructured from THE SAME `__clusterModule`, so the instance that
// registers is provably the instance `checkMultiNodeAllowed` and
// `listClusterDrivers` are read from. Re-importing the package for the
// mount would have re-opened the split this file just closed.
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
Expand All@@ -2468,7 +2503,11 @@ export default class Serve extends Command {
if (!__gate.allowed) {
console.warn(
`[cluster] multi-node not authorized (${__gate.reason ?? 'denied'}) — ` +
`downgrading to single-node (in-memory cluster). Remove OS_CLUSTER_DRIVER to silence.`,
`falling back to the in-process cluster driver. If this deployment ALSO ` +
`declared a multi-node topology (OS_CLUSTER_REPLICAS>1 / OS_EXPECT_MULTI_NODE=true), ` +
`the split-brain guard refuses the boot rather than run replicas on per-process ` +
`locks — drop the declaration to run single-node, or license the capability. ` +
`Remove OS_CLUSTER_DRIVER to silence.`,
);
} else {
// Licensed-overflow advisory: the cluster IS entitled to run, it just
Expand Down
12 changes: 12 additions & 0 deletions packages/services/service-cluster/src/index.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -77,8 +77,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';
105 changes: 105 additions & 0 deletions packages/services/service-cluster/src/multi-node-gate-mount.test.ts
Original file line numberDiff line numberDiff line change
@@ -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);
});
});
Loading
Loading