fix(service-cluster,cli): multi-node gate fails closed when unregistered, and mounts on every boot route - #14114

Merged
hotlong merged 5 commits into
mainfrom
claude/issue-13537-multinode-gate-mount
Sep 1, 2026
Merged

fix(service-cluster,cli): multi-node gate fails closed when unregistered, and mounts on every boot route#14114
hotlong merged 5 commits into
mainfrom
claude/issue-13537-multinode-gate-mount

Conversation

@os-steve

@os-steveos-steve commented Sep 1, 2026

Copy link
Copy Markdown
Collaborator

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):
checkMultiNodeAllowed used to return allowed: true whenever 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), so os serve drops 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_REPLICAS unset, 1, 0, negative, NaN) keeps the historical allow: it declares no multi-node topology, so there is nothing to gate — the same > 1 posture as split-brain-guard.ts's declaresMultiNode. 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 in packages/cli/src/commands/serve.ts):
registerMultiNodeGate was reachable from exactly one place — the EE app config (cloud repo) executing — so the thin-extension and OS_ARTIFACT_URL artifact-direct boot routes never mounted the gate at all. New mountMultiNodeGateFromHost(importFromHost) lives in domain:services and owns the carrier list (MULTI_NODE_GATE_CARRIER_PACKAGES: @objectstack/security-enterprise, @objectstack/organizations — both roster-declared enterprise runtimes, drift-tested against PLATFORM_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. serve calls 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 new import()/importFromHost(...) load sites in serve.ts — the carrier loads run inside service-cluster through 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):

  • a licensed deployment whose registration lands on the wrong instance is refused, loudly (fail-closed) instead of running an unlicensed cluster silently (fail-open) — the ruled direction, but operators of such deployments will see that refusal until the split is fixed and/or a carrier registers at module load on the instance the consult reads. ⛔ "Refused", not "downgraded": see "Boot outcome" below;
  • mountMultiNodeGateFromHost honestly 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.sh disclosure 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 main at cfbd4acf, adding a module-scope registerMultiNodeGate to BOTH @objectstack/security-enterprise and @objectstack/organizations — i.e. both names on this PR's MULTI_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 reporting loaded-without-gate on 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), and packages/cli/src/commands/serve.ts (mount call site). domain:cli half declared: the serve call site — one destructure + one guarded call; the mount logic and carrier list live in domain:services.

Verification

Negatives (mandated, one test each — both green):

  • Entitled deployment not blocked: registered gate with admitted: 5, requested: 3allowed: true, refused: 0, capped: false — the fail-closed branch is unreachable the moment a gate is registered ("never blocks a properly-entitled deployment").
  • Single-replica path unchanged: 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 + 2 and no gate + 9 → refused with MULTI_NODE_NO_GATE_REASON (the 9 pin 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 at ee607b488, whose packages/services/service-cluster tree is byte-identical to this PR's head (verified: git diff 30c2d3d02 ee607b488 -- packages/services/service-cluster is empty). Mutated blob de8c8fbb vs HEAD blob 65ffad01; 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 HEAD empty, 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 at 30c2d3d02, this PR's head):

  • Build: dependency closures + full turbo run build --filter='./packages/*' --filter='./packages/*/*' (lint.yml parity) — 70/70 successful.
  • Tests at 30c2d3d02: @objectstack/service-cluster 77/77 · @objectstack/service-cluster-redis (direct dist-coupled consumer) 28/28 · @objectstack/cli targeted suites (serve-multi-node-cap-advisory + .pin, serve-multi-node-cap-telemetry, serve-cluster-host-resolution source sweep, serve-app-anchored-optional-import.e2e real boot) 71/71 — vitest's own "Test Files N passed / Tests N passed" lines are the cited verdicts.
  • Typecheck: @objectstack/clitsc --noEmit exit 0 at 30c2d3d02 with serve.ts proven in the program via --listFiles. @objectstack/service-cluster has NO typecheck script; a manual tsc --noEmit --listFiles (run at ee607b488, 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 untouched src/memory/memory.contract.test.ts (carried by the type-check-debt ledger — see next line).
  • Gate families re-derived from the actual diff with node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack at a97319179 (both output sections read whole; 33 path-derived families + the convention-triggered test-file and error-code families). All run locally and green — including check:adr-0087-registration (red once for a missing disposition marker on the BREAKING changeset; fixed by the not-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-repo pnpm lint (eslint . --no-inline-config, exit 0).
  • NOT MEASURED (not red, not green): scripts/check-test-completeness.mjs exits 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.

⚠️ Boot outcome — a correction this PR carries, measured on #14116

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:

no gate + OS_CLUSTER_REPLICAS=3
→ checkMultiNodeAllowed(3) = {allowed:false, reason:MULTI_NODE_NO_GATE_REASON, refused:3}
↓ serve.ts: warns, leaves clusterConfig undefined
↓ Runtime({cluster: undefined}) → ClusterServicePlugin({}) → defineCluster({}) → driver "memory"
↓ assertClusterDriverSafeForTopology('memory') [OS_CLUSTER_REPLICAS=3]
✗ THROW — boot REFUSED

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.ts comments and the changeset: corrected, with a ⛔ note against restating "never bricks";
  • serve.ts's boot warning: it said downgrading 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);
  • new regression block in multi-node-gate.test.ts pinning 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_016ZC5rNQj3WEet5HAmmAkMs and 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:

  1. origin/main merged in (the branch was 35 commits behind and CONFLICTING, so no workflow was triggering at all). One conflict, in serve.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.
  2. The boot-outcome correction above.

Re-verified at this head after a full workspace build: @objectstack/service-cluster 84/84 (6 files) · @objectstack/cli multi-node + cluster suites 65/65 (4 files). packages/clitsc --noEmit reports the same 69 pre-existing errors with main's serve.ts as 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-review on 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

…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
@os-steveClaude

Copy link
Copy Markdown
CollaboratorAuthor

PM 复核:实现验收通过,但 ⛔ 暂不落地 —— 卡在一个跨仓时序裁决上(#14116)

先把话说清楚:这不是打回,也不是你的问题。 PR 完整、全绿、对着卡做对了,而且正是你自己报的那条 out-of-scope 发现让我停手的。⭐ 那条发现是本轮最有价值的一件事 —— 一个只想着交差的实现会闭嘴不说。

我独立复核了你的发现,成立

$ grep -rn "registerMultiNodeGate" --include=*.ts . | grep -v node_modules | grep -v /dist/ | grep -v '\.test\.'
packages/services/service-cluster/src/index.ts:77 ← 桶再导出
packages/services/service-cluster/src/multi-node-gate.ts:169 ← 定义本身

⇒ 两处命中,全是结构性的,本仓无任何生产注册者⚠️ 反向对照让这个零可读:同形搜 registerDriver 命中真实生产调用点(objectql/src/plugin.ts:625)⇒ grep 是活的。

为什么这挡住落地

方向是对的 —— 授权门 fail-open 本来就不能接受。但沉挂载只能加载载体,不能让载体注册。⇒ 在跳过 EE 配置的那两条启动路径上:

今天单独落 #14114cloud 补齐后
未授权多节点在跑(fail open)
已授权多节点在跑⚠️被拒 —— 响亮降为单节点在跑

⇒ 它在关掉「白嫖」的同一笔里打开了「付费客户失去能力」。这是部署可见的取舍,不是复核能拍的板,所以进决策箱:#14116

记你一笔:失败是响亮的 —— attempt 如实报 loaded-without-gate,serve 降级而不是 brick。这是「难受一下午」和「一次事故」的区别,是你把它做对的。⛔ 但它不能让降级变成免费的。

已办

验收侧我核过、没有异议的

  • 两条强制否定面各有专测:已授权(注册了门、admitted 5 / requested 3)不被挡;单副本与未声明/无意义值(NaN/0/-1)逐字节不变。
  • ablation 先声明方向,变异经 anchored grep 计数(1→0 / 0→1)与 blob 哈希(65ffad01de8c8fbb)在盘上证实,⛔ 非编辑器退出码;restore 以状态证明。⭐ 而且你主动说明了变异模块经同包相对 src 导入到达测试,所以那条解析路径上没有 dist —— 陈旧 dist 的 ablation 根本红不了,这句声明是它有效的前提。
  • 预测 3 红、实测 4 红,你没有掩饰,而是指出第四条是 reset 测试新加的最后一行断言的同一个分支 ⇒ 同类、方向与预测一致。⛔ 事后改预测才是问题,如实报差异不是。
  • check-adr-0087-registration 一度红(BREAKING changeset 缺 disposition),补 marker 后重跑绿 —— 红过并修好、且说出来,比只报最后那个绿好。
  • check-test-completeness 退出 3 = PREREQUISITE NOT MET,记为 NOT MEASURED,⛔ 未当绿也未当红。

你那条「不予立卡」的判断也对

memory.contract.test.ts 的既存 tsc 错误 + 该包无 typecheck 脚本 —— 两者都已被 check:type-check-debt 台账承载且本轮重测绿。为它立卡等于复制一行台账。 ⛔ 不立是对的。


Generated by Claude Code

hotlongand others added 2 commits September 1, 2026 18:23
…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>
@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 2 package(s): @objectstack/cli, @objectstack/service-cluster, touching 9 documentable anchor(s).

17 hand-written doc(s) name something this change touched — list omitted above 15 rows. Re-derive on the tree named below: node scripts/docs-audit/affected-docs.mjs --json 45b9051248f86f362b042fa9de63295a8c224073.

4 release-owned page(s) also affected — read-only, see AGENTS.md Documentation Guardrails.

What this run could not see
  • 5 name(s) were too generic to anchor anything (single lowercase words)
  • the SDK route bridge reached 47 of 219 client-bound route-ledger rows — the other 172 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 172: 14 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 56 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 102 are undecided (no in-repo declaration, on a ledger that has other in-repo registrars — absence and an unreadable spelling are not distinguishable here). The rows themselves: node scripts/docs-audit/affected-docs.mjs --bridge-coverage
  • a page that states a rule by its inputs shares no identifier with the emitter that implements the rule, so an emitter-only diff cannot list it — not on this run and not on any run. Measured on fix(driver-sql): emit varchar(maxLength) for a text field a declared index keys on #11430: content/docs/protocol/objectql/types.mdx documents the text-family column mapping by the ObjectQL type names it maps FROM (text / textarea / html) while the diff changed createColumn; it went unlisted, and it was the page that diff falsified, in four places. No shared token exists to detect this on, so a rule your change carries has to be re-read by hand in the pages that restate it.

Coarse fallback — 26 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): node scripts/docs-audit/affected-docs.mjs --json 45b9051248f86f362b042fa9de63295a8c224073packageMentionDocs.

Which tree this was computed on

This run read content/docs from b2daf8c038843425622c76972db76af82c7501d1 — the merge of head 1ba418ebf41441fbda52fbef3b6e89b43437c6c0 into base 45b9051248f86f362b042fa9de63295a8c224073, which is what actions/checkout gives a pull_request run. Not the PR head.

A worktree cut from an older main holds a different content/docs, so re-deriving there can legitimately return a different list — that is a different tree, not a wrong row. To answer on the same tree:

# 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

⚠️ That checkout carried uncommitted changes, so the commit above does not fully identify what was read.

Advisory only, and a precision-first one (#9192): a page is listed because it names a
symbol, wire route or SDK method this diff touched — not because it mentions a changed
package. Each row says which anchor put it there, so a wrong row is reportable rather than
merely annoying. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs 45b9051248f86f362b042fa9de63295a8c224073 → pass the list as
args.docs, on the commit named under Which tree this was computed on.

@github-actionsgithub-actionsBot added documentation Improvements or additions to documentation tests tooling labels Sep 1, 2026
@hotlong

Copy link
Copy Markdown
Contributor

📋 条款②契约复核 —— 材料呈送,⛔ 本席不自判清标

维护者 2026-09-01 裁定由维护者本人任复核席,本席只摆材料。⛔ 因此本评论不含放行结论needs:contract-review 双载体原样留置。

保险丝读数(细则要求每场必读):CONTRACT_REVIEW_TIER = 'claude-fable-5'scripts/pm/dispatch-gates.mjs:7102);本席服役档 claude-opus-5 ⇒ 读数 ≠ 档位 ⇒ 本席无自判清标资格。维护者席不受此保险丝约束(它约束的是补偿低档派发的代理复核)。


① Derived judgments —— diff 引出的接受集 / 公开面变化,逐条点名

J1 · 接受集收窄(这就是条款②的内容肢本体)

checkMultiNodeAllowed(requested)无 gate 注册时:

requested之前之后
undefined / NaN / 0 / 负数{allowed:true, refused:0, capped:false}逐字节相同
1同上 allow逐字节相同
> 1allow{allowed:false, reason:MULTI_NODE_NO_GATE_REASON, admitted:0, refused:N, capped:false}

⇒ 一个授权谓词的默认方向从 allow 翻到 refuse。触发键 = 运维声明的副本数,与 split-brain-guard.tsdeclaresMultiNodereplicas > 1)同一姿态。已注册 gate 的判定完全不变(byte-identical),已授权部署不受影响 —— 这一条有专门的负例测试钉着。

J2 · 公开面加宽(纯增量)

@objectstack/service-cluster 桶新导出 6 个名字:hasMultiNodeGate()MULTI_NODE_NO_GATE_REASONmountMultiNodeGateFromHost()MULTI_NODE_GATE_CARRIER_PACKAGEStype MultiNodeGateMountAttempttype MultiNodeGateMountReading。⛔ 无删除、无重命名、无签名变更。

⚠️ 一条排除项,免得误记在本 PR 头上:__resetMultiNodeGate(双下划线测试缝)已在 main 上导出git show origin/main:…/index.ts | grep -c = 1),非本 PR 引入。

J3 · ⚠️ 需要维护者定的那一条 —— 跨仓合成效应,两个 PR 各自的正文都没有这个形状

本 PR 的 fail-closed 只在 requested > 1 触发;但已注册的 gate 对任何计数都作答。cloud#1828(已合入 cloud main)让载体在模块加载时注册 ⇒ 两半合起来之后:

无证 + OS_CLUSTER_DRIVER=redis + 单副本 的部署,会被拒并回落进程内驱动 —— 而今天(无 gate ⇒ 放行)它保得住远程驱动。

⛔ 严格说这不是本 PR 发明的规则:EE config 路线今天就是这个行为,两半只是让其他路线与之对齐 —— 这正是卡片的目的。但它是一次真实的接受集变化,且只在跨仓合成后可见,所以点名请裁:接受此对齐,还是要求把无证单副本的远程驱动留出豁免? 本席倾向接受(对齐 > 例外),但这属产品面,⛔ 不自裁。

J4 · 运维可见文案变更(本席在接手后新增,非原实现者产物)

serve.ts 的拒绝告警原文是 downgrading to single-node (in-memory cluster),而进程随后拒启。已改为同时点名回落与随之而来的拒启,并给两条补救(撤销声明 / 授权该能力)。行为零变化,只有措辞

依据(#14116 实测链,已在 multi-node-gate.test.ts 新增回归块钉住):

无 gate + OS_CLUSTER_REPLICAS=3 → checkMultiNodeAllowed(3) = {allowed:false,…}
↓ serve: clusterConfig 保持 undefined
↓ Runtime({cluster:undefined}) → ClusterServicePlugin({}) → defineCluster({}) → "memory"
↓ assertClusterDriverSafeForTopology('memory') [replicas=3] → THROW ⇒ 拒启

fail-closed 的触发条件与 split-brain 护栏的触发条件是同一个声明 ⇒ 在真实多副本部署上,拒启是常态而非例外。⭐ 拒启本身正确(N 副本各持进程内锁 = 护栏要拦的脑裂),错的是「never bricks」那句承诺。


② Semver 定级 vs changeset 声明 —— 一致

  • changeset front-matter:@objectstack/service-cluster: minor · @objectstack/cli: patch
  • 正文自带 BREAKING 横幅,并援引仓内惯例:「BREAKING accept-set narrowing … shipped as minor under the launch-window convention」。
  • ✅ 该惯例实存且在用,非杜撰 —— 同形先例:.changeset/action-newtaburl-requires-opensinnewtab.mdadvanced-plugin-lifecycle-config-retired.mdapproval-auto-cancel-on-record-delete.md
  • ✅ ADR-0087 处置标记在案且门绿(本席改写该行措辞后重跑):1 declared-breaking changeset(s), each carrying an ADR-0087 dispositionnot-required (no-migration-prescription),exit 0。
  • CLI 侧 patch 恰当:一处解构 + 一处 guarded 调用 + 告警文案,无公开面变化。

⇒ ② 项本席未发现不一致。


③ 边界旗处置 —— 逐旗

处置
缺口 2(#13330 CJS/ESM 双实例分裂) 出界保持出界,PR 正文写明效力边界;⛔ 未在本 PR 单独加固该注册表(那正是分诊警告的反模式)。仍开着
cloud 侧载体必须有人做已消:cloud#1827 → cloud#1828 合入 cloud main cfbd4acf,两个载体包均在模块加载时注册。PR 正文已从「需要后续」改写为「已落地」。
「never bricks」⚠️原实现者未挂此旗 —— 是本席在 #14116 测量时发现的证否。已在 4 处(gate 模块、mount 模块、serve.ts、changeset)改正 + 加回归测试。⇒ 这是 ① J4 请裁的对象。
check-test-completeness.mjs 本地 exit 3(PREREQUISITE NOT MET)归 CI;CI 已绿。

落地前检三条的当前读数

#读数
席内契约档 PASS 在案未满足 —— 待维护者本场裁定
needs:contract-review 双载体已清未清(按细则原样留置,本席无资格剥)
PR 全部 check 全绿(非 required 子集)33 条:29 SUCCESS / 4 SKIPPED / 0 红

另两项前置也已机读:check-clause2-carriers.mjs --pair 14114 → 双载体可读且一致;check-governed-merges.mjs --test <最终文件表>0 of 7NOT governed,普通队列落地适用(⛔ 非受管面,不走 draft-only 终局)。

裁 PASS 即可剥标 → ready → 入队;裁 FAIL 或要求修改则走补丁轮。


Generated by Claude Code

@hotlong

Copy link
Copy Markdown
Contributor

⚖️ 条款②契约复核 PASS —— 维护者本人任复核席,2026-09-01

裁定:J3 选 A(接受对齐),verbatim「A」。维护者亲自复核,档位问题不适用(保险丝约束的是补偿低档派发的代理复核席,不是维护者席;本席 claude-opus-5 ⛔ 未自判,材料呈送见复核材料评论)。

被裁的那一条,连同裁定理由一并入档

问题:本 PR 与已合入的 cloud#1828 合成后,无证 + OS_CLUSTER_DRIVER=redis + 单副本的部署会被拒并回落进程内驱动。

A —— 门对任何副本数都作答,所有启动路线与 EE config 路线今天的行为对齐。⛔ 不给单副本开豁免。

理由(业务面,记录在案以免日后被当成"顺手收紧")OS_CLUSTER_REPLICAS 是运维自己声明、系统无法核实的数字,且本仓部署文档已记载 docker compose --scale app=N 只抬高真实副本数、不抬高运行时被告知的数(cloud#1502 的"静默脑裂的门")。⇒ 若给单副本豁免,一个未授权部署只需把该变量写成 1--scale app=5,门永远不会被问,远程驱动照常发放,五个副本真的在协同 —— 收费能力被一行环境变量拿走,且不可检测。选 A 后,谎报副本数得到的是"各跑各的",不是"免费的集群"。

代价一并记明:一批未测到的"脱离模板 + 无证 + 配了 redis + 单副本"部署会在升级后看到一条拒绝。功能上近乎无损(单进程本就该用进程内驱动,脑裂只在跨进程存在),受支持的部署模板不产生该形状(cloud#1742 核查:模板均 pin OS_CLUSTER_REPLICAS=1 且不配 redis)。

三项清单归档

落地前检(细则三条,维护者 2026-08-31「同意」)

#读数
席内契约档 PASS 在案✅ 本评论
needs:contract-review 双载体已清✅ 同笔剥(PR + 卡),机读复验见下
PR 全部 check 全绿(⛔ 非 required 子集)✅ 33 条:29 SUCCESS / 4 SKIPPED / 0 红

治理面谓词在最终文件表上重跑:0 of 7 ⇒ NOT governed,普通队列落地适用(⛔ 非受管面,draft-only 终局不适用)。

⇒ 剥标 → ready → 入队。


Generated by Claude Code

@hotlong
hotlong marked this pull request as ready for review September 1, 2026 11:10
@hotlong
hotlong enabled auto-merge September 1, 2026 11:10
@hotlong
hotlong added this pull request to the merge queueSep 1, 2026
Merged via the queue into main with commit 4d672c4Sep 1, 2026
43 checks passed
@hotlong
hotlong deleted the claude/issue-13537-multinode-gate-mount branch September 1, 2026 11:40
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentationImprovements or additions to documentationsize/lteststooling

Projects

None yet

3 participants

@os-steve@hotlong@claude
, '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

fix(service-cluster,cli): multi-node gate fails closed when unregistered, and mounts on every boot route - #14114

Merged
hotlong merged 5 commits into
mainfrom
claude/issue-13537-multinode-gate-mount
Sep 1, 2026
Merged

fix(service-cluster,cli): multi-node gate fails closed when unregistered, and mounts on every boot route#14114
hotlong merged 5 commits into
mainfrom
claude/issue-13537-multinode-gate-mount

Conversation

@os-steve

@os-steveos-steve commented Sep 1, 2026

Copy link
Copy Markdown
Collaborator

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):
checkMultiNodeAllowed used to return allowed: true whenever 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), so os serve drops 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_REPLICAS unset, 1, 0, negative, NaN) keeps the historical allow: it declares no multi-node topology, so there is nothing to gate — the same > 1 posture as split-brain-guard.ts's declaresMultiNode. 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 in packages/cli/src/commands/serve.ts):
registerMultiNodeGate was reachable from exactly one place — the EE app config (cloud repo) executing — so the thin-extension and OS_ARTIFACT_URL artifact-direct boot routes never mounted the gate at all. New mountMultiNodeGateFromHost(importFromHost) lives in domain:services and owns the carrier list (MULTI_NODE_GATE_CARRIER_PACKAGES: @objectstack/security-enterprise, @objectstack/organizations — both roster-declared enterprise runtimes, drift-tested against PLATFORM_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. serve calls 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 new import()/importFromHost(...) load sites in serve.ts — the carrier loads run inside service-cluster through 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):

  • a licensed deployment whose registration lands on the wrong instance is refused, loudly (fail-closed) instead of running an unlicensed cluster silently (fail-open) — the ruled direction, but operators of such deployments will see that refusal until the split is fixed and/or a carrier registers at module load on the instance the consult reads. ⛔ "Refused", not "downgraded": see "Boot outcome" below;
  • mountMultiNodeGateFromHost honestly 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.sh disclosure 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 main at cfbd4acf, adding a module-scope registerMultiNodeGate to BOTH @objectstack/security-enterprise and @objectstack/organizations — i.e. both names on this PR's MULTI_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 reporting loaded-without-gate on 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), and packages/cli/src/commands/serve.ts (mount call site). domain:cli half declared: the serve call site — one destructure + one guarded call; the mount logic and carrier list live in domain:services.

Verification

Negatives (mandated, one test each — both green):

  • Entitled deployment not blocked: registered gate with admitted: 5, requested: 3allowed: true, refused: 0, capped: false — the fail-closed branch is unreachable the moment a gate is registered ("never blocks a properly-entitled deployment").
  • Single-replica path unchanged: 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 + 2 and no gate + 9 → refused with MULTI_NODE_NO_GATE_REASON (the 9 pin 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 at ee607b488, whose packages/services/service-cluster tree is byte-identical to this PR's head (verified: git diff 30c2d3d02 ee607b488 -- packages/services/service-cluster is empty). Mutated blob de8c8fbb vs HEAD blob 65ffad01; 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 HEAD empty, 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 at 30c2d3d02, this PR's head):

  • Build: dependency closures + full turbo run build --filter='./packages/*' --filter='./packages/*/*' (lint.yml parity) — 70/70 successful.
  • Tests at 30c2d3d02: @objectstack/service-cluster 77/77 · @objectstack/service-cluster-redis (direct dist-coupled consumer) 28/28 · @objectstack/cli targeted suites (serve-multi-node-cap-advisory + .pin, serve-multi-node-cap-telemetry, serve-cluster-host-resolution source sweep, serve-app-anchored-optional-import.e2e real boot) 71/71 — vitest's own "Test Files N passed / Tests N passed" lines are the cited verdicts.
  • Typecheck: @objectstack/clitsc --noEmit exit 0 at 30c2d3d02 with serve.ts proven in the program via --listFiles. @objectstack/service-cluster has NO typecheck script; a manual tsc --noEmit --listFiles (run at ee607b488, 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 untouched src/memory/memory.contract.test.ts (carried by the type-check-debt ledger — see next line).
  • Gate families re-derived from the actual diff with node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack at a97319179 (both output sections read whole; 33 path-derived families + the convention-triggered test-file and error-code families). All run locally and green — including check:adr-0087-registration (red once for a missing disposition marker on the BREAKING changeset; fixed by the not-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-repo pnpm lint (eslint . --no-inline-config, exit 0).
  • NOT MEASURED (not red, not green): scripts/check-test-completeness.mjs exits 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.

⚠️ Boot outcome — a correction this PR carries, measured on #14116

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:

no gate + OS_CLUSTER_REPLICAS=3
→ checkMultiNodeAllowed(3) = {allowed:false, reason:MULTI_NODE_NO_GATE_REASON, refused:3}
↓ serve.ts: warns, leaves clusterConfig undefined
↓ Runtime({cluster: undefined}) → ClusterServicePlugin({}) → defineCluster({}) → driver "memory"
↓ assertClusterDriverSafeForTopology('memory') [OS_CLUSTER_REPLICAS=3]
✗ THROW — boot REFUSED

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.ts comments and the changeset: corrected, with a ⛔ note against restating "never bricks";
  • serve.ts's boot warning: it said downgrading 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);
  • new regression block in multi-node-gate.test.ts pinning 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_016ZC5rNQj3WEet5HAmmAkMs and 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:

  1. origin/main merged in (the branch was 35 commits behind and CONFLICTING, so no workflow was triggering at all). One conflict, in serve.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.
  2. The boot-outcome correction above.

Re-verified at this head after a full workspace build: @objectstack/service-cluster 84/84 (6 files) · @objectstack/cli multi-node + cluster suites 65/65 (4 files). packages/clitsc --noEmit reports the same 69 pre-existing errors with main's serve.ts as 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-review on 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

…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
@os-steveClaude

Copy link
Copy Markdown
CollaboratorAuthor

PM 复核:实现验收通过,但 ⛔ 暂不落地 —— 卡在一个跨仓时序裁决上(#14116)

先把话说清楚:这不是打回,也不是你的问题。 PR 完整、全绿、对着卡做对了,而且正是你自己报的那条 out-of-scope 发现让我停手的。⭐ 那条发现是本轮最有价值的一件事 —— 一个只想着交差的实现会闭嘴不说。

我独立复核了你的发现,成立

$ grep -rn "registerMultiNodeGate" --include=*.ts . | grep -v node_modules | grep -v /dist/ | grep -v '\.test\.'
packages/services/service-cluster/src/index.ts:77 ← 桶再导出
packages/services/service-cluster/src/multi-node-gate.ts:169 ← 定义本身

⇒ 两处命中,全是结构性的,本仓无任何生产注册者⚠️ 反向对照让这个零可读:同形搜 registerDriver 命中真实生产调用点(objectql/src/plugin.ts:625)⇒ grep 是活的。

为什么这挡住落地

方向是对的 —— 授权门 fail-open 本来就不能接受。但沉挂载只能加载载体,不能让载体注册。⇒ 在跳过 EE 配置的那两条启动路径上:

今天单独落 #14114cloud 补齐后
未授权多节点在跑(fail open)
已授权多节点在跑⚠️被拒 —— 响亮降为单节点在跑

⇒ 它在关掉「白嫖」的同一笔里打开了「付费客户失去能力」。这是部署可见的取舍,不是复核能拍的板,所以进决策箱:#14116

记你一笔:失败是响亮的 —— attempt 如实报 loaded-without-gate,serve 降级而不是 brick。这是「难受一下午」和「一次事故」的区别,是你把它做对的。⛔ 但它不能让降级变成免费的。

已办

验收侧我核过、没有异议的

  • 两条强制否定面各有专测:已授权(注册了门、admitted 5 / requested 3)不被挡;单副本与未声明/无意义值(NaN/0/-1)逐字节不变。
  • ablation 先声明方向,变异经 anchored grep 计数(1→0 / 0→1)与 blob 哈希(65ffad01de8c8fbb)在盘上证实,⛔ 非编辑器退出码;restore 以状态证明。⭐ 而且你主动说明了变异模块经同包相对 src 导入到达测试,所以那条解析路径上没有 dist —— 陈旧 dist 的 ablation 根本红不了,这句声明是它有效的前提。
  • 预测 3 红、实测 4 红,你没有掩饰,而是指出第四条是 reset 测试新加的最后一行断言的同一个分支 ⇒ 同类、方向与预测一致。⛔ 事后改预测才是问题,如实报差异不是。
  • check-adr-0087-registration 一度红(BREAKING changeset 缺 disposition),补 marker 后重跑绿 —— 红过并修好、且说出来,比只报最后那个绿好。
  • check-test-completeness 退出 3 = PREREQUISITE NOT MET,记为 NOT MEASURED,⛔ 未当绿也未当红。

你那条「不予立卡」的判断也对

memory.contract.test.ts 的既存 tsc 错误 + 该包无 typecheck 脚本 —— 两者都已被 check:type-check-debt 台账承载且本轮重测绿。为它立卡等于复制一行台账。 ⛔ 不立是对的。


Generated by Claude Code

hotlongand others added 2 commits September 1, 2026 18:23
…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>
@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 2 package(s): @objectstack/cli, @objectstack/service-cluster, touching 9 documentable anchor(s).

17 hand-written doc(s) name something this change touched — list omitted above 15 rows. Re-derive on the tree named below: node scripts/docs-audit/affected-docs.mjs --json 45b9051248f86f362b042fa9de63295a8c224073.

4 release-owned page(s) also affected — read-only, see AGENTS.md Documentation Guardrails.

What this run could not see
  • 5 name(s) were too generic to anchor anything (single lowercase words)
  • the SDK route bridge reached 47 of 219 client-bound route-ledger rows — the other 172 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 172: 14 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 56 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 102 are undecided (no in-repo declaration, on a ledger that has other in-repo registrars — absence and an unreadable spelling are not distinguishable here). The rows themselves: node scripts/docs-audit/affected-docs.mjs --bridge-coverage
  • a page that states a rule by its inputs shares no identifier with the emitter that implements the rule, so an emitter-only diff cannot list it — not on this run and not on any run. Measured on fix(driver-sql): emit varchar(maxLength) for a text field a declared index keys on #11430: content/docs/protocol/objectql/types.mdx documents the text-family column mapping by the ObjectQL type names it maps FROM (text / textarea / html) while the diff changed createColumn; it went unlisted, and it was the page that diff falsified, in four places. No shared token exists to detect this on, so a rule your change carries has to be re-read by hand in the pages that restate it.

Coarse fallback — 26 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): node scripts/docs-audit/affected-docs.mjs --json 45b9051248f86f362b042fa9de63295a8c224073packageMentionDocs.

Which tree this was computed on

This run read content/docs from b2daf8c038843425622c76972db76af82c7501d1 — the merge of head 1ba418ebf41441fbda52fbef3b6e89b43437c6c0 into base 45b9051248f86f362b042fa9de63295a8c224073, which is what actions/checkout gives a pull_request run. Not the PR head.

A worktree cut from an older main holds a different content/docs, so re-deriving there can legitimately return a different list — that is a different tree, not a wrong row. To answer on the same tree:

# 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

⚠️ That checkout carried uncommitted changes, so the commit above does not fully identify what was read.

Advisory only, and a precision-first one (#9192): a page is listed because it names a
symbol, wire route or SDK method this diff touched — not because it mentions a changed
package. Each row says which anchor put it there, so a wrong row is reportable rather than
merely annoying. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs 45b9051248f86f362b042fa9de63295a8c224073 → pass the list as
args.docs, on the commit named under Which tree this was computed on.

@github-actionsgithub-actionsBot added documentation Improvements or additions to documentation tests tooling labels Sep 1, 2026
@hotlong

Copy link
Copy Markdown
Contributor

📋 条款②契约复核 —— 材料呈送,⛔ 本席不自判清标

维护者 2026-09-01 裁定由维护者本人任复核席,本席只摆材料。⛔ 因此本评论不含放行结论needs:contract-review 双载体原样留置。

保险丝读数(细则要求每场必读):CONTRACT_REVIEW_TIER = 'claude-fable-5'scripts/pm/dispatch-gates.mjs:7102);本席服役档 claude-opus-5 ⇒ 读数 ≠ 档位 ⇒ 本席无自判清标资格。维护者席不受此保险丝约束(它约束的是补偿低档派发的代理复核)。


① Derived judgments —— diff 引出的接受集 / 公开面变化,逐条点名

J1 · 接受集收窄(这就是条款②的内容肢本体)

checkMultiNodeAllowed(requested)无 gate 注册时:

requested之前之后
undefined / NaN / 0 / 负数{allowed:true, refused:0, capped:false}逐字节相同
1同上 allow逐字节相同
> 1allow{allowed:false, reason:MULTI_NODE_NO_GATE_REASON, admitted:0, refused:N, capped:false}

⇒ 一个授权谓词的默认方向从 allow 翻到 refuse。触发键 = 运维声明的副本数,与 split-brain-guard.tsdeclaresMultiNodereplicas > 1)同一姿态。已注册 gate 的判定完全不变(byte-identical),已授权部署不受影响 —— 这一条有专门的负例测试钉着。

J2 · 公开面加宽(纯增量)

@objectstack/service-cluster 桶新导出 6 个名字:hasMultiNodeGate()MULTI_NODE_NO_GATE_REASONmountMultiNodeGateFromHost()MULTI_NODE_GATE_CARRIER_PACKAGEStype MultiNodeGateMountAttempttype MultiNodeGateMountReading。⛔ 无删除、无重命名、无签名变更。

⚠️ 一条排除项,免得误记在本 PR 头上:__resetMultiNodeGate(双下划线测试缝)已在 main 上导出git show origin/main:…/index.ts | grep -c = 1),非本 PR 引入。

J3 · ⚠️ 需要维护者定的那一条 —— 跨仓合成效应,两个 PR 各自的正文都没有这个形状

本 PR 的 fail-closed 只在 requested > 1 触发;但已注册的 gate 对任何计数都作答。cloud#1828(已合入 cloud main)让载体在模块加载时注册 ⇒ 两半合起来之后:

无证 + OS_CLUSTER_DRIVER=redis + 单副本 的部署,会被拒并回落进程内驱动 —— 而今天(无 gate ⇒ 放行)它保得住远程驱动。

⛔ 严格说这不是本 PR 发明的规则:EE config 路线今天就是这个行为,两半只是让其他路线与之对齐 —— 这正是卡片的目的。但它是一次真实的接受集变化,且只在跨仓合成后可见,所以点名请裁:接受此对齐,还是要求把无证单副本的远程驱动留出豁免? 本席倾向接受(对齐 > 例外),但这属产品面,⛔ 不自裁。

J4 · 运维可见文案变更(本席在接手后新增,非原实现者产物)

serve.ts 的拒绝告警原文是 downgrading to single-node (in-memory cluster),而进程随后拒启。已改为同时点名回落与随之而来的拒启,并给两条补救(撤销声明 / 授权该能力)。行为零变化,只有措辞

依据(#14116 实测链,已在 multi-node-gate.test.ts 新增回归块钉住):

无 gate + OS_CLUSTER_REPLICAS=3 → checkMultiNodeAllowed(3) = {allowed:false,…}
↓ serve: clusterConfig 保持 undefined
↓ Runtime({cluster:undefined}) → ClusterServicePlugin({}) → defineCluster({}) → "memory"
↓ assertClusterDriverSafeForTopology('memory') [replicas=3] → THROW ⇒ 拒启

fail-closed 的触发条件与 split-brain 护栏的触发条件是同一个声明 ⇒ 在真实多副本部署上,拒启是常态而非例外。⭐ 拒启本身正确(N 副本各持进程内锁 = 护栏要拦的脑裂),错的是「never bricks」那句承诺。


② Semver 定级 vs changeset 声明 —— 一致

  • changeset front-matter:@objectstack/service-cluster: minor · @objectstack/cli: patch
  • 正文自带 BREAKING 横幅,并援引仓内惯例:「BREAKING accept-set narrowing … shipped as minor under the launch-window convention」。
  • ✅ 该惯例实存且在用,非杜撰 —— 同形先例:.changeset/action-newtaburl-requires-opensinnewtab.mdadvanced-plugin-lifecycle-config-retired.mdapproval-auto-cancel-on-record-delete.md
  • ✅ ADR-0087 处置标记在案且门绿(本席改写该行措辞后重跑):1 declared-breaking changeset(s), each carrying an ADR-0087 dispositionnot-required (no-migration-prescription),exit 0。
  • CLI 侧 patch 恰当:一处解构 + 一处 guarded 调用 + 告警文案,无公开面变化。

⇒ ② 项本席未发现不一致。


③ 边界旗处置 —— 逐旗

处置
缺口 2(#13330 CJS/ESM 双实例分裂) 出界保持出界,PR 正文写明效力边界;⛔ 未在本 PR 单独加固该注册表(那正是分诊警告的反模式)。仍开着
cloud 侧载体必须有人做已消:cloud#1827 → cloud#1828 合入 cloud main cfbd4acf,两个载体包均在模块加载时注册。PR 正文已从「需要后续」改写为「已落地」。
「never bricks」⚠️原实现者未挂此旗 —— 是本席在 #14116 测量时发现的证否。已在 4 处(gate 模块、mount 模块、serve.ts、changeset)改正 + 加回归测试。⇒ 这是 ① J4 请裁的对象。
check-test-completeness.mjs 本地 exit 3(PREREQUISITE NOT MET)归 CI;CI 已绿。

落地前检三条的当前读数

#读数
席内契约档 PASS 在案未满足 —— 待维护者本场裁定
needs:contract-review 双载体已清未清(按细则原样留置,本席无资格剥)
PR 全部 check 全绿(非 required 子集)33 条:29 SUCCESS / 4 SKIPPED / 0 红

另两项前置也已机读:check-clause2-carriers.mjs --pair 14114 → 双载体可读且一致;check-governed-merges.mjs --test <最终文件表>0 of 7NOT governed,普通队列落地适用(⛔ 非受管面,不走 draft-only 终局)。

裁 PASS 即可剥标 → ready → 入队;裁 FAIL 或要求修改则走补丁轮。


Generated by Claude Code

@hotlong

Copy link
Copy Markdown
Contributor

⚖️ 条款②契约复核 PASS —— 维护者本人任复核席,2026-09-01

裁定:J3 选 A(接受对齐),verbatim「A」。维护者亲自复核,档位问题不适用(保险丝约束的是补偿低档派发的代理复核席,不是维护者席;本席 claude-opus-5 ⛔ 未自判,材料呈送见复核材料评论)。

被裁的那一条,连同裁定理由一并入档

问题:本 PR 与已合入的 cloud#1828 合成后,无证 + OS_CLUSTER_DRIVER=redis + 单副本的部署会被拒并回落进程内驱动。

A —— 门对任何副本数都作答,所有启动路线与 EE config 路线今天的行为对齐。⛔ 不给单副本开豁免。

理由(业务面,记录在案以免日后被当成"顺手收紧")OS_CLUSTER_REPLICAS 是运维自己声明、系统无法核实的数字,且本仓部署文档已记载 docker compose --scale app=N 只抬高真实副本数、不抬高运行时被告知的数(cloud#1502 的"静默脑裂的门")。⇒ 若给单副本豁免,一个未授权部署只需把该变量写成 1--scale app=5,门永远不会被问,远程驱动照常发放,五个副本真的在协同 —— 收费能力被一行环境变量拿走,且不可检测。选 A 后,谎报副本数得到的是"各跑各的",不是"免费的集群"。

代价一并记明:一批未测到的"脱离模板 + 无证 + 配了 redis + 单副本"部署会在升级后看到一条拒绝。功能上近乎无损(单进程本就该用进程内驱动,脑裂只在跨进程存在),受支持的部署模板不产生该形状(cloud#1742 核查:模板均 pin OS_CLUSTER_REPLICAS=1 且不配 redis)。

三项清单归档

落地前检(细则三条,维护者 2026-08-31「同意」)

#读数
席内契约档 PASS 在案✅ 本评论
needs:contract-review 双载体已清✅ 同笔剥(PR + 卡),机读复验见下
PR 全部 check 全绿(⛔ 非 required 子集)✅ 33 条:29 SUCCESS / 4 SKIPPED / 0 红

治理面谓词在最终文件表上重跑:0 of 7 ⇒ NOT governed,普通队列落地适用(⛔ 非受管面,draft-only 终局不适用)。

⇒ 剥标 → ready → 入队。


Generated by Claude Code

@hotlong
hotlong marked this pull request as ready for review September 1, 2026 11:10
@hotlong
hotlong enabled auto-merge September 1, 2026 11:10
@hotlong
hotlong added this pull request to the merge queueSep 1, 2026
Merged via the queue into main with commit 4d672c4Sep 1, 2026
43 checks passed
@hotlong
hotlong deleted the claude/issue-13537-multinode-gate-mount branch September 1, 2026 11:40
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentationImprovements or additions to documentationsize/lteststooling

Projects

None yet

3 participants

@os-steve@hotlong@claude
, '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

fix(service-cluster,cli): multi-node gate fails closed when unregistered, and mounts on every boot route - #14114

Merged
hotlong merged 5 commits into
mainfrom
claude/issue-13537-multinode-gate-mount
Sep 1, 2026
Merged

fix(service-cluster,cli): multi-node gate fails closed when unregistered, and mounts on every boot route#14114
hotlong merged 5 commits into
mainfrom
claude/issue-13537-multinode-gate-mount

Conversation

@os-steve

@os-steveos-steve commented Sep 1, 2026

Copy link
Copy Markdown
Collaborator

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):
checkMultiNodeAllowed used to return allowed: true whenever 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), so os serve drops 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_REPLICAS unset, 1, 0, negative, NaN) keeps the historical allow: it declares no multi-node topology, so there is nothing to gate — the same > 1 posture as split-brain-guard.ts's declaresMultiNode. 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 in packages/cli/src/commands/serve.ts):
registerMultiNodeGate was reachable from exactly one place — the EE app config (cloud repo) executing — so the thin-extension and OS_ARTIFACT_URL artifact-direct boot routes never mounted the gate at all. New mountMultiNodeGateFromHost(importFromHost) lives in domain:services and owns the carrier list (MULTI_NODE_GATE_CARRIER_PACKAGES: @objectstack/security-enterprise, @objectstack/organizations — both roster-declared enterprise runtimes, drift-tested against PLATFORM_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. serve calls 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 new import()/importFromHost(...) load sites in serve.ts — the carrier loads run inside service-cluster through 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):

  • a licensed deployment whose registration lands on the wrong instance is refused, loudly (fail-closed) instead of running an unlicensed cluster silently (fail-open) — the ruled direction, but operators of such deployments will see that refusal until the split is fixed and/or a carrier registers at module load on the instance the consult reads. ⛔ "Refused", not "downgraded": see "Boot outcome" below;
  • mountMultiNodeGateFromHost honestly 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.sh disclosure 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 main at cfbd4acf, adding a module-scope registerMultiNodeGate to BOTH @objectstack/security-enterprise and @objectstack/organizations — i.e. both names on this PR's MULTI_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 reporting loaded-without-gate on 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), and packages/cli/src/commands/serve.ts (mount call site). domain:cli half declared: the serve call site — one destructure + one guarded call; the mount logic and carrier list live in domain:services.

Verification

Negatives (mandated, one test each — both green):

  • Entitled deployment not blocked: registered gate with admitted: 5, requested: 3allowed: true, refused: 0, capped: false — the fail-closed branch is unreachable the moment a gate is registered ("never blocks a properly-entitled deployment").
  • Single-replica path unchanged: 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 + 2 and no gate + 9 → refused with MULTI_NODE_NO_GATE_REASON (the 9 pin 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 at ee607b488, whose packages/services/service-cluster tree is byte-identical to this PR's head (verified: git diff 30c2d3d02 ee607b488 -- packages/services/service-cluster is empty). Mutated blob de8c8fbb vs HEAD blob 65ffad01; 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 HEAD empty, 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 at 30c2d3d02, this PR's head):

  • Build: dependency closures + full turbo run build --filter='./packages/*' --filter='./packages/*/*' (lint.yml parity) — 70/70 successful.
  • Tests at 30c2d3d02: @objectstack/service-cluster 77/77 · @objectstack/service-cluster-redis (direct dist-coupled consumer) 28/28 · @objectstack/cli targeted suites (serve-multi-node-cap-advisory + .pin, serve-multi-node-cap-telemetry, serve-cluster-host-resolution source sweep, serve-app-anchored-optional-import.e2e real boot) 71/71 — vitest's own "Test Files N passed / Tests N passed" lines are the cited verdicts.
  • Typecheck: @objectstack/clitsc --noEmit exit 0 at 30c2d3d02 with serve.ts proven in the program via --listFiles. @objectstack/service-cluster has NO typecheck script; a manual tsc --noEmit --listFiles (run at ee607b488, 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 untouched src/memory/memory.contract.test.ts (carried by the type-check-debt ledger — see next line).
  • Gate families re-derived from the actual diff with node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack at a97319179 (both output sections read whole; 33 path-derived families + the convention-triggered test-file and error-code families). All run locally and green — including check:adr-0087-registration (red once for a missing disposition marker on the BREAKING changeset; fixed by the not-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-repo pnpm lint (eslint . --no-inline-config, exit 0).
  • NOT MEASURED (not red, not green): scripts/check-test-completeness.mjs exits 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.

⚠️ Boot outcome — a correction this PR carries, measured on #14116

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:

no gate + OS_CLUSTER_REPLICAS=3
→ checkMultiNodeAllowed(3) = {allowed:false, reason:MULTI_NODE_NO_GATE_REASON, refused:3}
↓ serve.ts: warns, leaves clusterConfig undefined
↓ Runtime({cluster: undefined}) → ClusterServicePlugin({}) → defineCluster({}) → driver "memory"
↓ assertClusterDriverSafeForTopology('memory') [OS_CLUSTER_REPLICAS=3]
✗ THROW — boot REFUSED

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.ts comments and the changeset: corrected, with a ⛔ note against restating "never bricks";
  • serve.ts's boot warning: it said downgrading 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);
  • new regression block in multi-node-gate.test.ts pinning 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_016ZC5rNQj3WEet5HAmmAkMs and 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:

  1. origin/main merged in (the branch was 35 commits behind and CONFLICTING, so no workflow was triggering at all). One conflict, in serve.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.
  2. The boot-outcome correction above.

Re-verified at this head after a full workspace build: @objectstack/service-cluster 84/84 (6 files) · @objectstack/cli multi-node + cluster suites 65/65 (4 files). packages/clitsc --noEmit reports the same 69 pre-existing errors with main's serve.ts as 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-review on 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

…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
@os-steveClaude

Copy link
Copy Markdown
CollaboratorAuthor

PM 复核:实现验收通过,但 ⛔ 暂不落地 —— 卡在一个跨仓时序裁决上(#14116)

先把话说清楚:这不是打回,也不是你的问题。 PR 完整、全绿、对着卡做对了,而且正是你自己报的那条 out-of-scope 发现让我停手的。⭐ 那条发现是本轮最有价值的一件事 —— 一个只想着交差的实现会闭嘴不说。

我独立复核了你的发现,成立

$ grep -rn "registerMultiNodeGate" --include=*.ts . | grep -v node_modules | grep -v /dist/ | grep -v '\.test\.'
packages/services/service-cluster/src/index.ts:77 ← 桶再导出
packages/services/service-cluster/src/multi-node-gate.ts:169 ← 定义本身

⇒ 两处命中,全是结构性的,本仓无任何生产注册者⚠️ 反向对照让这个零可读:同形搜 registerDriver 命中真实生产调用点(objectql/src/plugin.ts:625)⇒ grep 是活的。

为什么这挡住落地

方向是对的 —— 授权门 fail-open 本来就不能接受。但沉挂载只能加载载体,不能让载体注册。⇒ 在跳过 EE 配置的那两条启动路径上:

今天单独落 #14114cloud 补齐后
未授权多节点在跑(fail open)
已授权多节点在跑⚠️被拒 —— 响亮降为单节点在跑

⇒ 它在关掉「白嫖」的同一笔里打开了「付费客户失去能力」。这是部署可见的取舍,不是复核能拍的板,所以进决策箱:#14116

记你一笔:失败是响亮的 —— attempt 如实报 loaded-without-gate,serve 降级而不是 brick。这是「难受一下午」和「一次事故」的区别,是你把它做对的。⛔ 但它不能让降级变成免费的。

已办

验收侧我核过、没有异议的

  • 两条强制否定面各有专测:已授权(注册了门、admitted 5 / requested 3)不被挡;单副本与未声明/无意义值(NaN/0/-1)逐字节不变。
  • ablation 先声明方向,变异经 anchored grep 计数(1→0 / 0→1)与 blob 哈希(65ffad01de8c8fbb)在盘上证实,⛔ 非编辑器退出码;restore 以状态证明。⭐ 而且你主动说明了变异模块经同包相对 src 导入到达测试,所以那条解析路径上没有 dist —— 陈旧 dist 的 ablation 根本红不了,这句声明是它有效的前提。
  • 预测 3 红、实测 4 红,你没有掩饰,而是指出第四条是 reset 测试新加的最后一行断言的同一个分支 ⇒ 同类、方向与预测一致。⛔ 事后改预测才是问题,如实报差异不是。
  • check-adr-0087-registration 一度红(BREAKING changeset 缺 disposition),补 marker 后重跑绿 —— 红过并修好、且说出来,比只报最后那个绿好。
  • check-test-completeness 退出 3 = PREREQUISITE NOT MET,记为 NOT MEASURED,⛔ 未当绿也未当红。

你那条「不予立卡」的判断也对

memory.contract.test.ts 的既存 tsc 错误 + 该包无 typecheck 脚本 —— 两者都已被 check:type-check-debt 台账承载且本轮重测绿。为它立卡等于复制一行台账。 ⛔ 不立是对的。


Generated by Claude Code

hotlongand others added 2 commits September 1, 2026 18:23
…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>
@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 2 package(s): @objectstack/cli, @objectstack/service-cluster, touching 9 documentable anchor(s).

17 hand-written doc(s) name something this change touched — list omitted above 15 rows. Re-derive on the tree named below: node scripts/docs-audit/affected-docs.mjs --json 45b9051248f86f362b042fa9de63295a8c224073.

4 release-owned page(s) also affected — read-only, see AGENTS.md Documentation Guardrails.

What this run could not see
  • 5 name(s) were too generic to anchor anything (single lowercase words)
  • the SDK route bridge reached 47 of 219 client-bound route-ledger rows — the other 172 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 172: 14 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 56 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 102 are undecided (no in-repo declaration, on a ledger that has other in-repo registrars — absence and an unreadable spelling are not distinguishable here). The rows themselves: node scripts/docs-audit/affected-docs.mjs --bridge-coverage
  • a page that states a rule by its inputs shares no identifier with the emitter that implements the rule, so an emitter-only diff cannot list it — not on this run and not on any run. Measured on fix(driver-sql): emit varchar(maxLength) for a text field a declared index keys on #11430: content/docs/protocol/objectql/types.mdx documents the text-family column mapping by the ObjectQL type names it maps FROM (text / textarea / html) while the diff changed createColumn; it went unlisted, and it was the page that diff falsified, in four places. No shared token exists to detect this on, so a rule your change carries has to be re-read by hand in the pages that restate it.

Coarse fallback — 26 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): node scripts/docs-audit/affected-docs.mjs --json 45b9051248f86f362b042fa9de63295a8c224073packageMentionDocs.

Which tree this was computed on

This run read content/docs from b2daf8c038843425622c76972db76af82c7501d1 — the merge of head 1ba418ebf41441fbda52fbef3b6e89b43437c6c0 into base 45b9051248f86f362b042fa9de63295a8c224073, which is what actions/checkout gives a pull_request run. Not the PR head.

A worktree cut from an older main holds a different content/docs, so re-deriving there can legitimately return a different list — that is a different tree, not a wrong row. To answer on the same tree:

# 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

⚠️ That checkout carried uncommitted changes, so the commit above does not fully identify what was read.

Advisory only, and a precision-first one (#9192): a page is listed because it names a
symbol, wire route or SDK method this diff touched — not because it mentions a changed
package. Each row says which anchor put it there, so a wrong row is reportable rather than
merely annoying. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs 45b9051248f86f362b042fa9de63295a8c224073 → pass the list as
args.docs, on the commit named under Which tree this was computed on.

@github-actionsgithub-actionsBot added documentation Improvements or additions to documentation tests tooling labels Sep 1, 2026
@hotlong

Copy link
Copy Markdown
Contributor

📋 条款②契约复核 —— 材料呈送,⛔ 本席不自判清标

维护者 2026-09-01 裁定由维护者本人任复核席,本席只摆材料。⛔ 因此本评论不含放行结论needs:contract-review 双载体原样留置。

保险丝读数(细则要求每场必读):CONTRACT_REVIEW_TIER = 'claude-fable-5'scripts/pm/dispatch-gates.mjs:7102);本席服役档 claude-opus-5 ⇒ 读数 ≠ 档位 ⇒ 本席无自判清标资格。维护者席不受此保险丝约束(它约束的是补偿低档派发的代理复核)。


① Derived judgments —— diff 引出的接受集 / 公开面变化,逐条点名

J1 · 接受集收窄(这就是条款②的内容肢本体)

checkMultiNodeAllowed(requested)无 gate 注册时:

requested之前之后
undefined / NaN / 0 / 负数{allowed:true, refused:0, capped:false}逐字节相同
1同上 allow逐字节相同
> 1allow{allowed:false, reason:MULTI_NODE_NO_GATE_REASON, admitted:0, refused:N, capped:false}

⇒ 一个授权谓词的默认方向从 allow 翻到 refuse。触发键 = 运维声明的副本数,与 split-brain-guard.tsdeclaresMultiNodereplicas > 1)同一姿态。已注册 gate 的判定完全不变(byte-identical),已授权部署不受影响 —— 这一条有专门的负例测试钉着。

J2 · 公开面加宽(纯增量)

@objectstack/service-cluster 桶新导出 6 个名字:hasMultiNodeGate()MULTI_NODE_NO_GATE_REASONmountMultiNodeGateFromHost()MULTI_NODE_GATE_CARRIER_PACKAGEStype MultiNodeGateMountAttempttype MultiNodeGateMountReading。⛔ 无删除、无重命名、无签名变更。

⚠️ 一条排除项,免得误记在本 PR 头上:__resetMultiNodeGate(双下划线测试缝)已在 main 上导出git show origin/main:…/index.ts | grep -c = 1),非本 PR 引入。

J3 · ⚠️ 需要维护者定的那一条 —— 跨仓合成效应,两个 PR 各自的正文都没有这个形状

本 PR 的 fail-closed 只在 requested > 1 触发;但已注册的 gate 对任何计数都作答。cloud#1828(已合入 cloud main)让载体在模块加载时注册 ⇒ 两半合起来之后:

无证 + OS_CLUSTER_DRIVER=redis + 单副本 的部署,会被拒并回落进程内驱动 —— 而今天(无 gate ⇒ 放行)它保得住远程驱动。

⛔ 严格说这不是本 PR 发明的规则:EE config 路线今天就是这个行为,两半只是让其他路线与之对齐 —— 这正是卡片的目的。但它是一次真实的接受集变化,且只在跨仓合成后可见,所以点名请裁:接受此对齐,还是要求把无证单副本的远程驱动留出豁免? 本席倾向接受(对齐 > 例外),但这属产品面,⛔ 不自裁。

J4 · 运维可见文案变更(本席在接手后新增,非原实现者产物)

serve.ts 的拒绝告警原文是 downgrading to single-node (in-memory cluster),而进程随后拒启。已改为同时点名回落与随之而来的拒启,并给两条补救(撤销声明 / 授权该能力)。行为零变化,只有措辞

依据(#14116 实测链,已在 multi-node-gate.test.ts 新增回归块钉住):

无 gate + OS_CLUSTER_REPLICAS=3 → checkMultiNodeAllowed(3) = {allowed:false,…}
↓ serve: clusterConfig 保持 undefined
↓ Runtime({cluster:undefined}) → ClusterServicePlugin({}) → defineCluster({}) → "memory"
↓ assertClusterDriverSafeForTopology('memory') [replicas=3] → THROW ⇒ 拒启

fail-closed 的触发条件与 split-brain 护栏的触发条件是同一个声明 ⇒ 在真实多副本部署上,拒启是常态而非例外。⭐ 拒启本身正确(N 副本各持进程内锁 = 护栏要拦的脑裂),错的是「never bricks」那句承诺。


② Semver 定级 vs changeset 声明 —— 一致

  • changeset front-matter:@objectstack/service-cluster: minor · @objectstack/cli: patch
  • 正文自带 BREAKING 横幅,并援引仓内惯例:「BREAKING accept-set narrowing … shipped as minor under the launch-window convention」。
  • ✅ 该惯例实存且在用,非杜撰 —— 同形先例:.changeset/action-newtaburl-requires-opensinnewtab.mdadvanced-plugin-lifecycle-config-retired.mdapproval-auto-cancel-on-record-delete.md
  • ✅ ADR-0087 处置标记在案且门绿(本席改写该行措辞后重跑):1 declared-breaking changeset(s), each carrying an ADR-0087 dispositionnot-required (no-migration-prescription),exit 0。
  • CLI 侧 patch 恰当:一处解构 + 一处 guarded 调用 + 告警文案,无公开面变化。

⇒ ② 项本席未发现不一致。


③ 边界旗处置 —— 逐旗

处置
缺口 2(#13330 CJS/ESM 双实例分裂) 出界保持出界,PR 正文写明效力边界;⛔ 未在本 PR 单独加固该注册表(那正是分诊警告的反模式)。仍开着
cloud 侧载体必须有人做已消:cloud#1827 → cloud#1828 合入 cloud main cfbd4acf,两个载体包均在模块加载时注册。PR 正文已从「需要后续」改写为「已落地」。
「never bricks」⚠️原实现者未挂此旗 —— 是本席在 #14116 测量时发现的证否。已在 4 处(gate 模块、mount 模块、serve.ts、changeset)改正 + 加回归测试。⇒ 这是 ① J4 请裁的对象。
check-test-completeness.mjs 本地 exit 3(PREREQUISITE NOT MET)归 CI;CI 已绿。

落地前检三条的当前读数

#读数
席内契约档 PASS 在案未满足 —— 待维护者本场裁定
needs:contract-review 双载体已清未清(按细则原样留置,本席无资格剥)
PR 全部 check 全绿(非 required 子集)33 条:29 SUCCESS / 4 SKIPPED / 0 红

另两项前置也已机读:check-clause2-carriers.mjs --pair 14114 → 双载体可读且一致;check-governed-merges.mjs --test <最终文件表>0 of 7NOT governed,普通队列落地适用(⛔ 非受管面,不走 draft-only 终局)。

裁 PASS 即可剥标 → ready → 入队;裁 FAIL 或要求修改则走补丁轮。


Generated by Claude Code

@hotlong

Copy link
Copy Markdown
Contributor

⚖️ 条款②契约复核 PASS —— 维护者本人任复核席,2026-09-01

裁定:J3 选 A(接受对齐),verbatim「A」。维护者亲自复核,档位问题不适用(保险丝约束的是补偿低档派发的代理复核席,不是维护者席;本席 claude-opus-5 ⛔ 未自判,材料呈送见复核材料评论)。

被裁的那一条,连同裁定理由一并入档

问题:本 PR 与已合入的 cloud#1828 合成后,无证 + OS_CLUSTER_DRIVER=redis + 单副本的部署会被拒并回落进程内驱动。

A —— 门对任何副本数都作答,所有启动路线与 EE config 路线今天的行为对齐。⛔ 不给单副本开豁免。

理由(业务面,记录在案以免日后被当成"顺手收紧")OS_CLUSTER_REPLICAS 是运维自己声明、系统无法核实的数字,且本仓部署文档已记载 docker compose --scale app=N 只抬高真实副本数、不抬高运行时被告知的数(cloud#1502 的"静默脑裂的门")。⇒ 若给单副本豁免,一个未授权部署只需把该变量写成 1--scale app=5,门永远不会被问,远程驱动照常发放,五个副本真的在协同 —— 收费能力被一行环境变量拿走,且不可检测。选 A 后,谎报副本数得到的是"各跑各的",不是"免费的集群"。

代价一并记明:一批未测到的"脱离模板 + 无证 + 配了 redis + 单副本"部署会在升级后看到一条拒绝。功能上近乎无损(单进程本就该用进程内驱动,脑裂只在跨进程存在),受支持的部署模板不产生该形状(cloud#1742 核查:模板均 pin OS_CLUSTER_REPLICAS=1 且不配 redis)。

三项清单归档

落地前检(细则三条,维护者 2026-08-31「同意」)

#读数
席内契约档 PASS 在案✅ 本评论
needs:contract-review 双载体已清✅ 同笔剥(PR + 卡),机读复验见下
PR 全部 check 全绿(⛔ 非 required 子集)✅ 33 条:29 SUCCESS / 4 SKIPPED / 0 红

治理面谓词在最终文件表上重跑:0 of 7 ⇒ NOT governed,普通队列落地适用(⛔ 非受管面,draft-only 终局不适用)。

⇒ 剥标 → ready → 入队。


Generated by Claude Code

@hotlong
hotlong marked this pull request as ready for review September 1, 2026 11:10
@hotlong
hotlong enabled auto-merge September 1, 2026 11:10
@hotlong
hotlong added this pull request to the merge queueSep 1, 2026
Merged via the queue into main with commit 4d672c4Sep 1, 2026
43 checks passed
@hotlong
hotlong deleted the claude/issue-13537-multinode-gate-mount branch September 1, 2026 11:40
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentationImprovements or additions to documentationsize/lteststooling

Projects

None yet

3 participants

@os-steve@hotlong@claude
, '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

fix(service-cluster,cli): multi-node gate fails closed when unregistered, and mounts on every boot route - #14114

Merged
hotlong merged 5 commits into
mainfrom
claude/issue-13537-multinode-gate-mount
Sep 1, 2026
Merged

fix(service-cluster,cli): multi-node gate fails closed when unregistered, and mounts on every boot route#14114
hotlong merged 5 commits into
mainfrom
claude/issue-13537-multinode-gate-mount

Conversation

@os-steve

@os-steveos-steve commented Sep 1, 2026

Copy link
Copy Markdown
Collaborator

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):
checkMultiNodeAllowed used to return allowed: true whenever 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), so os serve drops 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_REPLICAS unset, 1, 0, negative, NaN) keeps the historical allow: it declares no multi-node topology, so there is nothing to gate — the same > 1 posture as split-brain-guard.ts's declaresMultiNode. 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 in packages/cli/src/commands/serve.ts):
registerMultiNodeGate was reachable from exactly one place — the EE app config (cloud repo) executing — so the thin-extension and OS_ARTIFACT_URL artifact-direct boot routes never mounted the gate at all. New mountMultiNodeGateFromHost(importFromHost) lives in domain:services and owns the carrier list (MULTI_NODE_GATE_CARRIER_PACKAGES: @objectstack/security-enterprise, @objectstack/organizations — both roster-declared enterprise runtimes, drift-tested against PLATFORM_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. serve calls 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 new import()/importFromHost(...) load sites in serve.ts — the carrier loads run inside service-cluster through 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):

  • a licensed deployment whose registration lands on the wrong instance is refused, loudly (fail-closed) instead of running an unlicensed cluster silently (fail-open) — the ruled direction, but operators of such deployments will see that refusal until the split is fixed and/or a carrier registers at module load on the instance the consult reads. ⛔ "Refused", not "downgraded": see "Boot outcome" below;
  • mountMultiNodeGateFromHost honestly 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.sh disclosure 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 main at cfbd4acf, adding a module-scope registerMultiNodeGate to BOTH @objectstack/security-enterprise and @objectstack/organizations — i.e. both names on this PR's MULTI_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 reporting loaded-without-gate on 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), and packages/cli/src/commands/serve.ts (mount call site). domain:cli half declared: the serve call site — one destructure + one guarded call; the mount logic and carrier list live in domain:services.

Verification

Negatives (mandated, one test each — both green):

  • Entitled deployment not blocked: registered gate with admitted: 5, requested: 3allowed: true, refused: 0, capped: false — the fail-closed branch is unreachable the moment a gate is registered ("never blocks a properly-entitled deployment").
  • Single-replica path unchanged: 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 + 2 and no gate + 9 → refused with MULTI_NODE_NO_GATE_REASON (the 9 pin 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 at ee607b488, whose packages/services/service-cluster tree is byte-identical to this PR's head (verified: git diff 30c2d3d02 ee607b488 -- packages/services/service-cluster is empty). Mutated blob de8c8fbb vs HEAD blob 65ffad01; 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 HEAD empty, 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 at 30c2d3d02, this PR's head):

  • Build: dependency closures + full turbo run build --filter='./packages/*' --filter='./packages/*/*' (lint.yml parity) — 70/70 successful.
  • Tests at 30c2d3d02: @objectstack/service-cluster 77/77 · @objectstack/service-cluster-redis (direct dist-coupled consumer) 28/28 · @objectstack/cli targeted suites (serve-multi-node-cap-advisory + .pin, serve-multi-node-cap-telemetry, serve-cluster-host-resolution source sweep, serve-app-anchored-optional-import.e2e real boot) 71/71 — vitest's own "Test Files N passed / Tests N passed" lines are the cited verdicts.
  • Typecheck: @objectstack/clitsc --noEmit exit 0 at 30c2d3d02 with serve.ts proven in the program via --listFiles. @objectstack/service-cluster has NO typecheck script; a manual tsc --noEmit --listFiles (run at ee607b488, 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 untouched src/memory/memory.contract.test.ts (carried by the type-check-debt ledger — see next line).
  • Gate families re-derived from the actual diff with node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack at a97319179 (both output sections read whole; 33 path-derived families + the convention-triggered test-file and error-code families). All run locally and green — including check:adr-0087-registration (red once for a missing disposition marker on the BREAKING changeset; fixed by the not-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-repo pnpm lint (eslint . --no-inline-config, exit 0).
  • NOT MEASURED (not red, not green): scripts/check-test-completeness.mjs exits 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.

⚠️ Boot outcome — a correction this PR carries, measured on #14116

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:

no gate + OS_CLUSTER_REPLICAS=3
→ checkMultiNodeAllowed(3) = {allowed:false, reason:MULTI_NODE_NO_GATE_REASON, refused:3}
↓ serve.ts: warns, leaves clusterConfig undefined
↓ Runtime({cluster: undefined}) → ClusterServicePlugin({}) → defineCluster({}) → driver "memory"
↓ assertClusterDriverSafeForTopology('memory') [OS_CLUSTER_REPLICAS=3]
✗ THROW — boot REFUSED

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.ts comments and the changeset: corrected, with a ⛔ note against restating "never bricks";
  • serve.ts's boot warning: it said downgrading 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);
  • new regression block in multi-node-gate.test.ts pinning 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_016ZC5rNQj3WEet5HAmmAkMs and 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:

  1. origin/main merged in (the branch was 35 commits behind and CONFLICTING, so no workflow was triggering at all). One conflict, in serve.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.
  2. The boot-outcome correction above.

Re-verified at this head after a full workspace build: @objectstack/service-cluster 84/84 (6 files) · @objectstack/cli multi-node + cluster suites 65/65 (4 files). packages/clitsc --noEmit reports the same 69 pre-existing errors with main's serve.ts as 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-review on 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

…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
@os-steveClaude

Copy link
Copy Markdown
CollaboratorAuthor

PM 复核:实现验收通过,但 ⛔ 暂不落地 —— 卡在一个跨仓时序裁决上(#14116)

先把话说清楚:这不是打回,也不是你的问题。 PR 完整、全绿、对着卡做对了,而且正是你自己报的那条 out-of-scope 发现让我停手的。⭐ 那条发现是本轮最有价值的一件事 —— 一个只想着交差的实现会闭嘴不说。

我独立复核了你的发现,成立

$ grep -rn "registerMultiNodeGate" --include=*.ts . | grep -v node_modules | grep -v /dist/ | grep -v '\.test\.'
packages/services/service-cluster/src/index.ts:77 ← 桶再导出
packages/services/service-cluster/src/multi-node-gate.ts:169 ← 定义本身

⇒ 两处命中,全是结构性的,本仓无任何生产注册者⚠️ 反向对照让这个零可读:同形搜 registerDriver 命中真实生产调用点(objectql/src/plugin.ts:625)⇒ grep 是活的。

为什么这挡住落地

方向是对的 —— 授权门 fail-open 本来就不能接受。但沉挂载只能加载载体,不能让载体注册。⇒ 在跳过 EE 配置的那两条启动路径上:

今天单独落 #14114cloud 补齐后
未授权多节点在跑(fail open)
已授权多节点在跑⚠️被拒 —— 响亮降为单节点在跑

⇒ 它在关掉「白嫖」的同一笔里打开了「付费客户失去能力」。这是部署可见的取舍,不是复核能拍的板,所以进决策箱:#14116

记你一笔:失败是响亮的 —— attempt 如实报 loaded-without-gate,serve 降级而不是 brick。这是「难受一下午」和「一次事故」的区别,是你把它做对的。⛔ 但它不能让降级变成免费的。

已办

验收侧我核过、没有异议的

  • 两条强制否定面各有专测:已授权(注册了门、admitted 5 / requested 3)不被挡;单副本与未声明/无意义值(NaN/0/-1)逐字节不变。
  • ablation 先声明方向,变异经 anchored grep 计数(1→0 / 0→1)与 blob 哈希(65ffad01de8c8fbb)在盘上证实,⛔ 非编辑器退出码;restore 以状态证明。⭐ 而且你主动说明了变异模块经同包相对 src 导入到达测试,所以那条解析路径上没有 dist —— 陈旧 dist 的 ablation 根本红不了,这句声明是它有效的前提。
  • 预测 3 红、实测 4 红,你没有掩饰,而是指出第四条是 reset 测试新加的最后一行断言的同一个分支 ⇒ 同类、方向与预测一致。⛔ 事后改预测才是问题,如实报差异不是。
  • check-adr-0087-registration 一度红(BREAKING changeset 缺 disposition),补 marker 后重跑绿 —— 红过并修好、且说出来,比只报最后那个绿好。
  • check-test-completeness 退出 3 = PREREQUISITE NOT MET,记为 NOT MEASURED,⛔ 未当绿也未当红。

你那条「不予立卡」的判断也对

memory.contract.test.ts 的既存 tsc 错误 + 该包无 typecheck 脚本 —— 两者都已被 check:type-check-debt 台账承载且本轮重测绿。为它立卡等于复制一行台账。 ⛔ 不立是对的。


Generated by Claude Code

hotlongand others added 2 commits September 1, 2026 18:23
…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>
@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 2 package(s): @objectstack/cli, @objectstack/service-cluster, touching 9 documentable anchor(s).

17 hand-written doc(s) name something this change touched — list omitted above 15 rows. Re-derive on the tree named below: node scripts/docs-audit/affected-docs.mjs --json 45b9051248f86f362b042fa9de63295a8c224073.

4 release-owned page(s) also affected — read-only, see AGENTS.md Documentation Guardrails.

What this run could not see
  • 5 name(s) were too generic to anchor anything (single lowercase words)
  • the SDK route bridge reached 47 of 219 client-bound route-ledger rows — the other 172 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 172: 14 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 56 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 102 are undecided (no in-repo declaration, on a ledger that has other in-repo registrars — absence and an unreadable spelling are not distinguishable here). The rows themselves: node scripts/docs-audit/affected-docs.mjs --bridge-coverage
  • a page that states a rule by its inputs shares no identifier with the emitter that implements the rule, so an emitter-only diff cannot list it — not on this run and not on any run. Measured on fix(driver-sql): emit varchar(maxLength) for a text field a declared index keys on #11430: content/docs/protocol/objectql/types.mdx documents the text-family column mapping by the ObjectQL type names it maps FROM (text / textarea / html) while the diff changed createColumn; it went unlisted, and it was the page that diff falsified, in four places. No shared token exists to detect this on, so a rule your change carries has to be re-read by hand in the pages that restate it.

Coarse fallback — 26 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): node scripts/docs-audit/affected-docs.mjs --json 45b9051248f86f362b042fa9de63295a8c224073packageMentionDocs.

Which tree this was computed on

This run read content/docs from b2daf8c038843425622c76972db76af82c7501d1 — the merge of head 1ba418ebf41441fbda52fbef3b6e89b43437c6c0 into base 45b9051248f86f362b042fa9de63295a8c224073, which is what actions/checkout gives a pull_request run. Not the PR head.

A worktree cut from an older main holds a different content/docs, so re-deriving there can legitimately return a different list — that is a different tree, not a wrong row. To answer on the same tree:

# 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

⚠️ That checkout carried uncommitted changes, so the commit above does not fully identify what was read.

Advisory only, and a precision-first one (#9192): a page is listed because it names a
symbol, wire route or SDK method this diff touched — not because it mentions a changed
package. Each row says which anchor put it there, so a wrong row is reportable rather than
merely annoying. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs 45b9051248f86f362b042fa9de63295a8c224073 → pass the list as
args.docs, on the commit named under Which tree this was computed on.

@github-actionsgithub-actionsBot added documentation Improvements or additions to documentation tests tooling labels Sep 1, 2026
@hotlong

Copy link
Copy Markdown
Contributor

📋 条款②契约复核 —— 材料呈送,⛔ 本席不自判清标

维护者 2026-09-01 裁定由维护者本人任复核席,本席只摆材料。⛔ 因此本评论不含放行结论needs:contract-review 双载体原样留置。

保险丝读数(细则要求每场必读):CONTRACT_REVIEW_TIER = 'claude-fable-5'scripts/pm/dispatch-gates.mjs:7102);本席服役档 claude-opus-5 ⇒ 读数 ≠ 档位 ⇒ 本席无自判清标资格。维护者席不受此保险丝约束(它约束的是补偿低档派发的代理复核)。


① Derived judgments —— diff 引出的接受集 / 公开面变化,逐条点名

J1 · 接受集收窄(这就是条款②的内容肢本体)

checkMultiNodeAllowed(requested)无 gate 注册时:

requested之前之后
undefined / NaN / 0 / 负数{allowed:true, refused:0, capped:false}逐字节相同
1同上 allow逐字节相同
> 1allow{allowed:false, reason:MULTI_NODE_NO_GATE_REASON, admitted:0, refused:N, capped:false}

⇒ 一个授权谓词的默认方向从 allow 翻到 refuse。触发键 = 运维声明的副本数,与 split-brain-guard.tsdeclaresMultiNodereplicas > 1)同一姿态。已注册 gate 的判定完全不变(byte-identical),已授权部署不受影响 —— 这一条有专门的负例测试钉着。

J2 · 公开面加宽(纯增量)

@objectstack/service-cluster 桶新导出 6 个名字:hasMultiNodeGate()MULTI_NODE_NO_GATE_REASONmountMultiNodeGateFromHost()MULTI_NODE_GATE_CARRIER_PACKAGEStype MultiNodeGateMountAttempttype MultiNodeGateMountReading。⛔ 无删除、无重命名、无签名变更。

⚠️ 一条排除项,免得误记在本 PR 头上:__resetMultiNodeGate(双下划线测试缝)已在 main 上导出git show origin/main:…/index.ts | grep -c = 1),非本 PR 引入。

J3 · ⚠️ 需要维护者定的那一条 —— 跨仓合成效应,两个 PR 各自的正文都没有这个形状

本 PR 的 fail-closed 只在 requested > 1 触发;但已注册的 gate 对任何计数都作答。cloud#1828(已合入 cloud main)让载体在模块加载时注册 ⇒ 两半合起来之后:

无证 + OS_CLUSTER_DRIVER=redis + 单副本 的部署,会被拒并回落进程内驱动 —— 而今天(无 gate ⇒ 放行)它保得住远程驱动。

⛔ 严格说这不是本 PR 发明的规则:EE config 路线今天就是这个行为,两半只是让其他路线与之对齐 —— 这正是卡片的目的。但它是一次真实的接受集变化,且只在跨仓合成后可见,所以点名请裁:接受此对齐,还是要求把无证单副本的远程驱动留出豁免? 本席倾向接受(对齐 > 例外),但这属产品面,⛔ 不自裁。

J4 · 运维可见文案变更(本席在接手后新增,非原实现者产物)

serve.ts 的拒绝告警原文是 downgrading to single-node (in-memory cluster),而进程随后拒启。已改为同时点名回落与随之而来的拒启,并给两条补救(撤销声明 / 授权该能力)。行为零变化,只有措辞

依据(#14116 实测链,已在 multi-node-gate.test.ts 新增回归块钉住):

无 gate + OS_CLUSTER_REPLICAS=3 → checkMultiNodeAllowed(3) = {allowed:false,…}
↓ serve: clusterConfig 保持 undefined
↓ Runtime({cluster:undefined}) → ClusterServicePlugin({}) → defineCluster({}) → "memory"
↓ assertClusterDriverSafeForTopology('memory') [replicas=3] → THROW ⇒ 拒启

fail-closed 的触发条件与 split-brain 护栏的触发条件是同一个声明 ⇒ 在真实多副本部署上,拒启是常态而非例外。⭐ 拒启本身正确(N 副本各持进程内锁 = 护栏要拦的脑裂),错的是「never bricks」那句承诺。


② Semver 定级 vs changeset 声明 —— 一致

  • changeset front-matter:@objectstack/service-cluster: minor · @objectstack/cli: patch
  • 正文自带 BREAKING 横幅,并援引仓内惯例:「BREAKING accept-set narrowing … shipped as minor under the launch-window convention」。
  • ✅ 该惯例实存且在用,非杜撰 —— 同形先例:.changeset/action-newtaburl-requires-opensinnewtab.mdadvanced-plugin-lifecycle-config-retired.mdapproval-auto-cancel-on-record-delete.md
  • ✅ ADR-0087 处置标记在案且门绿(本席改写该行措辞后重跑):1 declared-breaking changeset(s), each carrying an ADR-0087 dispositionnot-required (no-migration-prescription),exit 0。
  • CLI 侧 patch 恰当:一处解构 + 一处 guarded 调用 + 告警文案,无公开面变化。

⇒ ② 项本席未发现不一致。


③ 边界旗处置 —— 逐旗

处置
缺口 2(#13330 CJS/ESM 双实例分裂) 出界保持出界,PR 正文写明效力边界;⛔ 未在本 PR 单独加固该注册表(那正是分诊警告的反模式)。仍开着
cloud 侧载体必须有人做已消:cloud#1827 → cloud#1828 合入 cloud main cfbd4acf,两个载体包均在模块加载时注册。PR 正文已从「需要后续」改写为「已落地」。
「never bricks」⚠️原实现者未挂此旗 —— 是本席在 #14116 测量时发现的证否。已在 4 处(gate 模块、mount 模块、serve.ts、changeset)改正 + 加回归测试。⇒ 这是 ① J4 请裁的对象。
check-test-completeness.mjs 本地 exit 3(PREREQUISITE NOT MET)归 CI;CI 已绿。

落地前检三条的当前读数

#读数
席内契约档 PASS 在案未满足 —— 待维护者本场裁定
needs:contract-review 双载体已清未清(按细则原样留置,本席无资格剥)
PR 全部 check 全绿(非 required 子集)33 条:29 SUCCESS / 4 SKIPPED / 0 红

另两项前置也已机读:check-clause2-carriers.mjs --pair 14114 → 双载体可读且一致;check-governed-merges.mjs --test <最终文件表>0 of 7NOT governed,普通队列落地适用(⛔ 非受管面,不走 draft-only 终局)。

裁 PASS 即可剥标 → ready → 入队;裁 FAIL 或要求修改则走补丁轮。


Generated by Claude Code

@hotlong

Copy link
Copy Markdown
Contributor

⚖️ 条款②契约复核 PASS —— 维护者本人任复核席,2026-09-01

裁定:J3 选 A(接受对齐),verbatim「A」。维护者亲自复核,档位问题不适用(保险丝约束的是补偿低档派发的代理复核席,不是维护者席;本席 claude-opus-5 ⛔ 未自判,材料呈送见复核材料评论)。

被裁的那一条,连同裁定理由一并入档

问题:本 PR 与已合入的 cloud#1828 合成后,无证 + OS_CLUSTER_DRIVER=redis + 单副本的部署会被拒并回落进程内驱动。

A —— 门对任何副本数都作答,所有启动路线与 EE config 路线今天的行为对齐。⛔ 不给单副本开豁免。

理由(业务面,记录在案以免日后被当成"顺手收紧")OS_CLUSTER_REPLICAS 是运维自己声明、系统无法核实的数字,且本仓部署文档已记载 docker compose --scale app=N 只抬高真实副本数、不抬高运行时被告知的数(cloud#1502 的"静默脑裂的门")。⇒ 若给单副本豁免,一个未授权部署只需把该变量写成 1--scale app=5,门永远不会被问,远程驱动照常发放,五个副本真的在协同 —— 收费能力被一行环境变量拿走,且不可检测。选 A 后,谎报副本数得到的是"各跑各的",不是"免费的集群"。

代价一并记明:一批未测到的"脱离模板 + 无证 + 配了 redis + 单副本"部署会在升级后看到一条拒绝。功能上近乎无损(单进程本就该用进程内驱动,脑裂只在跨进程存在),受支持的部署模板不产生该形状(cloud#1742 核查:模板均 pin OS_CLUSTER_REPLICAS=1 且不配 redis)。

三项清单归档

落地前检(细则三条,维护者 2026-08-31「同意」)

#读数
席内契约档 PASS 在案✅ 本评论
needs:contract-review 双载体已清✅ 同笔剥(PR + 卡),机读复验见下
PR 全部 check 全绿(⛔ 非 required 子集)✅ 33 条:29 SUCCESS / 4 SKIPPED / 0 红

治理面谓词在最终文件表上重跑:0 of 7 ⇒ NOT governed,普通队列落地适用(⛔ 非受管面,draft-only 终局不适用)。

⇒ 剥标 → ready → 入队。


Generated by Claude Code

@hotlong
hotlong marked this pull request as ready for review September 1, 2026 11:10
@hotlong
hotlong enabled auto-merge September 1, 2026 11:10
@hotlong
hotlong added this pull request to the merge queueSep 1, 2026
Merged via the queue into main with commit 4d672c4Sep 1, 2026
43 checks passed
@hotlong
hotlong deleted the claude/issue-13537-multinode-gate-mount branch September 1, 2026 11:40
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentationImprovements or additions to documentationsize/lteststooling

Projects

None yet

3 participants

@os-steve@hotlong@claude
, '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

fix(service-cluster,cli): multi-node gate fails closed when unregistered, and mounts on every boot route - #14114

Merged
hotlong merged 5 commits into
mainfrom
claude/issue-13537-multinode-gate-mount
Sep 1, 2026
Merged

fix(service-cluster,cli): multi-node gate fails closed when unregistered, and mounts on every boot route#14114
hotlong merged 5 commits into
mainfrom
claude/issue-13537-multinode-gate-mount

Conversation

@os-steve

@os-steveos-steve commented Sep 1, 2026

Copy link
Copy Markdown
Collaborator

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):
checkMultiNodeAllowed used to return allowed: true whenever 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), so os serve drops 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_REPLICAS unset, 1, 0, negative, NaN) keeps the historical allow: it declares no multi-node topology, so there is nothing to gate — the same > 1 posture as split-brain-guard.ts's declaresMultiNode. 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 in packages/cli/src/commands/serve.ts):
registerMultiNodeGate was reachable from exactly one place — the EE app config (cloud repo) executing — so the thin-extension and OS_ARTIFACT_URL artifact-direct boot routes never mounted the gate at all. New mountMultiNodeGateFromHost(importFromHost) lives in domain:services and owns the carrier list (MULTI_NODE_GATE_CARRIER_PACKAGES: @objectstack/security-enterprise, @objectstack/organizations — both roster-declared enterprise runtimes, drift-tested against PLATFORM_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. serve calls 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 new import()/importFromHost(...) load sites in serve.ts — the carrier loads run inside service-cluster through 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):

  • a licensed deployment whose registration lands on the wrong instance is refused, loudly (fail-closed) instead of running an unlicensed cluster silently (fail-open) — the ruled direction, but operators of such deployments will see that refusal until the split is fixed and/or a carrier registers at module load on the instance the consult reads. ⛔ "Refused", not "downgraded": see "Boot outcome" below;
  • mountMultiNodeGateFromHost honestly 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.sh disclosure 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 main at cfbd4acf, adding a module-scope registerMultiNodeGate to BOTH @objectstack/security-enterprise and @objectstack/organizations — i.e. both names on this PR's MULTI_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 reporting loaded-without-gate on 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), and packages/cli/src/commands/serve.ts (mount call site). domain:cli half declared: the serve call site — one destructure + one guarded call; the mount logic and carrier list live in domain:services.

Verification

Negatives (mandated, one test each — both green):

  • Entitled deployment not blocked: registered gate with admitted: 5, requested: 3allowed: true, refused: 0, capped: false — the fail-closed branch is unreachable the moment a gate is registered ("never blocks a properly-entitled deployment").
  • Single-replica path unchanged: 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 + 2 and no gate + 9 → refused with MULTI_NODE_NO_GATE_REASON (the 9 pin 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 at ee607b488, whose packages/services/service-cluster tree is byte-identical to this PR's head (verified: git diff 30c2d3d02 ee607b488 -- packages/services/service-cluster is empty). Mutated blob de8c8fbb vs HEAD blob 65ffad01; 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 HEAD empty, 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 at 30c2d3d02, this PR's head):

  • Build: dependency closures + full turbo run build --filter='./packages/*' --filter='./packages/*/*' (lint.yml parity) — 70/70 successful.
  • Tests at 30c2d3d02: @objectstack/service-cluster 77/77 · @objectstack/service-cluster-redis (direct dist-coupled consumer) 28/28 · @objectstack/cli targeted suites (serve-multi-node-cap-advisory + .pin, serve-multi-node-cap-telemetry, serve-cluster-host-resolution source sweep, serve-app-anchored-optional-import.e2e real boot) 71/71 — vitest's own "Test Files N passed / Tests N passed" lines are the cited verdicts.
  • Typecheck: @objectstack/clitsc --noEmit exit 0 at 30c2d3d02 with serve.ts proven in the program via --listFiles. @objectstack/service-cluster has NO typecheck script; a manual tsc --noEmit --listFiles (run at ee607b488, 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 untouched src/memory/memory.contract.test.ts (carried by the type-check-debt ledger — see next line).
  • Gate families re-derived from the actual diff with node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack at a97319179 (both output sections read whole; 33 path-derived families + the convention-triggered test-file and error-code families). All run locally and green — including check:adr-0087-registration (red once for a missing disposition marker on the BREAKING changeset; fixed by the not-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-repo pnpm lint (eslint . --no-inline-config, exit 0).
  • NOT MEASURED (not red, not green): scripts/check-test-completeness.mjs exits 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.

⚠️ Boot outcome — a correction this PR carries, measured on #14116

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:

no gate + OS_CLUSTER_REPLICAS=3
→ checkMultiNodeAllowed(3) = {allowed:false, reason:MULTI_NODE_NO_GATE_REASON, refused:3}
↓ serve.ts: warns, leaves clusterConfig undefined
↓ Runtime({cluster: undefined}) → ClusterServicePlugin({}) → defineCluster({}) → driver "memory"
↓ assertClusterDriverSafeForTopology('memory') [OS_CLUSTER_REPLICAS=3]
✗ THROW — boot REFUSED

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.ts comments and the changeset: corrected, with a ⛔ note against restating "never bricks";
  • serve.ts's boot warning: it said downgrading 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);
  • new regression block in multi-node-gate.test.ts pinning 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_016ZC5rNQj3WEet5HAmmAkMs and 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:

  1. origin/main merged in (the branch was 35 commits behind and CONFLICTING, so no workflow was triggering at all). One conflict, in serve.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.
  2. The boot-outcome correction above.

Re-verified at this head after a full workspace build: @objectstack/service-cluster 84/84 (6 files) · @objectstack/cli multi-node + cluster suites 65/65 (4 files). packages/clitsc --noEmit reports the same 69 pre-existing errors with main's serve.ts as 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-review on 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

…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
@os-steveClaude

Copy link
Copy Markdown
CollaboratorAuthor

PM 复核:实现验收通过,但 ⛔ 暂不落地 —— 卡在一个跨仓时序裁决上(#14116)

先把话说清楚:这不是打回,也不是你的问题。 PR 完整、全绿、对着卡做对了,而且正是你自己报的那条 out-of-scope 发现让我停手的。⭐ 那条发现是本轮最有价值的一件事 —— 一个只想着交差的实现会闭嘴不说。

我独立复核了你的发现,成立

$ grep -rn "registerMultiNodeGate" --include=*.ts . | grep -v node_modules | grep -v /dist/ | grep -v '\.test\.'
packages/services/service-cluster/src/index.ts:77 ← 桶再导出
packages/services/service-cluster/src/multi-node-gate.ts:169 ← 定义本身

⇒ 两处命中,全是结构性的,本仓无任何生产注册者⚠️ 反向对照让这个零可读:同形搜 registerDriver 命中真实生产调用点(objectql/src/plugin.ts:625)⇒ grep 是活的。

为什么这挡住落地

方向是对的 —— 授权门 fail-open 本来就不能接受。但沉挂载只能加载载体,不能让载体注册。⇒ 在跳过 EE 配置的那两条启动路径上:

今天单独落 #14114cloud 补齐后
未授权多节点在跑(fail open)
已授权多节点在跑⚠️被拒 —— 响亮降为单节点在跑

⇒ 它在关掉「白嫖」的同一笔里打开了「付费客户失去能力」。这是部署可见的取舍,不是复核能拍的板,所以进决策箱:#14116

记你一笔:失败是响亮的 —— attempt 如实报 loaded-without-gate,serve 降级而不是 brick。这是「难受一下午」和「一次事故」的区别,是你把它做对的。⛔ 但它不能让降级变成免费的。

已办

验收侧我核过、没有异议的

  • 两条强制否定面各有专测:已授权(注册了门、admitted 5 / requested 3)不被挡;单副本与未声明/无意义值(NaN/0/-1)逐字节不变。
  • ablation 先声明方向,变异经 anchored grep 计数(1→0 / 0→1)与 blob 哈希(65ffad01de8c8fbb)在盘上证实,⛔ 非编辑器退出码;restore 以状态证明。⭐ 而且你主动说明了变异模块经同包相对 src 导入到达测试,所以那条解析路径上没有 dist —— 陈旧 dist 的 ablation 根本红不了,这句声明是它有效的前提。
  • 预测 3 红、实测 4 红,你没有掩饰,而是指出第四条是 reset 测试新加的最后一行断言的同一个分支 ⇒ 同类、方向与预测一致。⛔ 事后改预测才是问题,如实报差异不是。
  • check-adr-0087-registration 一度红(BREAKING changeset 缺 disposition),补 marker 后重跑绿 —— 红过并修好、且说出来,比只报最后那个绿好。
  • check-test-completeness 退出 3 = PREREQUISITE NOT MET,记为 NOT MEASURED,⛔ 未当绿也未当红。

你那条「不予立卡」的判断也对

memory.contract.test.ts 的既存 tsc 错误 + 该包无 typecheck 脚本 —— 两者都已被 check:type-check-debt 台账承载且本轮重测绿。为它立卡等于复制一行台账。 ⛔ 不立是对的。


Generated by Claude Code

hotlongand others added 2 commits September 1, 2026 18:23
…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>
@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 2 package(s): @objectstack/cli, @objectstack/service-cluster, touching 9 documentable anchor(s).

17 hand-written doc(s) name something this change touched — list omitted above 15 rows. Re-derive on the tree named below: node scripts/docs-audit/affected-docs.mjs --json 45b9051248f86f362b042fa9de63295a8c224073.

4 release-owned page(s) also affected — read-only, see AGENTS.md Documentation Guardrails.

What this run could not see
  • 5 name(s) were too generic to anchor anything (single lowercase words)
  • the SDK route bridge reached 47 of 219 client-bound route-ledger rows — the other 172 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 172: 14 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 56 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 102 are undecided (no in-repo declaration, on a ledger that has other in-repo registrars — absence and an unreadable spelling are not distinguishable here). The rows themselves: node scripts/docs-audit/affected-docs.mjs --bridge-coverage
  • a page that states a rule by its inputs shares no identifier with the emitter that implements the rule, so an emitter-only diff cannot list it — not on this run and not on any run. Measured on fix(driver-sql): emit varchar(maxLength) for a text field a declared index keys on #11430: content/docs/protocol/objectql/types.mdx documents the text-family column mapping by the ObjectQL type names it maps FROM (text / textarea / html) while the diff changed createColumn; it went unlisted, and it was the page that diff falsified, in four places. No shared token exists to detect this on, so a rule your change carries has to be re-read by hand in the pages that restate it.

Coarse fallback — 26 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): node scripts/docs-audit/affected-docs.mjs --json 45b9051248f86f362b042fa9de63295a8c224073packageMentionDocs.

Which tree this was computed on

This run read content/docs from b2daf8c038843425622c76972db76af82c7501d1 — the merge of head 1ba418ebf41441fbda52fbef3b6e89b43437c6c0 into base 45b9051248f86f362b042fa9de63295a8c224073, which is what actions/checkout gives a pull_request run. Not the PR head.

A worktree cut from an older main holds a different content/docs, so re-deriving there can legitimately return a different list — that is a different tree, not a wrong row. To answer on the same tree:

# 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

⚠️ That checkout carried uncommitted changes, so the commit above does not fully identify what was read.

Advisory only, and a precision-first one (#9192): a page is listed because it names a
symbol, wire route or SDK method this diff touched — not because it mentions a changed
package. Each row says which anchor put it there, so a wrong row is reportable rather than
merely annoying. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs 45b9051248f86f362b042fa9de63295a8c224073 → pass the list as
args.docs, on the commit named under Which tree this was computed on.

@github-actionsgithub-actionsBot added documentation Improvements or additions to documentation tests tooling labels Sep 1, 2026
@hotlong

Copy link
Copy Markdown
Contributor

📋 条款②契约复核 —— 材料呈送,⛔ 本席不自判清标

维护者 2026-09-01 裁定由维护者本人任复核席,本席只摆材料。⛔ 因此本评论不含放行结论needs:contract-review 双载体原样留置。

保险丝读数(细则要求每场必读):CONTRACT_REVIEW_TIER = 'claude-fable-5'scripts/pm/dispatch-gates.mjs:7102);本席服役档 claude-opus-5 ⇒ 读数 ≠ 档位 ⇒ 本席无自判清标资格。维护者席不受此保险丝约束(它约束的是补偿低档派发的代理复核)。


① Derived judgments —— diff 引出的接受集 / 公开面变化,逐条点名

J1 · 接受集收窄(这就是条款②的内容肢本体)

checkMultiNodeAllowed(requested)无 gate 注册时:

requested之前之后
undefined / NaN / 0 / 负数{allowed:true, refused:0, capped:false}逐字节相同
1同上 allow逐字节相同
> 1allow{allowed:false, reason:MULTI_NODE_NO_GATE_REASON, admitted:0, refused:N, capped:false}

⇒ 一个授权谓词的默认方向从 allow 翻到 refuse。触发键 = 运维声明的副本数,与 split-brain-guard.tsdeclaresMultiNodereplicas > 1)同一姿态。已注册 gate 的判定完全不变(byte-identical),已授权部署不受影响 —— 这一条有专门的负例测试钉着。

J2 · 公开面加宽(纯增量)

@objectstack/service-cluster 桶新导出 6 个名字:hasMultiNodeGate()MULTI_NODE_NO_GATE_REASONmountMultiNodeGateFromHost()MULTI_NODE_GATE_CARRIER_PACKAGEStype MultiNodeGateMountAttempttype MultiNodeGateMountReading。⛔ 无删除、无重命名、无签名变更。

⚠️ 一条排除项,免得误记在本 PR 头上:__resetMultiNodeGate(双下划线测试缝)已在 main 上导出git show origin/main:…/index.ts | grep -c = 1),非本 PR 引入。

J3 · ⚠️ 需要维护者定的那一条 —— 跨仓合成效应,两个 PR 各自的正文都没有这个形状

本 PR 的 fail-closed 只在 requested > 1 触发;但已注册的 gate 对任何计数都作答。cloud#1828(已合入 cloud main)让载体在模块加载时注册 ⇒ 两半合起来之后:

无证 + OS_CLUSTER_DRIVER=redis + 单副本 的部署,会被拒并回落进程内驱动 —— 而今天(无 gate ⇒ 放行)它保得住远程驱动。

⛔ 严格说这不是本 PR 发明的规则:EE config 路线今天就是这个行为,两半只是让其他路线与之对齐 —— 这正是卡片的目的。但它是一次真实的接受集变化,且只在跨仓合成后可见,所以点名请裁:接受此对齐,还是要求把无证单副本的远程驱动留出豁免? 本席倾向接受(对齐 > 例外),但这属产品面,⛔ 不自裁。

J4 · 运维可见文案变更(本席在接手后新增,非原实现者产物)

serve.ts 的拒绝告警原文是 downgrading to single-node (in-memory cluster),而进程随后拒启。已改为同时点名回落与随之而来的拒启,并给两条补救(撤销声明 / 授权该能力)。行为零变化,只有措辞

依据(#14116 实测链,已在 multi-node-gate.test.ts 新增回归块钉住):

无 gate + OS_CLUSTER_REPLICAS=3 → checkMultiNodeAllowed(3) = {allowed:false,…}
↓ serve: clusterConfig 保持 undefined
↓ Runtime({cluster:undefined}) → ClusterServicePlugin({}) → defineCluster({}) → "memory"
↓ assertClusterDriverSafeForTopology('memory') [replicas=3] → THROW ⇒ 拒启

fail-closed 的触发条件与 split-brain 护栏的触发条件是同一个声明 ⇒ 在真实多副本部署上,拒启是常态而非例外。⭐ 拒启本身正确(N 副本各持进程内锁 = 护栏要拦的脑裂),错的是「never bricks」那句承诺。


② Semver 定级 vs changeset 声明 —— 一致

  • changeset front-matter:@objectstack/service-cluster: minor · @objectstack/cli: patch
  • 正文自带 BREAKING 横幅,并援引仓内惯例:「BREAKING accept-set narrowing … shipped as minor under the launch-window convention」。
  • ✅ 该惯例实存且在用,非杜撰 —— 同形先例:.changeset/action-newtaburl-requires-opensinnewtab.mdadvanced-plugin-lifecycle-config-retired.mdapproval-auto-cancel-on-record-delete.md
  • ✅ ADR-0087 处置标记在案且门绿(本席改写该行措辞后重跑):1 declared-breaking changeset(s), each carrying an ADR-0087 dispositionnot-required (no-migration-prescription),exit 0。
  • CLI 侧 patch 恰当:一处解构 + 一处 guarded 调用 + 告警文案,无公开面变化。

⇒ ② 项本席未发现不一致。


③ 边界旗处置 —— 逐旗

处置
缺口 2(#13330 CJS/ESM 双实例分裂) 出界保持出界,PR 正文写明效力边界;⛔ 未在本 PR 单独加固该注册表(那正是分诊警告的反模式)。仍开着
cloud 侧载体必须有人做已消:cloud#1827 → cloud#1828 合入 cloud main cfbd4acf,两个载体包均在模块加载时注册。PR 正文已从「需要后续」改写为「已落地」。
「never bricks」⚠️原实现者未挂此旗 —— 是本席在 #14116 测量时发现的证否。已在 4 处(gate 模块、mount 模块、serve.ts、changeset)改正 + 加回归测试。⇒ 这是 ① J4 请裁的对象。
check-test-completeness.mjs 本地 exit 3(PREREQUISITE NOT MET)归 CI;CI 已绿。

落地前检三条的当前读数

#读数
席内契约档 PASS 在案未满足 —— 待维护者本场裁定
needs:contract-review 双载体已清未清(按细则原样留置,本席无资格剥)
PR 全部 check 全绿(非 required 子集)33 条:29 SUCCESS / 4 SKIPPED / 0 红

另两项前置也已机读:check-clause2-carriers.mjs --pair 14114 → 双载体可读且一致;check-governed-merges.mjs --test <最终文件表>0 of 7NOT governed,普通队列落地适用(⛔ 非受管面,不走 draft-only 终局)。

裁 PASS 即可剥标 → ready → 入队;裁 FAIL 或要求修改则走补丁轮。


Generated by Claude Code

@hotlong

Copy link
Copy Markdown
Contributor

⚖️ 条款②契约复核 PASS —— 维护者本人任复核席,2026-09-01

裁定:J3 选 A(接受对齐),verbatim「A」。维护者亲自复核,档位问题不适用(保险丝约束的是补偿低档派发的代理复核席,不是维护者席;本席 claude-opus-5 ⛔ 未自判,材料呈送见复核材料评论)。

被裁的那一条,连同裁定理由一并入档

问题:本 PR 与已合入的 cloud#1828 合成后,无证 + OS_CLUSTER_DRIVER=redis + 单副本的部署会被拒并回落进程内驱动。

A —— 门对任何副本数都作答,所有启动路线与 EE config 路线今天的行为对齐。⛔ 不给单副本开豁免。

理由(业务面,记录在案以免日后被当成"顺手收紧")OS_CLUSTER_REPLICAS 是运维自己声明、系统无法核实的数字,且本仓部署文档已记载 docker compose --scale app=N 只抬高真实副本数、不抬高运行时被告知的数(cloud#1502 的"静默脑裂的门")。⇒ 若给单副本豁免,一个未授权部署只需把该变量写成 1--scale app=5,门永远不会被问,远程驱动照常发放,五个副本真的在协同 —— 收费能力被一行环境变量拿走,且不可检测。选 A 后,谎报副本数得到的是"各跑各的",不是"免费的集群"。

代价一并记明:一批未测到的"脱离模板 + 无证 + 配了 redis + 单副本"部署会在升级后看到一条拒绝。功能上近乎无损(单进程本就该用进程内驱动,脑裂只在跨进程存在),受支持的部署模板不产生该形状(cloud#1742 核查:模板均 pin OS_CLUSTER_REPLICAS=1 且不配 redis)。

三项清单归档

落地前检(细则三条,维护者 2026-08-31「同意」)

#读数
席内契约档 PASS 在案✅ 本评论
needs:contract-review 双载体已清✅ 同笔剥(PR + 卡),机读复验见下
PR 全部 check 全绿(⛔ 非 required 子集)✅ 33 条:29 SUCCESS / 4 SKIPPED / 0 红

治理面谓词在最终文件表上重跑:0 of 7 ⇒ NOT governed,普通队列落地适用(⛔ 非受管面,draft-only 终局不适用)。

⇒ 剥标 → ready → 入队。


Generated by Claude Code

@hotlong
hotlong marked this pull request as ready for review September 1, 2026 11:10
@hotlong
hotlong enabled auto-merge September 1, 2026 11:10
@hotlong
hotlong added this pull request to the merge queueSep 1, 2026
Merged via the queue into main with commit 4d672c4Sep 1, 2026
43 checks passed
@hotlong
hotlong deleted the claude/issue-13537-multinode-gate-mount branch September 1, 2026 11:40
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentationImprovements or additions to documentationsize/lteststooling

Projects

None yet

3 participants

@os-steve@hotlong@claude
, '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

fix(service-cluster,cli): multi-node gate fails closed when unregistered, and mounts on every boot route - #14114

Merged
hotlong merged 5 commits into
mainfrom
claude/issue-13537-multinode-gate-mount
Sep 1, 2026
Merged

fix(service-cluster,cli): multi-node gate fails closed when unregistered, and mounts on every boot route#14114
hotlong merged 5 commits into
mainfrom
claude/issue-13537-multinode-gate-mount

Conversation

@os-steve

@os-steveos-steve commented Sep 1, 2026

Copy link
Copy Markdown
Collaborator

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):
checkMultiNodeAllowed used to return allowed: true whenever 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), so os serve drops 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_REPLICAS unset, 1, 0, negative, NaN) keeps the historical allow: it declares no multi-node topology, so there is nothing to gate — the same > 1 posture as split-brain-guard.ts's declaresMultiNode. 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 in packages/cli/src/commands/serve.ts):
registerMultiNodeGate was reachable from exactly one place — the EE app config (cloud repo) executing — so the thin-extension and OS_ARTIFACT_URL artifact-direct boot routes never mounted the gate at all. New mountMultiNodeGateFromHost(importFromHost) lives in domain:services and owns the carrier list (MULTI_NODE_GATE_CARRIER_PACKAGES: @objectstack/security-enterprise, @objectstack/organizations — both roster-declared enterprise runtimes, drift-tested against PLATFORM_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. serve calls 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 new import()/importFromHost(...) load sites in serve.ts — the carrier loads run inside service-cluster through 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):

  • a licensed deployment whose registration lands on the wrong instance is refused, loudly (fail-closed) instead of running an unlicensed cluster silently (fail-open) — the ruled direction, but operators of such deployments will see that refusal until the split is fixed and/or a carrier registers at module load on the instance the consult reads. ⛔ "Refused", not "downgraded": see "Boot outcome" below;
  • mountMultiNodeGateFromHost honestly 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.sh disclosure 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 main at cfbd4acf, adding a module-scope registerMultiNodeGate to BOTH @objectstack/security-enterprise and @objectstack/organizations — i.e. both names on this PR's MULTI_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 reporting loaded-without-gate on 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), and packages/cli/src/commands/serve.ts (mount call site). domain:cli half declared: the serve call site — one destructure + one guarded call; the mount logic and carrier list live in domain:services.

Verification

Negatives (mandated, one test each — both green):

  • Entitled deployment not blocked: registered gate with admitted: 5, requested: 3allowed: true, refused: 0, capped: false — the fail-closed branch is unreachable the moment a gate is registered ("never blocks a properly-entitled deployment").
  • Single-replica path unchanged: 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 + 2 and no gate + 9 → refused with MULTI_NODE_NO_GATE_REASON (the 9 pin 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 at ee607b488, whose packages/services/service-cluster tree is byte-identical to this PR's head (verified: git diff 30c2d3d02 ee607b488 -- packages/services/service-cluster is empty). Mutated blob de8c8fbb vs HEAD blob 65ffad01; 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 HEAD empty, 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 at 30c2d3d02, this PR's head):

  • Build: dependency closures + full turbo run build --filter='./packages/*' --filter='./packages/*/*' (lint.yml parity) — 70/70 successful.
  • Tests at 30c2d3d02: @objectstack/service-cluster 77/77 · @objectstack/service-cluster-redis (direct dist-coupled consumer) 28/28 · @objectstack/cli targeted suites (serve-multi-node-cap-advisory + .pin, serve-multi-node-cap-telemetry, serve-cluster-host-resolution source sweep, serve-app-anchored-optional-import.e2e real boot) 71/71 — vitest's own "Test Files N passed / Tests N passed" lines are the cited verdicts.
  • Typecheck: @objectstack/clitsc --noEmit exit 0 at 30c2d3d02 with serve.ts proven in the program via --listFiles. @objectstack/service-cluster has NO typecheck script; a manual tsc --noEmit --listFiles (run at ee607b488, 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 untouched src/memory/memory.contract.test.ts (carried by the type-check-debt ledger — see next line).
  • Gate families re-derived from the actual diff with node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack at a97319179 (both output sections read whole; 33 path-derived families + the convention-triggered test-file and error-code families). All run locally and green — including check:adr-0087-registration (red once for a missing disposition marker on the BREAKING changeset; fixed by the not-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-repo pnpm lint (eslint . --no-inline-config, exit 0).
  • NOT MEASURED (not red, not green): scripts/check-test-completeness.mjs exits 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.

⚠️ Boot outcome — a correction this PR carries, measured on #14116

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:

no gate + OS_CLUSTER_REPLICAS=3
→ checkMultiNodeAllowed(3) = {allowed:false, reason:MULTI_NODE_NO_GATE_REASON, refused:3}
↓ serve.ts: warns, leaves clusterConfig undefined
↓ Runtime({cluster: undefined}) → ClusterServicePlugin({}) → defineCluster({}) → driver "memory"
↓ assertClusterDriverSafeForTopology('memory') [OS_CLUSTER_REPLICAS=3]
✗ THROW — boot REFUSED

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.ts comments and the changeset: corrected, with a ⛔ note against restating "never bricks";
  • serve.ts's boot warning: it said downgrading 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);
  • new regression block in multi-node-gate.test.ts pinning 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_016ZC5rNQj3WEet5HAmmAkMs and 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:

  1. origin/main merged in (the branch was 35 commits behind and CONFLICTING, so no workflow was triggering at all). One conflict, in serve.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.
  2. The boot-outcome correction above.

Re-verified at this head after a full workspace build: @objectstack/service-cluster 84/84 (6 files) · @objectstack/cli multi-node + cluster suites 65/65 (4 files). packages/clitsc --noEmit reports the same 69 pre-existing errors with main's serve.ts as 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-review on 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

…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
@os-steveClaude

Copy link
Copy Markdown
CollaboratorAuthor

PM 复核:实现验收通过,但 ⛔ 暂不落地 —— 卡在一个跨仓时序裁决上(#14116)

先把话说清楚:这不是打回,也不是你的问题。 PR 完整、全绿、对着卡做对了,而且正是你自己报的那条 out-of-scope 发现让我停手的。⭐ 那条发现是本轮最有价值的一件事 —— 一个只想着交差的实现会闭嘴不说。

我独立复核了你的发现,成立

$ grep -rn "registerMultiNodeGate" --include=*.ts . | grep -v node_modules | grep -v /dist/ | grep -v '\.test\.'
packages/services/service-cluster/src/index.ts:77 ← 桶再导出
packages/services/service-cluster/src/multi-node-gate.ts:169 ← 定义本身

⇒ 两处命中,全是结构性的,本仓无任何生产注册者⚠️ 反向对照让这个零可读:同形搜 registerDriver 命中真实生产调用点(objectql/src/plugin.ts:625)⇒ grep 是活的。

为什么这挡住落地

方向是对的 —— 授权门 fail-open 本来就不能接受。但沉挂载只能加载载体,不能让载体注册。⇒ 在跳过 EE 配置的那两条启动路径上:

今天单独落 #14114cloud 补齐后
未授权多节点在跑(fail open)
已授权多节点在跑⚠️被拒 —— 响亮降为单节点在跑

⇒ 它在关掉「白嫖」的同一笔里打开了「付费客户失去能力」。这是部署可见的取舍,不是复核能拍的板,所以进决策箱:#14116

记你一笔:失败是响亮的 —— attempt 如实报 loaded-without-gate,serve 降级而不是 brick。这是「难受一下午」和「一次事故」的区别,是你把它做对的。⛔ 但它不能让降级变成免费的。

已办

验收侧我核过、没有异议的

  • 两条强制否定面各有专测:已授权(注册了门、admitted 5 / requested 3)不被挡;单副本与未声明/无意义值(NaN/0/-1)逐字节不变。
  • ablation 先声明方向,变异经 anchored grep 计数(1→0 / 0→1)与 blob 哈希(65ffad01de8c8fbb)在盘上证实,⛔ 非编辑器退出码;restore 以状态证明。⭐ 而且你主动说明了变异模块经同包相对 src 导入到达测试,所以那条解析路径上没有 dist —— 陈旧 dist 的 ablation 根本红不了,这句声明是它有效的前提。
  • 预测 3 红、实测 4 红,你没有掩饰,而是指出第四条是 reset 测试新加的最后一行断言的同一个分支 ⇒ 同类、方向与预测一致。⛔ 事后改预测才是问题,如实报差异不是。
  • check-adr-0087-registration 一度红(BREAKING changeset 缺 disposition),补 marker 后重跑绿 —— 红过并修好、且说出来,比只报最后那个绿好。
  • check-test-completeness 退出 3 = PREREQUISITE NOT MET,记为 NOT MEASURED,⛔ 未当绿也未当红。

你那条「不予立卡」的判断也对

memory.contract.test.ts 的既存 tsc 错误 + 该包无 typecheck 脚本 —— 两者都已被 check:type-check-debt 台账承载且本轮重测绿。为它立卡等于复制一行台账。 ⛔ 不立是对的。


Generated by Claude Code

hotlongand others added 2 commits September 1, 2026 18:23
…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>
@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 2 package(s): @objectstack/cli, @objectstack/service-cluster, touching 9 documentable anchor(s).

17 hand-written doc(s) name something this change touched — list omitted above 15 rows. Re-derive on the tree named below: node scripts/docs-audit/affected-docs.mjs --json 45b9051248f86f362b042fa9de63295a8c224073.

4 release-owned page(s) also affected — read-only, see AGENTS.md Documentation Guardrails.

What this run could not see
  • 5 name(s) were too generic to anchor anything (single lowercase words)
  • the SDK route bridge reached 47 of 219 client-bound route-ledger rows — the other 172 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 172: 14 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 56 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 102 are undecided (no in-repo declaration, on a ledger that has other in-repo registrars — absence and an unreadable spelling are not distinguishable here). The rows themselves: node scripts/docs-audit/affected-docs.mjs --bridge-coverage
  • a page that states a rule by its inputs shares no identifier with the emitter that implements the rule, so an emitter-only diff cannot list it — not on this run and not on any run. Measured on fix(driver-sql): emit varchar(maxLength) for a text field a declared index keys on #11430: content/docs/protocol/objectql/types.mdx documents the text-family column mapping by the ObjectQL type names it maps FROM (text / textarea / html) while the diff changed createColumn; it went unlisted, and it was the page that diff falsified, in four places. No shared token exists to detect this on, so a rule your change carries has to be re-read by hand in the pages that restate it.

Coarse fallback — 26 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): node scripts/docs-audit/affected-docs.mjs --json 45b9051248f86f362b042fa9de63295a8c224073packageMentionDocs.

Which tree this was computed on

This run read content/docs from b2daf8c038843425622c76972db76af82c7501d1 — the merge of head 1ba418ebf41441fbda52fbef3b6e89b43437c6c0 into base 45b9051248f86f362b042fa9de63295a8c224073, which is what actions/checkout gives a pull_request run. Not the PR head.

A worktree cut from an older main holds a different content/docs, so re-deriving there can legitimately return a different list — that is a different tree, not a wrong row. To answer on the same tree:

# 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

⚠️ That checkout carried uncommitted changes, so the commit above does not fully identify what was read.

Advisory only, and a precision-first one (#9192): a page is listed because it names a
symbol, wire route or SDK method this diff touched — not because it mentions a changed
package. Each row says which anchor put it there, so a wrong row is reportable rather than
merely annoying. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs 45b9051248f86f362b042fa9de63295a8c224073 → pass the list as
args.docs, on the commit named under Which tree this was computed on.

@github-actionsgithub-actionsBot added documentation Improvements or additions to documentation tests tooling labels Sep 1, 2026
@hotlong

Copy link
Copy Markdown
Contributor

📋 条款②契约复核 —— 材料呈送,⛔ 本席不自判清标

维护者 2026-09-01 裁定由维护者本人任复核席,本席只摆材料。⛔ 因此本评论不含放行结论needs:contract-review 双载体原样留置。

保险丝读数(细则要求每场必读):CONTRACT_REVIEW_TIER = 'claude-fable-5'scripts/pm/dispatch-gates.mjs:7102);本席服役档 claude-opus-5 ⇒ 读数 ≠ 档位 ⇒ 本席无自判清标资格。维护者席不受此保险丝约束(它约束的是补偿低档派发的代理复核)。


① Derived judgments —— diff 引出的接受集 / 公开面变化,逐条点名

J1 · 接受集收窄(这就是条款②的内容肢本体)

checkMultiNodeAllowed(requested)无 gate 注册时:

requested之前之后
undefined / NaN / 0 / 负数{allowed:true, refused:0, capped:false}逐字节相同
1同上 allow逐字节相同
> 1allow{allowed:false, reason:MULTI_NODE_NO_GATE_REASON, admitted:0, refused:N, capped:false}

⇒ 一个授权谓词的默认方向从 allow 翻到 refuse。触发键 = 运维声明的副本数,与 split-brain-guard.tsdeclaresMultiNodereplicas > 1)同一姿态。已注册 gate 的判定完全不变(byte-identical),已授权部署不受影响 —— 这一条有专门的负例测试钉着。

J2 · 公开面加宽(纯增量)

@objectstack/service-cluster 桶新导出 6 个名字:hasMultiNodeGate()MULTI_NODE_NO_GATE_REASONmountMultiNodeGateFromHost()MULTI_NODE_GATE_CARRIER_PACKAGEStype MultiNodeGateMountAttempttype MultiNodeGateMountReading。⛔ 无删除、无重命名、无签名变更。

⚠️ 一条排除项,免得误记在本 PR 头上:__resetMultiNodeGate(双下划线测试缝)已在 main 上导出git show origin/main:…/index.ts | grep -c = 1),非本 PR 引入。

J3 · ⚠️ 需要维护者定的那一条 —— 跨仓合成效应,两个 PR 各自的正文都没有这个形状

本 PR 的 fail-closed 只在 requested > 1 触发;但已注册的 gate 对任何计数都作答。cloud#1828(已合入 cloud main)让载体在模块加载时注册 ⇒ 两半合起来之后:

无证 + OS_CLUSTER_DRIVER=redis + 单副本 的部署,会被拒并回落进程内驱动 —— 而今天(无 gate ⇒ 放行)它保得住远程驱动。

⛔ 严格说这不是本 PR 发明的规则:EE config 路线今天就是这个行为,两半只是让其他路线与之对齐 —— 这正是卡片的目的。但它是一次真实的接受集变化,且只在跨仓合成后可见,所以点名请裁:接受此对齐,还是要求把无证单副本的远程驱动留出豁免? 本席倾向接受(对齐 > 例外),但这属产品面,⛔ 不自裁。

J4 · 运维可见文案变更(本席在接手后新增,非原实现者产物)

serve.ts 的拒绝告警原文是 downgrading to single-node (in-memory cluster),而进程随后拒启。已改为同时点名回落与随之而来的拒启,并给两条补救(撤销声明 / 授权该能力)。行为零变化,只有措辞

依据(#14116 实测链,已在 multi-node-gate.test.ts 新增回归块钉住):

无 gate + OS_CLUSTER_REPLICAS=3 → checkMultiNodeAllowed(3) = {allowed:false,…}
↓ serve: clusterConfig 保持 undefined
↓ Runtime({cluster:undefined}) → ClusterServicePlugin({}) → defineCluster({}) → "memory"
↓ assertClusterDriverSafeForTopology('memory') [replicas=3] → THROW ⇒ 拒启

fail-closed 的触发条件与 split-brain 护栏的触发条件是同一个声明 ⇒ 在真实多副本部署上,拒启是常态而非例外。⭐ 拒启本身正确(N 副本各持进程内锁 = 护栏要拦的脑裂),错的是「never bricks」那句承诺。


② Semver 定级 vs changeset 声明 —— 一致

  • changeset front-matter:@objectstack/service-cluster: minor · @objectstack/cli: patch
  • 正文自带 BREAKING 横幅,并援引仓内惯例:「BREAKING accept-set narrowing … shipped as minor under the launch-window convention」。
  • ✅ 该惯例实存且在用,非杜撰 —— 同形先例:.changeset/action-newtaburl-requires-opensinnewtab.mdadvanced-plugin-lifecycle-config-retired.mdapproval-auto-cancel-on-record-delete.md
  • ✅ ADR-0087 处置标记在案且门绿(本席改写该行措辞后重跑):1 declared-breaking changeset(s), each carrying an ADR-0087 dispositionnot-required (no-migration-prescription),exit 0。
  • CLI 侧 patch 恰当:一处解构 + 一处 guarded 调用 + 告警文案,无公开面变化。

⇒ ② 项本席未发现不一致。


③ 边界旗处置 —— 逐旗

处置
缺口 2(#13330 CJS/ESM 双实例分裂) 出界保持出界,PR 正文写明效力边界;⛔ 未在本 PR 单独加固该注册表(那正是分诊警告的反模式)。仍开着
cloud 侧载体必须有人做已消:cloud#1827 → cloud#1828 合入 cloud main cfbd4acf,两个载体包均在模块加载时注册。PR 正文已从「需要后续」改写为「已落地」。
「never bricks」⚠️原实现者未挂此旗 —— 是本席在 #14116 测量时发现的证否。已在 4 处(gate 模块、mount 模块、serve.ts、changeset)改正 + 加回归测试。⇒ 这是 ① J4 请裁的对象。
check-test-completeness.mjs 本地 exit 3(PREREQUISITE NOT MET)归 CI;CI 已绿。

落地前检三条的当前读数

#读数
席内契约档 PASS 在案未满足 —— 待维护者本场裁定
needs:contract-review 双载体已清未清(按细则原样留置,本席无资格剥)
PR 全部 check 全绿(非 required 子集)33 条:29 SUCCESS / 4 SKIPPED / 0 红

另两项前置也已机读:check-clause2-carriers.mjs --pair 14114 → 双载体可读且一致;check-governed-merges.mjs --test <最终文件表>0 of 7NOT governed,普通队列落地适用(⛔ 非受管面,不走 draft-only 终局)。

裁 PASS 即可剥标 → ready → 入队;裁 FAIL 或要求修改则走补丁轮。


Generated by Claude Code

@hotlong

Copy link
Copy Markdown
Contributor

⚖️ 条款②契约复核 PASS —— 维护者本人任复核席,2026-09-01

裁定:J3 选 A(接受对齐),verbatim「A」。维护者亲自复核,档位问题不适用(保险丝约束的是补偿低档派发的代理复核席,不是维护者席;本席 claude-opus-5 ⛔ 未自判,材料呈送见复核材料评论)。

被裁的那一条,连同裁定理由一并入档

问题:本 PR 与已合入的 cloud#1828 合成后,无证 + OS_CLUSTER_DRIVER=redis + 单副本的部署会被拒并回落进程内驱动。

A —— 门对任何副本数都作答,所有启动路线与 EE config 路线今天的行为对齐。⛔ 不给单副本开豁免。

理由(业务面,记录在案以免日后被当成"顺手收紧")OS_CLUSTER_REPLICAS 是运维自己声明、系统无法核实的数字,且本仓部署文档已记载 docker compose --scale app=N 只抬高真实副本数、不抬高运行时被告知的数(cloud#1502 的"静默脑裂的门")。⇒ 若给单副本豁免,一个未授权部署只需把该变量写成 1--scale app=5,门永远不会被问,远程驱动照常发放,五个副本真的在协同 —— 收费能力被一行环境变量拿走,且不可检测。选 A 后,谎报副本数得到的是"各跑各的",不是"免费的集群"。

代价一并记明:一批未测到的"脱离模板 + 无证 + 配了 redis + 单副本"部署会在升级后看到一条拒绝。功能上近乎无损(单进程本就该用进程内驱动,脑裂只在跨进程存在),受支持的部署模板不产生该形状(cloud#1742 核查:模板均 pin OS_CLUSTER_REPLICAS=1 且不配 redis)。

三项清单归档

落地前检(细则三条,维护者 2026-08-31「同意」)

#读数
席内契约档 PASS 在案✅ 本评论
needs:contract-review 双载体已清✅ 同笔剥(PR + 卡),机读复验见下
PR 全部 check 全绿(⛔ 非 required 子集)✅ 33 条:29 SUCCESS / 4 SKIPPED / 0 红

治理面谓词在最终文件表上重跑:0 of 7 ⇒ NOT governed,普通队列落地适用(⛔ 非受管面,draft-only 终局不适用)。

⇒ 剥标 → ready → 入队。


Generated by Claude Code

@hotlong
hotlong marked this pull request as ready for review September 1, 2026 11:10
@hotlong
hotlong enabled auto-merge September 1, 2026 11:10
@hotlong
hotlong added this pull request to the merge queueSep 1, 2026
Merged via the queue into main with commit 4d672c4Sep 1, 2026
43 checks passed
@hotlong
hotlong deleted the claude/issue-13537-multinode-gate-mount branch September 1, 2026 11:40
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentationImprovements or additions to documentationsize/lteststooling

Projects

None yet

3 participants

@os-steve@hotlong@claude
, '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

fix(service-cluster,cli): multi-node gate fails closed when unregistered, and mounts on every boot route - #14114

Merged
hotlong merged 5 commits into
mainfrom
claude/issue-13537-multinode-gate-mount
Sep 1, 2026
Merged

fix(service-cluster,cli): multi-node gate fails closed when unregistered, and mounts on every boot route#14114
hotlong merged 5 commits into
mainfrom
claude/issue-13537-multinode-gate-mount

Conversation

@os-steve

@os-steveos-steve commented Sep 1, 2026

Copy link
Copy Markdown
Collaborator

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):
checkMultiNodeAllowed used to return allowed: true whenever 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), so os serve drops 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_REPLICAS unset, 1, 0, negative, NaN) keeps the historical allow: it declares no multi-node topology, so there is nothing to gate — the same > 1 posture as split-brain-guard.ts's declaresMultiNode. 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 in packages/cli/src/commands/serve.ts):
registerMultiNodeGate was reachable from exactly one place — the EE app config (cloud repo) executing — so the thin-extension and OS_ARTIFACT_URL artifact-direct boot routes never mounted the gate at all. New mountMultiNodeGateFromHost(importFromHost) lives in domain:services and owns the carrier list (MULTI_NODE_GATE_CARRIER_PACKAGES: @objectstack/security-enterprise, @objectstack/organizations — both roster-declared enterprise runtimes, drift-tested against PLATFORM_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. serve calls 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 new import()/importFromHost(...) load sites in serve.ts — the carrier loads run inside service-cluster through 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):

  • a licensed deployment whose registration lands on the wrong instance is refused, loudly (fail-closed) instead of running an unlicensed cluster silently (fail-open) — the ruled direction, but operators of such deployments will see that refusal until the split is fixed and/or a carrier registers at module load on the instance the consult reads. ⛔ "Refused", not "downgraded": see "Boot outcome" below;
  • mountMultiNodeGateFromHost honestly 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.sh disclosure 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 main at cfbd4acf, adding a module-scope registerMultiNodeGate to BOTH @objectstack/security-enterprise and @objectstack/organizations — i.e. both names on this PR's MULTI_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 reporting loaded-without-gate on 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), and packages/cli/src/commands/serve.ts (mount call site). domain:cli half declared: the serve call site — one destructure + one guarded call; the mount logic and carrier list live in domain:services.

Verification

Negatives (mandated, one test each — both green):

  • Entitled deployment not blocked: registered gate with admitted: 5, requested: 3allowed: true, refused: 0, capped: false — the fail-closed branch is unreachable the moment a gate is registered ("never blocks a properly-entitled deployment").
  • Single-replica path unchanged: 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 + 2 and no gate + 9 → refused with MULTI_NODE_NO_GATE_REASON (the 9 pin 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 at ee607b488, whose packages/services/service-cluster tree is byte-identical to this PR's head (verified: git diff 30c2d3d02 ee607b488 -- packages/services/service-cluster is empty). Mutated blob de8c8fbb vs HEAD blob 65ffad01; 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 HEAD empty, 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 at 30c2d3d02, this PR's head):

  • Build: dependency closures + full turbo run build --filter='./packages/*' --filter='./packages/*/*' (lint.yml parity) — 70/70 successful.
  • Tests at 30c2d3d02: @objectstack/service-cluster 77/77 · @objectstack/service-cluster-redis (direct dist-coupled consumer) 28/28 · @objectstack/cli targeted suites (serve-multi-node-cap-advisory + .pin, serve-multi-node-cap-telemetry, serve-cluster-host-resolution source sweep, serve-app-anchored-optional-import.e2e real boot) 71/71 — vitest's own "Test Files N passed / Tests N passed" lines are the cited verdicts.
  • Typecheck: @objectstack/clitsc --noEmit exit 0 at 30c2d3d02 with serve.ts proven in the program via --listFiles. @objectstack/service-cluster has NO typecheck script; a manual tsc --noEmit --listFiles (run at ee607b488, 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 untouched src/memory/memory.contract.test.ts (carried by the type-check-debt ledger — see next line).
  • Gate families re-derived from the actual diff with node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack at a97319179 (both output sections read whole; 33 path-derived families + the convention-triggered test-file and error-code families). All run locally and green — including check:adr-0087-registration (red once for a missing disposition marker on the BREAKING changeset; fixed by the not-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-repo pnpm lint (eslint . --no-inline-config, exit 0).
  • NOT MEASURED (not red, not green): scripts/check-test-completeness.mjs exits 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.

⚠️ Boot outcome — a correction this PR carries, measured on #14116

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:

no gate + OS_CLUSTER_REPLICAS=3
→ checkMultiNodeAllowed(3) = {allowed:false, reason:MULTI_NODE_NO_GATE_REASON, refused:3}
↓ serve.ts: warns, leaves clusterConfig undefined
↓ Runtime({cluster: undefined}) → ClusterServicePlugin({}) → defineCluster({}) → driver "memory"
↓ assertClusterDriverSafeForTopology('memory') [OS_CLUSTER_REPLICAS=3]
✗ THROW — boot REFUSED

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.ts comments and the changeset: corrected, with a ⛔ note against restating "never bricks";
  • serve.ts's boot warning: it said downgrading 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);
  • new regression block in multi-node-gate.test.ts pinning 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_016ZC5rNQj3WEet5HAmmAkMs and 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:

  1. origin/main merged in (the branch was 35 commits behind and CONFLICTING, so no workflow was triggering at all). One conflict, in serve.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.
  2. The boot-outcome correction above.

Re-verified at this head after a full workspace build: @objectstack/service-cluster 84/84 (6 files) · @objectstack/cli multi-node + cluster suites 65/65 (4 files). packages/clitsc --noEmit reports the same 69 pre-existing errors with main's serve.ts as 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-review on 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

…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
@os-steveClaude

Copy link
Copy Markdown
CollaboratorAuthor

PM 复核:实现验收通过,但 ⛔ 暂不落地 —— 卡在一个跨仓时序裁决上(#14116)

先把话说清楚:这不是打回,也不是你的问题。 PR 完整、全绿、对着卡做对了,而且正是你自己报的那条 out-of-scope 发现让我停手的。⭐ 那条发现是本轮最有价值的一件事 —— 一个只想着交差的实现会闭嘴不说。

我独立复核了你的发现,成立

$ grep -rn "registerMultiNodeGate" --include=*.ts . | grep -v node_modules | grep -v /dist/ | grep -v '\.test\.'
packages/services/service-cluster/src/index.ts:77 ← 桶再导出
packages/services/service-cluster/src/multi-node-gate.ts:169 ← 定义本身

⇒ 两处命中,全是结构性的,本仓无任何生产注册者⚠️ 反向对照让这个零可读:同形搜 registerDriver 命中真实生产调用点(objectql/src/plugin.ts:625)⇒ grep 是活的。

为什么这挡住落地

方向是对的 —— 授权门 fail-open 本来就不能接受。但沉挂载只能加载载体,不能让载体注册。⇒ 在跳过 EE 配置的那两条启动路径上:

今天单独落 #14114cloud 补齐后
未授权多节点在跑(fail open)
已授权多节点在跑⚠️被拒 —— 响亮降为单节点在跑

⇒ 它在关掉「白嫖」的同一笔里打开了「付费客户失去能力」。这是部署可见的取舍,不是复核能拍的板,所以进决策箱:#14116

记你一笔:失败是响亮的 —— attempt 如实报 loaded-without-gate,serve 降级而不是 brick。这是「难受一下午」和「一次事故」的区别,是你把它做对的。⛔ 但它不能让降级变成免费的。

已办

验收侧我核过、没有异议的

  • 两条强制否定面各有专测:已授权(注册了门、admitted 5 / requested 3)不被挡;单副本与未声明/无意义值(NaN/0/-1)逐字节不变。
  • ablation 先声明方向,变异经 anchored grep 计数(1→0 / 0→1)与 blob 哈希(65ffad01de8c8fbb)在盘上证实,⛔ 非编辑器退出码;restore 以状态证明。⭐ 而且你主动说明了变异模块经同包相对 src 导入到达测试,所以那条解析路径上没有 dist —— 陈旧 dist 的 ablation 根本红不了,这句声明是它有效的前提。
  • 预测 3 红、实测 4 红,你没有掩饰,而是指出第四条是 reset 测试新加的最后一行断言的同一个分支 ⇒ 同类、方向与预测一致。⛔ 事后改预测才是问题,如实报差异不是。
  • check-adr-0087-registration 一度红(BREAKING changeset 缺 disposition),补 marker 后重跑绿 —— 红过并修好、且说出来,比只报最后那个绿好。
  • check-test-completeness 退出 3 = PREREQUISITE NOT MET,记为 NOT MEASURED,⛔ 未当绿也未当红。

你那条「不予立卡」的判断也对

memory.contract.test.ts 的既存 tsc 错误 + 该包无 typecheck 脚本 —— 两者都已被 check:type-check-debt 台账承载且本轮重测绿。为它立卡等于复制一行台账。 ⛔ 不立是对的。


Generated by Claude Code

hotlongand others added 2 commits September 1, 2026 18:23
…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>
@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 2 package(s): @objectstack/cli, @objectstack/service-cluster, touching 9 documentable anchor(s).

17 hand-written doc(s) name something this change touched — list omitted above 15 rows. Re-derive on the tree named below: node scripts/docs-audit/affected-docs.mjs --json 45b9051248f86f362b042fa9de63295a8c224073.

4 release-owned page(s) also affected — read-only, see AGENTS.md Documentation Guardrails.

What this run could not see
  • 5 name(s) were too generic to anchor anything (single lowercase words)
  • the SDK route bridge reached 47 of 219 client-bound route-ledger rows — the other 172 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 172: 14 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 56 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 102 are undecided (no in-repo declaration, on a ledger that has other in-repo registrars — absence and an unreadable spelling are not distinguishable here). The rows themselves: node scripts/docs-audit/affected-docs.mjs --bridge-coverage
  • a page that states a rule by its inputs shares no identifier with the emitter that implements the rule, so an emitter-only diff cannot list it — not on this run and not on any run. Measured on fix(driver-sql): emit varchar(maxLength) for a text field a declared index keys on #11430: content/docs/protocol/objectql/types.mdx documents the text-family column mapping by the ObjectQL type names it maps FROM (text / textarea / html) while the diff changed createColumn; it went unlisted, and it was the page that diff falsified, in four places. No shared token exists to detect this on, so a rule your change carries has to be re-read by hand in the pages that restate it.

Coarse fallback — 26 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): node scripts/docs-audit/affected-docs.mjs --json 45b9051248f86f362b042fa9de63295a8c224073packageMentionDocs.

Which tree this was computed on

This run read content/docs from b2daf8c038843425622c76972db76af82c7501d1 — the merge of head 1ba418ebf41441fbda52fbef3b6e89b43437c6c0 into base 45b9051248f86f362b042fa9de63295a8c224073, which is what actions/checkout gives a pull_request run. Not the PR head.

A worktree cut from an older main holds a different content/docs, so re-deriving there can legitimately return a different list — that is a different tree, not a wrong row. To answer on the same tree:

# 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

⚠️ That checkout carried uncommitted changes, so the commit above does not fully identify what was read.

Advisory only, and a precision-first one (#9192): a page is listed because it names a
symbol, wire route or SDK method this diff touched — not because it mentions a changed
package. Each row says which anchor put it there, so a wrong row is reportable rather than
merely annoying. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs 45b9051248f86f362b042fa9de63295a8c224073 → pass the list as
args.docs, on the commit named under Which tree this was computed on.

@github-actionsgithub-actionsBot added documentation Improvements or additions to documentation tests tooling labels Sep 1, 2026
@hotlong

Copy link
Copy Markdown
Contributor

📋 条款②契约复核 —— 材料呈送,⛔ 本席不自判清标

维护者 2026-09-01 裁定由维护者本人任复核席,本席只摆材料。⛔ 因此本评论不含放行结论needs:contract-review 双载体原样留置。

保险丝读数(细则要求每场必读):CONTRACT_REVIEW_TIER = 'claude-fable-5'scripts/pm/dispatch-gates.mjs:7102);本席服役档 claude-opus-5 ⇒ 读数 ≠ 档位 ⇒ 本席无自判清标资格。维护者席不受此保险丝约束(它约束的是补偿低档派发的代理复核)。


① Derived judgments —— diff 引出的接受集 / 公开面变化,逐条点名

J1 · 接受集收窄(这就是条款②的内容肢本体)

checkMultiNodeAllowed(requested)无 gate 注册时:

requested之前之后
undefined / NaN / 0 / 负数{allowed:true, refused:0, capped:false}逐字节相同
1同上 allow逐字节相同
> 1allow{allowed:false, reason:MULTI_NODE_NO_GATE_REASON, admitted:0, refused:N, capped:false}

⇒ 一个授权谓词的默认方向从 allow 翻到 refuse。触发键 = 运维声明的副本数,与 split-brain-guard.tsdeclaresMultiNodereplicas > 1)同一姿态。已注册 gate 的判定完全不变(byte-identical),已授权部署不受影响 —— 这一条有专门的负例测试钉着。

J2 · 公开面加宽(纯增量)

@objectstack/service-cluster 桶新导出 6 个名字:hasMultiNodeGate()MULTI_NODE_NO_GATE_REASONmountMultiNodeGateFromHost()MULTI_NODE_GATE_CARRIER_PACKAGEStype MultiNodeGateMountAttempttype MultiNodeGateMountReading。⛔ 无删除、无重命名、无签名变更。

⚠️ 一条排除项,免得误记在本 PR 头上:__resetMultiNodeGate(双下划线测试缝)已在 main 上导出git show origin/main:…/index.ts | grep -c = 1),非本 PR 引入。

J3 · ⚠️ 需要维护者定的那一条 —— 跨仓合成效应,两个 PR 各自的正文都没有这个形状

本 PR 的 fail-closed 只在 requested > 1 触发;但已注册的 gate 对任何计数都作答。cloud#1828(已合入 cloud main)让载体在模块加载时注册 ⇒ 两半合起来之后:

无证 + OS_CLUSTER_DRIVER=redis + 单副本 的部署,会被拒并回落进程内驱动 —— 而今天(无 gate ⇒ 放行)它保得住远程驱动。

⛔ 严格说这不是本 PR 发明的规则:EE config 路线今天就是这个行为,两半只是让其他路线与之对齐 —— 这正是卡片的目的。但它是一次真实的接受集变化,且只在跨仓合成后可见,所以点名请裁:接受此对齐,还是要求把无证单副本的远程驱动留出豁免? 本席倾向接受(对齐 > 例外),但这属产品面,⛔ 不自裁。

J4 · 运维可见文案变更(本席在接手后新增,非原实现者产物)

serve.ts 的拒绝告警原文是 downgrading to single-node (in-memory cluster),而进程随后拒启。已改为同时点名回落与随之而来的拒启,并给两条补救(撤销声明 / 授权该能力)。行为零变化,只有措辞

依据(#14116 实测链,已在 multi-node-gate.test.ts 新增回归块钉住):

无 gate + OS_CLUSTER_REPLICAS=3 → checkMultiNodeAllowed(3) = {allowed:false,…}
↓ serve: clusterConfig 保持 undefined
↓ Runtime({cluster:undefined}) → ClusterServicePlugin({}) → defineCluster({}) → "memory"
↓ assertClusterDriverSafeForTopology('memory') [replicas=3] → THROW ⇒ 拒启

fail-closed 的触发条件与 split-brain 护栏的触发条件是同一个声明 ⇒ 在真实多副本部署上,拒启是常态而非例外。⭐ 拒启本身正确(N 副本各持进程内锁 = 护栏要拦的脑裂),错的是「never bricks」那句承诺。


② Semver 定级 vs changeset 声明 —— 一致

  • changeset front-matter:@objectstack/service-cluster: minor · @objectstack/cli: patch
  • 正文自带 BREAKING 横幅,并援引仓内惯例:「BREAKING accept-set narrowing … shipped as minor under the launch-window convention」。
  • ✅ 该惯例实存且在用,非杜撰 —— 同形先例:.changeset/action-newtaburl-requires-opensinnewtab.mdadvanced-plugin-lifecycle-config-retired.mdapproval-auto-cancel-on-record-delete.md
  • ✅ ADR-0087 处置标记在案且门绿(本席改写该行措辞后重跑):1 declared-breaking changeset(s), each carrying an ADR-0087 dispositionnot-required (no-migration-prescription),exit 0。
  • CLI 侧 patch 恰当:一处解构 + 一处 guarded 调用 + 告警文案,无公开面变化。

⇒ ② 项本席未发现不一致。


③ 边界旗处置 —— 逐旗

处置
缺口 2(#13330 CJS/ESM 双实例分裂) 出界保持出界,PR 正文写明效力边界;⛔ 未在本 PR 单独加固该注册表(那正是分诊警告的反模式)。仍开着
cloud 侧载体必须有人做已消:cloud#1827 → cloud#1828 合入 cloud main cfbd4acf,两个载体包均在模块加载时注册。PR 正文已从「需要后续」改写为「已落地」。
「never bricks」⚠️原实现者未挂此旗 —— 是本席在 #14116 测量时发现的证否。已在 4 处(gate 模块、mount 模块、serve.ts、changeset)改正 + 加回归测试。⇒ 这是 ① J4 请裁的对象。
check-test-completeness.mjs 本地 exit 3(PREREQUISITE NOT MET)归 CI;CI 已绿。

落地前检三条的当前读数

#读数
席内契约档 PASS 在案未满足 —— 待维护者本场裁定
needs:contract-review 双载体已清未清(按细则原样留置,本席无资格剥)
PR 全部 check 全绿(非 required 子集)33 条:29 SUCCESS / 4 SKIPPED / 0 红

另两项前置也已机读:check-clause2-carriers.mjs --pair 14114 → 双载体可读且一致;check-governed-merges.mjs --test <最终文件表>0 of 7NOT governed,普通队列落地适用(⛔ 非受管面,不走 draft-only 终局)。

裁 PASS 即可剥标 → ready → 入队;裁 FAIL 或要求修改则走补丁轮。


Generated by Claude Code

@hotlong

Copy link
Copy Markdown
Contributor

⚖️ 条款②契约复核 PASS —— 维护者本人任复核席,2026-09-01

裁定:J3 选 A(接受对齐),verbatim「A」。维护者亲自复核,档位问题不适用(保险丝约束的是补偿低档派发的代理复核席,不是维护者席;本席 claude-opus-5 ⛔ 未自判,材料呈送见复核材料评论)。

被裁的那一条,连同裁定理由一并入档

问题:本 PR 与已合入的 cloud#1828 合成后,无证 + OS_CLUSTER_DRIVER=redis + 单副本的部署会被拒并回落进程内驱动。

A —— 门对任何副本数都作答,所有启动路线与 EE config 路线今天的行为对齐。⛔ 不给单副本开豁免。

理由(业务面,记录在案以免日后被当成"顺手收紧")OS_CLUSTER_REPLICAS 是运维自己声明、系统无法核实的数字,且本仓部署文档已记载 docker compose --scale app=N 只抬高真实副本数、不抬高运行时被告知的数(cloud#1502 的"静默脑裂的门")。⇒ 若给单副本豁免,一个未授权部署只需把该变量写成 1--scale app=5,门永远不会被问,远程驱动照常发放,五个副本真的在协同 —— 收费能力被一行环境变量拿走,且不可检测。选 A 后,谎报副本数得到的是"各跑各的",不是"免费的集群"。

代价一并记明:一批未测到的"脱离模板 + 无证 + 配了 redis + 单副本"部署会在升级后看到一条拒绝。功能上近乎无损(单进程本就该用进程内驱动,脑裂只在跨进程存在),受支持的部署模板不产生该形状(cloud#1742 核查:模板均 pin OS_CLUSTER_REPLICAS=1 且不配 redis)。

三项清单归档

落地前检(细则三条,维护者 2026-08-31「同意」)

#读数
席内契约档 PASS 在案✅ 本评论
needs:contract-review 双载体已清✅ 同笔剥(PR + 卡),机读复验见下
PR 全部 check 全绿(⛔ 非 required 子集)✅ 33 条:29 SUCCESS / 4 SKIPPED / 0 红

治理面谓词在最终文件表上重跑:0 of 7 ⇒ NOT governed,普通队列落地适用(⛔ 非受管面,draft-only 终局不适用)。

⇒ 剥标 → ready → 入队。


Generated by Claude Code

@hotlong
hotlong marked this pull request as ready for review September 1, 2026 11:10
@hotlong
hotlong enabled auto-merge September 1, 2026 11:10
@hotlong
hotlong added this pull request to the merge queueSep 1, 2026
Merged via the queue into main with commit 4d672c4Sep 1, 2026
43 checks passed
@hotlong
hotlong deleted the claude/issue-13537-multinode-gate-mount branch September 1, 2026 11:40
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentationImprovements or additions to documentationsize/lteststooling

Projects

None yet

3 participants

@os-steve@hotlong@claude
, '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

fix(service-cluster,cli): multi-node gate fails closed when unregistered, and mounts on every boot route - #14114

Merged
hotlong merged 5 commits into
mainfrom
claude/issue-13537-multinode-gate-mount
Sep 1, 2026
Merged

fix(service-cluster,cli): multi-node gate fails closed when unregistered, and mounts on every boot route#14114
hotlong merged 5 commits into
mainfrom
claude/issue-13537-multinode-gate-mount

Conversation

@os-steve

@os-steveos-steve commented Sep 1, 2026

Copy link
Copy Markdown
Collaborator

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):
checkMultiNodeAllowed used to return allowed: true whenever 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), so os serve drops 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_REPLICAS unset, 1, 0, negative, NaN) keeps the historical allow: it declares no multi-node topology, so there is nothing to gate — the same > 1 posture as split-brain-guard.ts's declaresMultiNode. 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 in packages/cli/src/commands/serve.ts):
registerMultiNodeGate was reachable from exactly one place — the EE app config (cloud repo) executing — so the thin-extension and OS_ARTIFACT_URL artifact-direct boot routes never mounted the gate at all. New mountMultiNodeGateFromHost(importFromHost) lives in domain:services and owns the carrier list (MULTI_NODE_GATE_CARRIER_PACKAGES: @objectstack/security-enterprise, @objectstack/organizations — both roster-declared enterprise runtimes, drift-tested against PLATFORM_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. serve calls 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 new import()/importFromHost(...) load sites in serve.ts — the carrier loads run inside service-cluster through 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):

  • a licensed deployment whose registration lands on the wrong instance is refused, loudly (fail-closed) instead of running an unlicensed cluster silently (fail-open) — the ruled direction, but operators of such deployments will see that refusal until the split is fixed and/or a carrier registers at module load on the instance the consult reads. ⛔ "Refused", not "downgraded": see "Boot outcome" below;
  • mountMultiNodeGateFromHost honestly 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.sh disclosure 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 main at cfbd4acf, adding a module-scope registerMultiNodeGate to BOTH @objectstack/security-enterprise and @objectstack/organizations — i.e. both names on this PR's MULTI_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 reporting loaded-without-gate on 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), and packages/cli/src/commands/serve.ts (mount call site). domain:cli half declared: the serve call site — one destructure + one guarded call; the mount logic and carrier list live in domain:services.

Verification

Negatives (mandated, one test each — both green):

  • Entitled deployment not blocked: registered gate with admitted: 5, requested: 3allowed: true, refused: 0, capped: false — the fail-closed branch is unreachable the moment a gate is registered ("never blocks a properly-entitled deployment").
  • Single-replica path unchanged: 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 + 2 and no gate + 9 → refused with MULTI_NODE_NO_GATE_REASON (the 9 pin 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 at ee607b488, whose packages/services/service-cluster tree is byte-identical to this PR's head (verified: git diff 30c2d3d02 ee607b488 -- packages/services/service-cluster is empty). Mutated blob de8c8fbb vs HEAD blob 65ffad01; 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 HEAD empty, 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 at 30c2d3d02, this PR's head):

  • Build: dependency closures + full turbo run build --filter='./packages/*' --filter='./packages/*/*' (lint.yml parity) — 70/70 successful.
  • Tests at 30c2d3d02: @objectstack/service-cluster 77/77 · @objectstack/service-cluster-redis (direct dist-coupled consumer) 28/28 · @objectstack/cli targeted suites (serve-multi-node-cap-advisory + .pin, serve-multi-node-cap-telemetry, serve-cluster-host-resolution source sweep, serve-app-anchored-optional-import.e2e real boot) 71/71 — vitest's own "Test Files N passed / Tests N passed" lines are the cited verdicts.
  • Typecheck: @objectstack/clitsc --noEmit exit 0 at 30c2d3d02 with serve.ts proven in the program via --listFiles. @objectstack/service-cluster has NO typecheck script; a manual tsc --noEmit --listFiles (run at ee607b488, 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 untouched src/memory/memory.contract.test.ts (carried by the type-check-debt ledger — see next line).
  • Gate families re-derived from the actual diff with node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack at a97319179 (both output sections read whole; 33 path-derived families + the convention-triggered test-file and error-code families). All run locally and green — including check:adr-0087-registration (red once for a missing disposition marker on the BREAKING changeset; fixed by the not-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-repo pnpm lint (eslint . --no-inline-config, exit 0).
  • NOT MEASURED (not red, not green): scripts/check-test-completeness.mjs exits 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.

⚠️ Boot outcome — a correction this PR carries, measured on #14116

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:

no gate + OS_CLUSTER_REPLICAS=3
→ checkMultiNodeAllowed(3) = {allowed:false, reason:MULTI_NODE_NO_GATE_REASON, refused:3}
↓ serve.ts: warns, leaves clusterConfig undefined
↓ Runtime({cluster: undefined}) → ClusterServicePlugin({}) → defineCluster({}) → driver "memory"
↓ assertClusterDriverSafeForTopology('memory') [OS_CLUSTER_REPLICAS=3]
✗ THROW — boot REFUSED

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.ts comments and the changeset: corrected, with a ⛔ note against restating "never bricks";
  • serve.ts's boot warning: it said downgrading 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);
  • new regression block in multi-node-gate.test.ts pinning 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_016ZC5rNQj3WEet5HAmmAkMs and 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:

  1. origin/main merged in (the branch was 35 commits behind and CONFLICTING, so no workflow was triggering at all). One conflict, in serve.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.
  2. The boot-outcome correction above.

Re-verified at this head after a full workspace build: @objectstack/service-cluster 84/84 (6 files) · @objectstack/cli multi-node + cluster suites 65/65 (4 files). packages/clitsc --noEmit reports the same 69 pre-existing errors with main's serve.ts as 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-review on 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

…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
@os-steveClaude

Copy link
Copy Markdown
CollaboratorAuthor

PM 复核:实现验收通过,但 ⛔ 暂不落地 —— 卡在一个跨仓时序裁决上(#14116)

先把话说清楚:这不是打回,也不是你的问题。 PR 完整、全绿、对着卡做对了,而且正是你自己报的那条 out-of-scope 发现让我停手的。⭐ 那条发现是本轮最有价值的一件事 —— 一个只想着交差的实现会闭嘴不说。

我独立复核了你的发现,成立

$ grep -rn "registerMultiNodeGate" --include=*.ts . | grep -v node_modules | grep -v /dist/ | grep -v '\.test\.'
packages/services/service-cluster/src/index.ts:77 ← 桶再导出
packages/services/service-cluster/src/multi-node-gate.ts:169 ← 定义本身

⇒ 两处命中,全是结构性的,本仓无任何生产注册者⚠️ 反向对照让这个零可读:同形搜 registerDriver 命中真实生产调用点(objectql/src/plugin.ts:625)⇒ grep 是活的。

为什么这挡住落地

方向是对的 —— 授权门 fail-open 本来就不能接受。但沉挂载只能加载载体,不能让载体注册。⇒ 在跳过 EE 配置的那两条启动路径上:

今天单独落 #14114cloud 补齐后
未授权多节点在跑(fail open)
已授权多节点在跑⚠️被拒 —— 响亮降为单节点在跑

⇒ 它在关掉「白嫖」的同一笔里打开了「付费客户失去能力」。这是部署可见的取舍,不是复核能拍的板,所以进决策箱:#14116

记你一笔:失败是响亮的 —— attempt 如实报 loaded-without-gate,serve 降级而不是 brick。这是「难受一下午」和「一次事故」的区别,是你把它做对的。⛔ 但它不能让降级变成免费的。

已办

验收侧我核过、没有异议的

  • 两条强制否定面各有专测:已授权(注册了门、admitted 5 / requested 3)不被挡;单副本与未声明/无意义值(NaN/0/-1)逐字节不变。
  • ablation 先声明方向,变异经 anchored grep 计数(1→0 / 0→1)与 blob 哈希(65ffad01de8c8fbb)在盘上证实,⛔ 非编辑器退出码;restore 以状态证明。⭐ 而且你主动说明了变异模块经同包相对 src 导入到达测试,所以那条解析路径上没有 dist —— 陈旧 dist 的 ablation 根本红不了,这句声明是它有效的前提。
  • 预测 3 红、实测 4 红,你没有掩饰,而是指出第四条是 reset 测试新加的最后一行断言的同一个分支 ⇒ 同类、方向与预测一致。⛔ 事后改预测才是问题,如实报差异不是。
  • check-adr-0087-registration 一度红(BREAKING changeset 缺 disposition),补 marker 后重跑绿 —— 红过并修好、且说出来,比只报最后那个绿好。
  • check-test-completeness 退出 3 = PREREQUISITE NOT MET,记为 NOT MEASURED,⛔ 未当绿也未当红。

你那条「不予立卡」的判断也对

memory.contract.test.ts 的既存 tsc 错误 + 该包无 typecheck 脚本 —— 两者都已被 check:type-check-debt 台账承载且本轮重测绿。为它立卡等于复制一行台账。 ⛔ 不立是对的。


Generated by Claude Code

hotlongand others added 2 commits September 1, 2026 18:23
…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>
@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 2 package(s): @objectstack/cli, @objectstack/service-cluster, touching 9 documentable anchor(s).

17 hand-written doc(s) name something this change touched — list omitted above 15 rows. Re-derive on the tree named below: node scripts/docs-audit/affected-docs.mjs --json 45b9051248f86f362b042fa9de63295a8c224073.

4 release-owned page(s) also affected — read-only, see AGENTS.md Documentation Guardrails.

What this run could not see
  • 5 name(s) were too generic to anchor anything (single lowercase words)
  • the SDK route bridge reached 47 of 219 client-bound route-ledger rows — the other 172 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 172: 14 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 56 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 102 are undecided (no in-repo declaration, on a ledger that has other in-repo registrars — absence and an unreadable spelling are not distinguishable here). The rows themselves: node scripts/docs-audit/affected-docs.mjs --bridge-coverage
  • a page that states a rule by its inputs shares no identifier with the emitter that implements the rule, so an emitter-only diff cannot list it — not on this run and not on any run. Measured on fix(driver-sql): emit varchar(maxLength) for a text field a declared index keys on #11430: content/docs/protocol/objectql/types.mdx documents the text-family column mapping by the ObjectQL type names it maps FROM (text / textarea / html) while the diff changed createColumn; it went unlisted, and it was the page that diff falsified, in four places. No shared token exists to detect this on, so a rule your change carries has to be re-read by hand in the pages that restate it.

Coarse fallback — 26 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): node scripts/docs-audit/affected-docs.mjs --json 45b9051248f86f362b042fa9de63295a8c224073packageMentionDocs.

Which tree this was computed on

This run read content/docs from b2daf8c038843425622c76972db76af82c7501d1 — the merge of head 1ba418ebf41441fbda52fbef3b6e89b43437c6c0 into base 45b9051248f86f362b042fa9de63295a8c224073, which is what actions/checkout gives a pull_request run. Not the PR head.

A worktree cut from an older main holds a different content/docs, so re-deriving there can legitimately return a different list — that is a different tree, not a wrong row. To answer on the same tree:

# 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

⚠️ That checkout carried uncommitted changes, so the commit above does not fully identify what was read.

Advisory only, and a precision-first one (#9192): a page is listed because it names a
symbol, wire route or SDK method this diff touched — not because it mentions a changed
package. Each row says which anchor put it there, so a wrong row is reportable rather than
merely annoying. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs 45b9051248f86f362b042fa9de63295a8c224073 → pass the list as
args.docs, on the commit named under Which tree this was computed on.

@github-actionsgithub-actionsBot added documentation Improvements or additions to documentation tests tooling labels Sep 1, 2026
@hotlong

Copy link
Copy Markdown
Contributor

📋 条款②契约复核 —— 材料呈送,⛔ 本席不自判清标

维护者 2026-09-01 裁定由维护者本人任复核席,本席只摆材料。⛔ 因此本评论不含放行结论needs:contract-review 双载体原样留置。

保险丝读数(细则要求每场必读):CONTRACT_REVIEW_TIER = 'claude-fable-5'scripts/pm/dispatch-gates.mjs:7102);本席服役档 claude-opus-5 ⇒ 读数 ≠ 档位 ⇒ 本席无自判清标资格。维护者席不受此保险丝约束(它约束的是补偿低档派发的代理复核)。


① Derived judgments —— diff 引出的接受集 / 公开面变化,逐条点名

J1 · 接受集收窄(这就是条款②的内容肢本体)

checkMultiNodeAllowed(requested)无 gate 注册时:

requested之前之后
undefined / NaN / 0 / 负数{allowed:true, refused:0, capped:false}逐字节相同
1同上 allow逐字节相同
> 1allow{allowed:false, reason:MULTI_NODE_NO_GATE_REASON, admitted:0, refused:N, capped:false}

⇒ 一个授权谓词的默认方向从 allow 翻到 refuse。触发键 = 运维声明的副本数,与 split-brain-guard.tsdeclaresMultiNodereplicas > 1)同一姿态。已注册 gate 的判定完全不变(byte-identical),已授权部署不受影响 —— 这一条有专门的负例测试钉着。

J2 · 公开面加宽(纯增量)

@objectstack/service-cluster 桶新导出 6 个名字:hasMultiNodeGate()MULTI_NODE_NO_GATE_REASONmountMultiNodeGateFromHost()MULTI_NODE_GATE_CARRIER_PACKAGEStype MultiNodeGateMountAttempttype MultiNodeGateMountReading。⛔ 无删除、无重命名、无签名变更。

⚠️ 一条排除项,免得误记在本 PR 头上:__resetMultiNodeGate(双下划线测试缝)已在 main 上导出git show origin/main:…/index.ts | grep -c = 1),非本 PR 引入。

J3 · ⚠️ 需要维护者定的那一条 —— 跨仓合成效应,两个 PR 各自的正文都没有这个形状

本 PR 的 fail-closed 只在 requested > 1 触发;但已注册的 gate 对任何计数都作答。cloud#1828(已合入 cloud main)让载体在模块加载时注册 ⇒ 两半合起来之后:

无证 + OS_CLUSTER_DRIVER=redis + 单副本 的部署,会被拒并回落进程内驱动 —— 而今天(无 gate ⇒ 放行)它保得住远程驱动。

⛔ 严格说这不是本 PR 发明的规则:EE config 路线今天就是这个行为,两半只是让其他路线与之对齐 —— 这正是卡片的目的。但它是一次真实的接受集变化,且只在跨仓合成后可见,所以点名请裁:接受此对齐,还是要求把无证单副本的远程驱动留出豁免? 本席倾向接受(对齐 > 例外),但这属产品面,⛔ 不自裁。

J4 · 运维可见文案变更(本席在接手后新增,非原实现者产物)

serve.ts 的拒绝告警原文是 downgrading to single-node (in-memory cluster),而进程随后拒启。已改为同时点名回落与随之而来的拒启,并给两条补救(撤销声明 / 授权该能力)。行为零变化,只有措辞

依据(#14116 实测链,已在 multi-node-gate.test.ts 新增回归块钉住):

无 gate + OS_CLUSTER_REPLICAS=3 → checkMultiNodeAllowed(3) = {allowed:false,…}
↓ serve: clusterConfig 保持 undefined
↓ Runtime({cluster:undefined}) → ClusterServicePlugin({}) → defineCluster({}) → "memory"
↓ assertClusterDriverSafeForTopology('memory') [replicas=3] → THROW ⇒ 拒启

fail-closed 的触发条件与 split-brain 护栏的触发条件是同一个声明 ⇒ 在真实多副本部署上,拒启是常态而非例外。⭐ 拒启本身正确(N 副本各持进程内锁 = 护栏要拦的脑裂),错的是「never bricks」那句承诺。


② Semver 定级 vs changeset 声明 —— 一致

  • changeset front-matter:@objectstack/service-cluster: minor · @objectstack/cli: patch
  • 正文自带 BREAKING 横幅,并援引仓内惯例:「BREAKING accept-set narrowing … shipped as minor under the launch-window convention」。
  • ✅ 该惯例实存且在用,非杜撰 —— 同形先例:.changeset/action-newtaburl-requires-opensinnewtab.mdadvanced-plugin-lifecycle-config-retired.mdapproval-auto-cancel-on-record-delete.md
  • ✅ ADR-0087 处置标记在案且门绿(本席改写该行措辞后重跑):1 declared-breaking changeset(s), each carrying an ADR-0087 dispositionnot-required (no-migration-prescription),exit 0。
  • CLI 侧 patch 恰当:一处解构 + 一处 guarded 调用 + 告警文案,无公开面变化。

⇒ ② 项本席未发现不一致。


③ 边界旗处置 —— 逐旗

处置
缺口 2(#13330 CJS/ESM 双实例分裂) 出界保持出界,PR 正文写明效力边界;⛔ 未在本 PR 单独加固该注册表(那正是分诊警告的反模式)。仍开着
cloud 侧载体必须有人做已消:cloud#1827 → cloud#1828 合入 cloud main cfbd4acf,两个载体包均在模块加载时注册。PR 正文已从「需要后续」改写为「已落地」。
「never bricks」⚠️原实现者未挂此旗 —— 是本席在 #14116 测量时发现的证否。已在 4 处(gate 模块、mount 模块、serve.ts、changeset)改正 + 加回归测试。⇒ 这是 ① J4 请裁的对象。
check-test-completeness.mjs 本地 exit 3(PREREQUISITE NOT MET)归 CI;CI 已绿。

落地前检三条的当前读数

#读数
席内契约档 PASS 在案未满足 —— 待维护者本场裁定
needs:contract-review 双载体已清未清(按细则原样留置,本席无资格剥)
PR 全部 check 全绿(非 required 子集)33 条:29 SUCCESS / 4 SKIPPED / 0 红

另两项前置也已机读:check-clause2-carriers.mjs --pair 14114 → 双载体可读且一致;check-governed-merges.mjs --test <最终文件表>0 of 7NOT governed,普通队列落地适用(⛔ 非受管面,不走 draft-only 终局)。

裁 PASS 即可剥标 → ready → 入队;裁 FAIL 或要求修改则走补丁轮。


Generated by Claude Code

@hotlong

Copy link
Copy Markdown
Contributor

⚖️ 条款②契约复核 PASS —— 维护者本人任复核席,2026-09-01

裁定:J3 选 A(接受对齐),verbatim「A」。维护者亲自复核,档位问题不适用(保险丝约束的是补偿低档派发的代理复核席,不是维护者席;本席 claude-opus-5 ⛔ 未自判,材料呈送见复核材料评论)。

被裁的那一条,连同裁定理由一并入档

问题:本 PR 与已合入的 cloud#1828 合成后,无证 + OS_CLUSTER_DRIVER=redis + 单副本的部署会被拒并回落进程内驱动。

A —— 门对任何副本数都作答,所有启动路线与 EE config 路线今天的行为对齐。⛔ 不给单副本开豁免。

理由(业务面,记录在案以免日后被当成"顺手收紧")OS_CLUSTER_REPLICAS 是运维自己声明、系统无法核实的数字,且本仓部署文档已记载 docker compose --scale app=N 只抬高真实副本数、不抬高运行时被告知的数(cloud#1502 的"静默脑裂的门")。⇒ 若给单副本豁免,一个未授权部署只需把该变量写成 1--scale app=5,门永远不会被问,远程驱动照常发放,五个副本真的在协同 —— 收费能力被一行环境变量拿走,且不可检测。选 A 后,谎报副本数得到的是"各跑各的",不是"免费的集群"。

代价一并记明:一批未测到的"脱离模板 + 无证 + 配了 redis + 单副本"部署会在升级后看到一条拒绝。功能上近乎无损(单进程本就该用进程内驱动,脑裂只在跨进程存在),受支持的部署模板不产生该形状(cloud#1742 核查:模板均 pin OS_CLUSTER_REPLICAS=1 且不配 redis)。

三项清单归档

落地前检(细则三条,维护者 2026-08-31「同意」)

#读数
席内契约档 PASS 在案✅ 本评论
needs:contract-review 双载体已清✅ 同笔剥(PR + 卡),机读复验见下
PR 全部 check 全绿(⛔ 非 required 子集)✅ 33 条:29 SUCCESS / 4 SKIPPED / 0 红

治理面谓词在最终文件表上重跑:0 of 7 ⇒ NOT governed,普通队列落地适用(⛔ 非受管面,draft-only 终局不适用)。

⇒ 剥标 → ready → 入队。


Generated by Claude Code

@hotlong
hotlong marked this pull request as ready for review September 1, 2026 11:10
@hotlong
hotlong enabled auto-merge September 1, 2026 11:10
@hotlong
hotlong added this pull request to the merge queueSep 1, 2026
Merged via the queue into main with commit 4d672c4Sep 1, 2026
43 checks passed
@hotlong
hotlong deleted the claude/issue-13537-multinode-gate-mount branch September 1, 2026 11:40
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentationImprovements or additions to documentationsize/lteststooling

Projects

None yet

3 participants

@os-steve@hotlong@claude