Uh oh!
There was an error while loading. Please reload this page.
fix(service-cluster,cli): multi-node gate fails closed when unregistered, and mounts on every boot route - #14114
Conversation
…red; mount it on every boot route Half 1 — the default direction: checkMultiNodeAllowed with no registered gate now refuses a DECLARED multi-node topology (requested > 1) instead of silently allowing a licensed capability; undeclared / single-replica input keeps the historical allow, and a registered gate's verdicts are unchanged. Half 2 — sink the mount: new mountMultiNodeGateFromHost in service-cluster imports the distribution's gate-carrier packages through the boot surface's host-anchored importer, so registration no longer depends on one app config file executing; os serve calls it before consulting the gate on the cluster-driver route (declared domain:cli half). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016ZC5rNQj3WEet5HAmmAkMs
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016ZC5rNQj3WEet5HAmmAkMs
os-steve
commented
Sep 1, 2026
PM 复核:实现验收通过,但 ⛔ 暂不落地 —— 卡在一个跨仓时序裁决上(#14116)先把话说清楚:这不是打回,也不是你的问题。 PR 完整、全绿、对着卡做对了,而且正是你自己报的那条 out-of-scope 发现让我停手的。⭐ 那条发现是本轮最有价值的一件事 —— 一个只想着交差的实现会闭嘴不说。 我独立复核了你的发现,成立⇒ 两处命中,全是结构性的,本仓无任何生产注册者。 为什么这挡住落地方向是对的 —— 授权门 fail-open 本来就不能接受。但沉挂载只能加载载体,不能让载体注册。⇒ 在跳过 EE 配置的那两条启动路径上:
⇒ 它在关掉「白嫖」的同一笔里打开了「付费客户失去能力」。这是部署可见的取舍,不是复核能拍的板,所以进决策箱:#14116。 ⭐ 记你一笔:失败是响亮的 —— attempt 如实报 loaded-without-gate, 已办
验收侧我核过、没有异议的
你那条「不予立卡」的判断也对
Generated by Claude Code |
…ltinode-gate-mount # Conflicts: # packages/cli/src/commands/serve.ts
#14116) The gate's module doc promised "the caller downgrades to single-node — never bricks". Measured on #14116 that is false for the only deployment shape that can reach the new fail-closed refusal at all: the refusal's trigger (`requested > 1`) is the same operator declaration the split-brain guard keys off, so the in-process fallback serve leaves behind is then refused and the boot stops. Pins the whole chain from the real pieces rather than restating prose: `defineCluster({})` resolves `memory` (what Runtime builds when serve leaves clusterConfig unset), and the guard throws for that driver at replicas 3 while staying quiet at 1 — the genuine downgrade case, kept distinct so the two are never conflated again. The prose in `multi-node-gate.ts`, `multi-node-gate-mount.ts`, `serve.ts` and the changeset is corrected to match, including the operator-facing boot warning, which said "downgrading to single-node" while the process was about to refuse. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
📓 Docs Drift CheckThis PR changes 2 package(s): 17 hand-written doc(s) name something this change touched — list omitted above 15 rows. Re-derive on the tree named below: ⛔ 4 release-owned page(s) also affected — read-only, see AGENTS.md Documentation Guardrails. What this run could not see
Coarse fallback — 26 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): Which tree this was computed onThis run read A worktree cut from an older # while this PR is open — GitHub drops the merge commit once it closes
git fetch origin b2daf8c038843425622c76972db76af82c7501d1 && git checkout b2daf8c038843425622c76972db76af82c7501d1
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 45b9051248f86f362b042fa9de63295a8c224073 1ba418ebf41441fbda52fbef3b6e89b43437c6c0 && git checkout -B drift-repro 45b9051248f86f362b042fa9de63295a8c224073 && git merge --no-ff 1ba418ebf41441fbda52fbef3b6e89b43437c6c0
node scripts/docs-audit/affected-docs.mjs --json 45b9051248f86f362b042fa9de63295a8c224073
|
hotlong
commented
Sep 1, 2026
📋 条款②契约复核 —— 材料呈送,⛔ 本席不自判清标维护者 2026-09-01 裁定由维护者本人任复核席,本席只摆材料。⛔ 因此本评论不含放行结论, 保险丝读数(细则要求每场必读): ① Derived judgments —— diff 引出的接受集 / 公开面变化,逐条点名J1 · 接受集收窄(这就是条款②的内容肢本体)
⇒ 一个授权谓词的默认方向从 allow 翻到 refuse。触发键 = 运维声明的副本数,与 J2 · 公开面加宽(纯增量)
J3 · 本 PR 的 fail-closed 只在
⛔ 严格说这不是本 PR 发明的规则:EE config 路线今天就是这个行为,两半只是让其他路线与之对齐 —— 这正是卡片的目的。但它是一次真实的接受集变化,且只在跨仓合成后可见,所以点名请裁:接受此对齐,还是要求把无证单副本的远程驱动留出豁免? 本席倾向接受(对齐 > 例外),但这属产品面,⛔ 不自裁。 J4 · 运维可见文案变更(本席在接手后新增,非原实现者产物)
依据(#14116 实测链,已在 fail-closed 的触发条件与 split-brain 护栏的触发条件是同一个声明 ⇒ 在真实多副本部署上,拒启是常态而非例外。⭐ 拒启本身正确(N 副本各持进程内锁 = 护栏要拦的脑裂),错的是「never bricks」那句承诺。 ② Semver 定级 vs changeset 声明 —— 一致
⇒ ② 项本席未发现不一致。 ③ 边界旗处置 —— 逐旗
落地前检三条的当前读数
另两项前置也已机读: ⇒ 裁 PASS 即可剥标 → ready → 入队;裁 FAIL 或要求修改则走补丁轮。 Generated by Claude Code |
hotlong
commented
Sep 1, 2026
⚖️ 条款②契约复核 PASS —— 维护者本人任复核席,2026-09-01裁定:J3 选 A(接受对齐),verbatim「A」。维护者亲自复核,档位问题不适用(保险丝约束的是补偿低档派发的代理复核席,不是维护者席;本席 被裁的那一条,连同裁定理由一并入档问题:本 PR 与已合入的 cloud#1828 合成后,无证 + 裁 A —— 门对任何副本数都作答,所有启动路线与 EE config 路线今天的行为对齐。⛔ 不给单副本开豁免。 理由(业务面,记录在案以免日后被当成"顺手收紧"): 代价一并记明:一批未测到的"脱离模板 + 无证 + 配了 redis + 单副本"部署会在升级后看到一条拒绝。功能上近乎无损(单进程本就该用进程内驱动,脑裂只在跨进程存在),受支持的部署模板不产生该形状(cloud#1742 核查:模板均 pin 三项清单归档
落地前检(细则三条,维护者 2026-08-31「同意」)
治理面谓词在最终文件表上重跑: ⇒ 剥标 → ready → 入队。 Generated by Claude Code |
Uh oh!
There was an error while loading. Please reload this page.
Fixes#13537
Gap 1 of the card, both halves — the mount and the default direction. Gap 2 (the CJS/ESM dual-instance split) is deliberately NOT addressed here: #13330 remains open and is the canonical card for that class; see the effectiveness boundary below.
What changed
Half (b) — the default direction itself (
packages/services/service-cluster/src/multi-node-gate.ts):checkMultiNodeAllowedused to returnallowed: truewhenever no gate was registered — on a licensed capability, "the registration code didn't run" silently meant "permitted". With no gate registered it now refuses a DECLARED multi-node topology (requested > 1), in the same verdict shape as a registered gate's outright denial (allowed: false,reason: MULTI_NODE_NO_GATE_REASON,admitted: 0,refused: N,capped: false), soos servedrops the remote driver and warns loudly. ⛔ Corrected since the first draft of this body (see "Boot outcome" below): that is not always a downgrade — when the operator also declared the topology, the in-process fallback then trips the split-brain guard and the boot is REFUSED. Undeclared or single-replica input (OS_CLUSTER_REPLICASunset,1,0, negative,NaN) keeps the historical allow: it declares no multi-node topology, so there is nothing to gate — the same> 1posture assplit-brain-guard.ts'sdeclaresMultiNode. New exports:hasMultiNodeGate(),MULTI_NODE_NO_GATE_REASON.Half (a) — sink the mount (
packages/services/service-cluster/src/multi-node-gate-mount.ts, new + a call site inpackages/cli/src/commands/serve.ts):registerMultiNodeGatewas reachable from exactly one place — the EE app config (cloud repo) executing — so the thin-extension andOS_ARTIFACT_URLartifact-direct boot routes never mounted the gate at all. NewmountMultiNodeGateFromHost(importFromHost)lives indomain:servicesand owns the carrier list (MULTI_NODE_GATE_CARRIER_PACKAGES:@objectstack/security-enterprise,@objectstack/organizations— both roster-declared enterprise runtimes, drift-tested againstPLATFORM_PLUGIN_WIRED_RUNTIMES). The boot surface hands over its host-anchored importer (#4719) and the helper imports the carriers so a carrier that registers the gate at its own module load is mounted on EVERY boot route.servecalls it right before consulting the gate, best-effort (?.+ try/catch: an app pinning an older service-cluster, or no distribution installed, keeps booting — the fail-closed default answers). No newimport()/importFromHost(...)load sites inserve.ts— the carrier loads run insideservice-clusterthrough the passed importer, so every load still resolves from the served app.The ceiling of what this PR claims
An unregistered gate no longer defaults to permitted, and registration no longer depends on a single config file executing. Nothing beyond that line — in particular this PR does NOT claim "the gate now actually takes effect in a cluster".
Effectiveness boundary while the dual-instance split stands — #13330 remains open
The gate registry is module-level singleton state, not
globalThis-anchored. Registration through one module instance (e.g. bundle-require's ESM instance executing the EE config) is invisible to a consult through another (the hostImporter's CJS condition) — the #13330 class, derived (not measured) on this registry. Until #13330 lands (for the registry class generally — deliberately not done one registry at a time here):mountMultiNodeGateFromHosthonestly reports that state (loaded-without-gate) rather than papering over it —hasMultiNodeGate()answers per module instance by construction.Also out of scope here, unchanged: cloud#1752 keeps the
docker-entrypoint.shdisclosure line (cloud side); cloud#1742's product question is ruled and closed.✅ The cloud-side follow-up this paragraph asked for has LANDED. The carriers now self-register at module load: objectstack-ai/cloud#1828 (from cloud#1827) merged to cloud
mainatcfbd4acf, adding a module-scoperegisterMultiNodeGateto BOTH@objectstack/security-enterpriseand@objectstack/organizations— i.e. both names on this PR'sMULTI_NODE_GATE_CARRIER_PACKAGES. That is the sequencing #14116 ruled (option 2: cloud half first, this PR after), so the mount half now has a real carrier to find instead of reportingloaded-without-gateon every route.Tier declaration
Clause-②: yes — this PR changes accept/reject behaviour without touching contract files: the no-registration default of an authorization gate flips from ALLOW to REFUSE for declared multi-node topologies. Surface actually touched:
packages/services/service-cluster/src/multi-node-gate.ts(the default),multi-node-gate-mount.ts(new mount seam),src/index.ts(exports), andpackages/cli/src/commands/serve.ts(mount call site).domain:clihalf declared: the serve call site — one destructure + one guarded call; the mount logic and carrier list live indomain:services.Verification
Negatives (mandated, one test each — both green):
admitted: 5,requested: 3→allowed: true, refused: 0, capped: false— the fail-closed branch is unreachable the moment a gate is registered ("never blocks a properly-entitled deployment").checkMultiNodeAllowed(1)and undeclared/meaningless counts (NaN/0/-1) with no gate → byte-identical allow verdict to before ("allows a single declared replica…", "keeps meaningless declared counts on the allow path…").Refusal side: no gate +
2and no gate +9→ refused withMULTI_NODE_NO_GATE_REASON(the9pin is the exact call that used to pin default-ALLOW, direction flipped on purpose). Mount seam: registers-and-stops, all-unavailable (open-core), loaded-without-gate (#13330 shape), already-registered short-circuit, never-throws, roster drift guard.Ablation (direction predicted before running): mutating the fail-closed branch back to fail-open (
if (wanted !== undefined && wanted > 1)→if (false)) predicted RED on exactly the three fail-closed assertions; observed: the mutated tree went RED with 4 failing tests — the 3 predicted plus one more of the same class ("reset restores the unregistered default", whose added last line also asserts the fail-closed branch; the prediction under-counted by that one assertion, direction as predicted) — while all 23 control tests stayed green. Mutation was applied atee607b488, whosepackages/services/service-clustertree is byte-identical to this PR's head (verified:git diff 30c2d3d02 ee607b488 -- packages/services/service-clusteris empty). Mutated blobde8c8fbbvs HEAD blob65ffad01; restored blob equals the HEAD blob. Mutation proven on disk by marker counts (target 1→0, marker 0→1) and blob hash vs the HEAD blob; restore proven by state (git diff HEADempty, blob hash back to the HEAD blob, counts back). The mutated module reaches its tests via same-package relative src imports (no dist on that resolution path), so no rebuild is required for the mutation to be visible; the single-replica/undeclared/entitled negatives stay green in both trees and are declared controls, not ablation evidence.Gates and tests (builds and test suites through the shared verify lock;
check:*gates and lint unlocked per the lock's own contract; exit codes captured before any pipe; run at30c2d3d02, this PR's head):turbo run build --filter='./packages/*' --filter='./packages/*/*'(lint.yml parity) — 70/70 successful.30c2d3d02:@objectstack/service-cluster77/77 ·@objectstack/service-cluster-redis(direct dist-coupled consumer) 28/28 ·@objectstack/clitargeted suites (serve-multi-node-cap-advisory+.pin,serve-multi-node-cap-telemetry,serve-cluster-host-resolutionsource sweep,serve-app-anchored-optional-import.e2ereal boot) 71/71 — vitest's own "Test Files N passed / Tests N passed" lines are the cited verdicts.@objectstack/clitsc --noEmitexit 0 at30c2d3d02withserve.tsproven in the program via--listFiles.@objectstack/service-clusterhas NOtypecheckscript; a manualtsc --noEmit --listFiles(run atee607b488, whose service-cluster tree is byte-identical to this head) shows all four edited/added files in the program contributing zero errors, plus one PRE-EXISTING error in the untouchedsrc/memory/memory.contract.test.ts(carried by the type-check-debt ledger — see next line).node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstackata97319179(both output sections read whole; 33 path-derived families + the convention-triggered test-file and error-code families). All run locally and green — includingcheck:adr-0087-registration(red once for a missing disposition marker on the BREAKING changeset; fixed by thenot-required (no-migration-prescription)line, re-run green),check:type-check-debt --re-measure("none above its recorded number" — my new test files add zero tsc errors),check:engine-double-contract,check:where-matcher,check:test-source-alias,check:dispatcher-error-vocabulary,check:nul-bytes, and full-repopnpm lint(eslint . --no-inline-config, exit 0).scripts/check-test-completeness.mjsexits 3 = PREREQUISITE NOT MET locally (it needs CI shard context) — CI owns it. CI's unconditional always-runs steps (workspace typecheck farm, hook self-tests, etc.) are likewise CI's; every exit code above was captured before any pipe.The paragraphs above (and the gate module's own doc) promised that a denial means
os servedowngrades to single-node — never bricks. That is false for the only deployment shape which can reach the new fail-closed refusal at all, and the wording is corrected throughout this PR rather than shipped:The fail-closed refusal's trigger (
requested > 1) and the split-brain guard's trigger (OS_CLUSTER_REPLICAS > 1/OS_EXPECT_MULTI_NODE) are the same operator declaration, so on a real multi-replica deployment the refusal is the ordinary outcome, not the exception. ⭐ The refusal is CORRECT and must not be "fixed" by weakening the guard — N replicas each holding a per-process lock is exactly the silent split-brain it exists to stop. What was wrong was the promise.Carried in this PR, no behaviour change beyond the wording plus one operator-facing message:
multi-node-gate.ts,multi-node-gate-mount.ts,serve.tscomments and the changeset: corrected, with a ⛔ note against restating "never bricks";serve.ts's boot warning: it saiddowngrading to single-node (in-memory cluster)while the process was about to refuse. It now names the fallback AND the refusal that follows a declared topology, with both remedies (drop the declaration, or license the capability);multi-node-gate.test.tspinning the chain from the real pieces (defineCluster({}).driver === 'memory'; guard throws at replicas 3, stays quiet at 1 — the genuine downgrade case, kept distinct so the two are never conflated again).Takeover, and what changed after the original delivery
This PR was delivered by
session_016ZC5rNQj3WEet5HAmmAkMsand held in draft at the maintainer's sequencing question (#14116). The maintainer ruled option 2 on 2026-09-01 and assigned the finish to a new seat (297844eb-1f29-47eb-997c-75fab32d79d9); the takeover is recorded on #13537. ⛔ The delivered implementation was NOT redone and Gap 2 stays out of scope.Added since
30c2d3d02:origin/mainmerged in (the branch was 35 commits behind andCONFLICTING, so no workflow was triggering at all). One conflict, inserve.ts: main had replaced the destructure with a whole-namespace read (__clusterModule) because the driver-registry read further down must come from the same instance (cli: serve's cluster-driver load registers into the CJS registry while the ESM Runtime reads the ESM one — OS_CLUSTER_DRIVER=redis silently downgrades to "not registered" (post-#10645) #13330). Resolved by keeping main's namespace shape and destructuring the mount helper from that same__clusterModule— re-importing the package for the mount would have re-opened the very split this file closes.Re-verified at this head after a full workspace build:
@objectstack/service-cluster84/84 (6 files) ·@objectstack/climulti-node + cluster suites 65/65 (4 files).packages/clitsc --noEmitreports the same 69 pre-existing errors with main'sserve.tsas with this one (all missing-dist/dts for dynamically-imported packages in a fresh worktree) ⇒ this diff adds zero type errors; that count is an artefact of the local tree, and CI's typecheck farm is the authority.Clause-② carriers re-hung after the head moved (
needs:contract-reviewon both PR and card) and machine-verified:node scripts/pm/check-clause2-carriers.mjs --pair 14114→ readable, both carriers agree. Governed-surface predicate re-run on the FINAL file list:0 of 7 paths hit the register⇒ NOT governed, ordinary queue landing applies.Generated by Claude Code
🤖 Generated with Claude Code
https://claude.ai/code/session_016ZC5rNQj3WEet5HAmmAkMs
Generated by Claude Code