You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
{{ message }}
Repository navigation
MultiNodeGate is count-blind: allowMultiNode() returns a boolean verdict, so a license's max_nodes cap can only deny the whole cluster, never the excess replicas #8367
Filed unassigned and unlabeled for triage from the repo:cloud seat (session session_01TPHe3S9uZYC9wtUwdkRZ46) while accepting cloud#1278 (ADR-0022 Phase 1 runtime online client, PR cloud#1291). I am not this repo's triage and do not grade or route here.
Fact (measured against 62b6a2fb, the SHA cloud pins today)
registerMultiNodeGate consumes { allowMultiNode(): { allowed, reason } } — a boolean verdict with no node count in the contract. The EE license layer (@objectstack/security-enterprise) registers into this seam.
Why it matters now
The maintainer ruled on 2026-08-13 (recorded on cloud#1275) that a licensed max_nodes overflow must refuse the excess replicas — run up to the paid limit — plus a loud warning, explicitly NOT whole-cluster degrade. cloud#1291 shipped everything expressible from the consumer side: the per-replica verdict (checkNodeCap), the loud warning, and node-count telemetry to the control plane. But the refusal cannot be made binding through a boolean gate: denying allowMultiNode is precisely the whole-cluster degrade the ruling rejected, so today the cap is advisory at the clustering seam.
Suggested shape (not a decision)
Widen the seam to carry a count — e.g. allowMultiNode(nodeCount: number) returning an admitted count, or a per-node admission callback — so a license cap can admit N and refuse N+1..M. Cloud's consumer change afterwards is one line (cloud#1291's checkNodeCap already computes { allowed, refused }).
Refs: cloud#1275 (the ruling), cloud#1278 / PR cloud#1291 (the consumer half, with the measurement in its report), docs/adr/ ADR-0022 in the cloud repo (D4 multi-node gating).
Triage: lands in packages/services/service-cluster (registerMultiNodeGate / allowMultiNode, verified on origin/main) ⇒ domain:services, pm:queue, type Task. Rationale: implements the maintainer's 2026-08-13 ruling recorded on objectstack-ai/cloud#1275 (refuse excess replicas up to the paid limit, loud warning — NOT whole-cluster degrade); the semantic decision is made, this card is the seam-widening that makes it enforceable, so it does not enter the decision inbox. Contract note for the dispatching seat: allowMultiNode is a published seam consumed by @objectstack/security-enterprise in the cloud repo — widen it backward-compatibly (count-carrying overload or admitted-count return) and coordinate the one-line consumer change in cloud after this lands (cloud#1291 already computes { allowed, refused }). Size/model suggestion: S–M but with design judgement on the seam shape ⇒ opus.
Claim: PM loop round 14 — services seat #6021
Session: session_01ARidKDYSCD56LaygrvDPnk
Branch: claude/issue-8367-multinode-gate-count
Worktree: objectstack-issue-8367
Domain: domain:services
File surface: packages/services/service-cluster/src (+ its tests, changeset). ⛔ No cloud-repo consumer change in this PR.
Container & model: M, mode:subagent, model: claude-opus-5
Serial constraints cleared — no open PR touches service-cluster.
⛔ The semantic decision is ALREADY RULED — do not re-open it
The maintainer ruled 2026-08-13 (recorded on objectstack-ai/cloud#1275): a licensed max_nodes overflow refuses the excess replicas, runs up to the paid limit, and warns loudly — explicitly NOT whole-cluster degrade.
⚠️ This card is not asking whether to do that. It is the seam widening that makes the ruled behaviour enforceable: today allowMultiNode() returns a bare { allowed, reason }, so the only refusal a license can express through the seam is denying the whole cluster — which is precisely the outcome the ruling rejects. The cap is therefore advisory at the clustering seam, and cloud#1291 shipped everything expressible from the consumer side already.
⛔ Backward compatibility is a hard constraint, not a preference
allowMultiNode is a published seam consumed by @objectstack/security-enterprise in the cloud repo. A breaking signature change strands the EE provider. Widen compatibly — a count-carrying overload, an optional argument, or an admitted-count return that still reads correctly for an existing boolean-shaped provider. Measure what the existing registration sites actually pass and return before choosing; the card's suggested shape (allowMultiNode(nodeCount)) is a suggestion, ⛔ not a ruling.
The card's own framing of shape is explicitly non-binding
The filer wrote 「Suggested shape (not a decision)」 and is not this repo's triage. Treat both the signature choice and the admitted-count representation as PM mechanism assumptions open to falsification — if measurement says a per-node admission callback composes better with how the gate is actually consumed, take that and say why.
Out of scope
⛔ The one-line cloud consumer change (cloud#1291's checkNodeCap already computes { allowed, refused }) is sequenced after this lands. Do not open a cloud PR from this card. Report the exact consumer signature this PR leaves behind so the follow-up is mechanical.
{
"issue": 8367,
"status": "done",
"branch": "claude/issue-8367-multinode-gate-count",
"pr": "https://github.com/objectstack-ai/objectstack/pull/8503",
"premise_still_valid": true,
"summary": "Re-verified the card's premise on current origin/main (ff1e9b6a9), not just the cloud-pinned 62b6a2fb: packages/services/service-cluster/src/multi-node-gate.ts:22 is still `allowMultiNode(): { allowed: boolean; reason?: string }`. Widened it to `allowMultiNode(requested?: number): MultiNodeVerdict` where the verdict may carry `admitted`, and `checkMultiNodeAllowed(requested?)` forwards the count and returns a normalized verdict with totalized `refused`/`capped`; `capped` marks only a PARTIAL refusal so the licensed-overflow case cannot be conflated with the unlicensed one. Chose the optional-argument shape over the per-node admission callback on measurement: a gate is a module singleton inside ONE replica's process with no cross-process state, so a per-node callback would have each replica's provider start its own counter at zero and admit itself — phantom enforcement. ZONE 2 FALSIFICATION, the bigger result: the 'advisory at the seam' claim held and is STRONGER than the card stated — a count-carrying verdict is necessary but NOT sufficient, and no consumer-side change can close it. The gate is consulted once per process at boot, where a replica has no membership view (nodeId is Math.random-generated per process at cluster.ts:123; nothing tracks live nodes) and no ordinal (OS_CLUSTER_REPLICAS is an operator-DECLARED count, identical in every replica). With cap 3 and 5 replicas all five compute the same verdict and none can tell whether it is one of the admitted 3, leaving only 'all join' (cap advisory) or 'all refuse' (the whole-cluster degrade the ruling rejects). Binding enforcement needs an atomic slot claim against the ILock/ICounter/IKV primitives this package already ships — filed as #8501, not shipped here. The PR documents the limitation in the module doc and changeset rather than implying enforcement.",
"tests": "BASELINE (unmodified worktree): `pnpm --filter @objectstack/service-cluster test` -> 'Test Files 3 passed (3) / Tests 45 passed (45)'. RED (11 tests added, implementation byte-identical to origin/main, proven by `git diff --quiet packages/services/service-cluster/src/multi-node-gate.ts`): predicted RED before running, read 'Tests 9 failed | 47 passed (56)' — diagnostics name the mechanism: 'AssertionError: expected [ undefined ] to deeply equal [ 5 ]' (count dropped, never reaches the gate), 'expected { allowed: true, admitted: 5 } to deeply equal { allowed: true, admitted: 3, ...}' (provider's raw object passes through unclamped, no refused/capped), 'expected { allowed: true, admitted: -2 } to match object { admitted: +0, refused: 3 }' (degenerate counts unnormalized). GREEN after implementation: 'Test Files 3 passed (3) / Tests 56 passed (56)'. VACUITY TRAP named and closed: an assertion reading only allowed/reason, or one whose requested count never exceeds the cap, passes verbatim against a completely unwidened tree since refused would be 0 either way — every count pin therefore requests strictly MORE than the cap, and partial-cap pins assert `allowed === true` in the SAME expectation (a gate denying the whole cluster would also yield refused > 0, so the allowed half is what distinguishes the ruled behaviour from the rejected one). Measured proof it matters: 2 of the 11 new tests (the uncounted compat pins) were GREEN on main by construction and are reported as NON-DISCRIMINATING rather than counted as evidence. COMPATIBILITY PIN (the card's core risk): a provider written byte-for-byte as the EE one registers today — zero-arg arrow returning bare { allowed, reason } — is pinned in tests under BOTH an uncounted and a COUNTED call, and must admit everything requested (refused: 0) rather than have a refusal invented for it. TYPE LEVEL against the REBUILT dist/index.d.ts (service-cluster rebuilt first): the EE zero-arg shape, the count-aware follow-up shape and today's zero-arg CLI call site all compile clean (tsc exit 0). Reverse type verification fires as predicted, exit 2, errors naming MultiNodeVerdict: 'TS2322: Type string is not assignable to type number' (string admitted), 'TS2741: Property allowed is missing in type { admitted: number; } but required in type MultiNodeVerdict', 'TS2345: Argument of type string is not assignable to parameter of type number', 'TS2322: Type number is not assignable to type undefined' (refused is totalized). CONSUMER SWEEP, DOWNSTREAM direction (prefix form `--filter '...@objectstack/service-cluster'`, 16 packages downstream): @objectstack/cli, @objectstack/runtime and @objectstack/service-cluster-redis all typecheck Done AFTER building the CLI's 55-package closure; before that build they emitted 'Cannot find module' errors that read exactly like broken imports — a false red from the fresh worktree, not from this change.",
"gates": "Re-derived against the ACTUAL changed paths via `node scripts/pm/dispatch-gates.mjs`, then run with true exit codes captured (a first pass wrongly read `tail`'s status and was redone). All exit 0: check:changeset-gate-self-tests, check:docs-audit-scope, check:objectui-changeset, check:test-source-alias, check:type-source-resolution, check-adr-0087-registration, check-changeset-fixed, check-changeset-no-major, check-empty-changeset. Convention-scoped, added on judgment and by the script's own convention detection: check:query-options-erasure (exit 0, 'baseline key set verified against ff1e9b6: no files added'), check:type-check-coverage (exit 0), check:nul-bytes (exit 0, 7643 files scanned) plus a manual control-byte self-scan over all four changed files (clean), check:error-code-casing (exit 0). ONE PRE-EXISTING RED, not from this change: check:objectui-pin-fresh exits 1 — it reads only .objectui-sha, which this PR does not touch (git status confirms the diff is 4 files, none of them that pin); it entered the derived list solely because the diff touches .changeset/, and its own output cites #3340 as its tracking issue.",
"consumer_signature_for_cloud_followup": "Unchanged and still valid as-is: `registerMultiNodeGate({ allowMultiNode: () => resolveMultiNodeEntitlement(process.env.OS_LICENSE_KEY) })` at cloud apps/objectos-ee/objectstack.config.ts:92-94 keeps working and reads as 'no cap'. To adopt the cap, only the registration changes (maxNodes is already on the entitlement at cloud packages/security-enterprise/src/license/entitlement.ts:173,258): `registerMultiNodeGate({ allowMultiNode: (requested) => { const ent = resolveMultiNodeEntitlement(process.env.OS_LICENSE_KEY); if (!ent.allowed) return ent; const cap = getActiveEntitlement()?.maxNodes; return cap === undefined ? ent : { ...ent, admitted: Math.min(requested ?? cap, cap) }; } })`. Framework-side types exported for it: MultiNodeGate, MultiNodeVerdict { allowed: boolean; reason?: string; admitted?: number }, ResolvedMultiNodeVerdict { allowed: boolean; reason?: string; admitted?: number; refused: number; capped: boolean }. NOTE for sequencing: the framework's own CLI consumer (packages/cli/src/commands/serve.ts:1234-1247) still calls checkMultiNodeAllowed() zero-arg and re-declares the return type locally inside a dynamic-import cast, so it neither sees nor is broken by the widening — teaching it to pass a count and emit the ruling's loud warning was left out of scope per the dispatch's file surface, and is the other half of the follow-up alongside cloud.",
"open_questions": [],
"out_of_scope_findings": [
"filed as #8501: a licensed max_nodes cap still cannot be enforced by any replica — no cluster membership view and no slot claim, so a count-carrying gate verdict stays advisory; needs an atomic slot claim against ILock/ICounter/IKV (labelled `finding`, unassigned, no pm:queue, Blocked-by: #8367)",
"not filed (self-tracking): check:objectui-pin-fresh is red on main because .objectui-sha is stale; the gate's own output names #3340 as its tracking issue, so a new card would be noise. Reported here so it is not mistaken for this PR's red."
]
}
⛔ CLOSED — but the 2026-08-13 ruling is NOT enforced. Read this before treating it as implemented.
Post-merge inventory, domain:services seat #6021, session session_01ARidKDYSCD56LaygrvDPnk. PR #8503 MERGED 2026-08-13T16:49:09Z via the merge queue (25 checks success; queue entry confirmed by the enqueued event, ⛔ not by the auto-merge receipt, whose fields have come back empty every time this shift). Auto-close verified against closed_by_pull_requests → #8503. pm:dispatched dropped; nothing is in flight.
⚠️This card closing does NOT mean a licensed max_nodes cap is enforced. It is not. A cap of 3 with 5 replicas still starts 5.
I am stating that at the top because this card is exactly the sort that gets read as a checkmark against a maintainer ruling. It is not one.
What actually changed
The seam can now express the ruled verdict: allowMultiNode(requested?: number) returns a verdict that may carry admitted, and checkMultiNodeAllowed totalizes it into { allowed, reason, admitted?, refused, capped }. capped marks only a partial refusal, so the licensed-overflow case cannot be conflated with the unlicensed one. Backward compatible: the existing EE zero-arg provider keeps working and reads as "no cap".
⭐ Why this card's own premise was wrong, recorded so it is not re-derived
This card states the widening is what makes the refusal binding, and triage's routing repeated it ("the seam-widening that makes it enforceable"). Measurement during implementation falsified that. It is necessary but not sufficient, and ⛔ no consumer-side change can close the gap:
The gate is consulted once per process at boot, by each replica independently (packages/cli/src/commands/serve.ts:1234-1247).
There is no membership view — nodeId is generated randomly per process (cluster.ts:123); nothing registers a node, tracks liveness, or counts live members.
There is no ordinal — OS_CLUSTER_REPLICAS is an operator-declared desired count, identical in every replica.
With a cap of 3 and 5 replicas, all five compute the same verdict and none can determine whether it is one of the admitted 3. The only locally reachable outcomes are "all join" (cap advisory — the status quo) or "all refuse" (the whole-cluster degrade the ruling explicitly rejects). "Run N, refuse N+1" is not producible from a per-replica decision over a count alone.
⭐ The PR therefore documents the limitation in the module doc and changeset rather than implying enforcement. That was the right call and it is the reason this closure is safe to make.
The ruling has three clauses. This card delivered none of them — it delivered the vocabulary.
#8504 — os serve calls the gate zero-arg, so the partial-cap state is unreachable from the only call site, not merely unread
⭐ #8504 is deliverable now and does not wait on #8501 — but its wording must be advisory while #8501 is open, because a warning that says "2 replicas refused" would be false today.
For the cloud follow-up
⛔ Not required, and safe to defer: the current zero-arg registration keeps working and reads as uncapped. To adopt the cap, only the registration changes (maxNodes is already on the entitlement at packages/security-enterprise/src/license/entitlement.ts:173,258) — the exact shape is in PR #8503's body. ⚠️ Adopting it alone still does not enforce anything; it only makes the provider supply the number that #8501 would enforce.
Anyone auditing "is the 2026-08-13 ruling implemented?" should read #8501 and #8504, not this card.
Activity
hotlong commentedon Aug 13, 2026
Triage: lands in
packages/services/service-cluster(registerMultiNodeGate/allowMultiNode, verified onorigin/main) ⇒domain:services,pm:queue, type Task. Rationale: implements the maintainer's 2026-08-13 ruling recorded on objectstack-ai/cloud#1275 (refuse excess replicas up to the paid limit, loud warning — NOT whole-cluster degrade); the semantic decision is made, this card is the seam-widening that makes it enforceable, so it does not enter the decision inbox. Contract note for the dispatching seat:allowMultiNodeis a published seam consumed by@objectstack/security-enterprisein the cloud repo — widen it backward-compatibly (count-carrying overload or admitted-count return) and coordinate the one-line consumer change in cloud after this lands (cloud#1291 already computes{ allowed, refused }). Size/model suggestion: S–M but with design judgement on the seam shape ⇒ opus.Generated by Claude Code
os-zhuang commentedon Aug 13, 2026
Claim: PM loop round 14 — services seat #6021
Session:
session_01ARidKDYSCD56LaygrvDPnkBranch:
claude/issue-8367-multinode-gate-countWorktree:
objectstack-issue-8367Domain:
domain:servicesFile surface:
packages/services/service-cluster/src(+ its tests, changeset). ⛔ No cloud-repo consumer change in this PR.Container & model:
M,mode:subagent,model: claude-opus-5Serial constraints cleared — no open PR touches
service-cluster.⛔ The semantic decision is ALREADY RULED — do not re-open it
The maintainer ruled 2026-08-13 (recorded on objectstack-ai/cloud#1275): a licensed
max_nodesoverflow refuses the excess replicas, runs up to the paid limit, and warns loudly — explicitly NOT whole-cluster degrade.allowMultiNode()returns a bare{ allowed, reason }, so the only refusal a license can express through the seam is denying the whole cluster — which is precisely the outcome the ruling rejects. The cap is therefore advisory at the clustering seam, and cloud#1291 shipped everything expressible from the consumer side already.⛔ Backward compatibility is a hard constraint, not a preference
allowMultiNodeis a published seam consumed by@objectstack/security-enterprisein the cloud repo. A breaking signature change strands the EE provider. Widen compatibly — a count-carrying overload, an optional argument, or an admitted-count return that still reads correctly for an existing boolean-shaped provider. Measure what the existing registration sites actually pass and return before choosing; the card's suggested shape (allowMultiNode(nodeCount)) is a suggestion, ⛔ not a ruling.The card's own framing of shape is explicitly non-binding
The filer wrote 「Suggested shape (not a decision)」 and is not this repo's triage. Treat both the signature choice and the admitted-count representation as PM mechanism assumptions open to falsification — if measurement says a per-node admission callback composes better with how the gate is actually consumed, take that and say why.
Out of scope
⛔ The one-line cloud consumer change (cloud#1291's
checkNodeCapalready computes{ allowed, refused }) is sequenced after this lands. Do not open a cloud PR from this card. Report the exact consumer signature this PR leaves behind so the follow-up is mechanical.Generated by Claude Code
os-zhuang commentedon Aug 13, 2026
{ "issue": 8367, "status": "done", "branch": "claude/issue-8367-multinode-gate-count", "pr": "https://github.com/objectstack-ai/objectstack/pull/8503", "premise_still_valid": true, "summary": "Re-verified the card's premise on current origin/main (ff1e9b6a9), not just the cloud-pinned 62b6a2fb: packages/services/service-cluster/src/multi-node-gate.ts:22 is still `allowMultiNode(): { allowed: boolean; reason?: string }`. Widened it to `allowMultiNode(requested?: number): MultiNodeVerdict` where the verdict may carry `admitted`, and `checkMultiNodeAllowed(requested?)` forwards the count and returns a normalized verdict with totalized `refused`/`capped`; `capped` marks only a PARTIAL refusal so the licensed-overflow case cannot be conflated with the unlicensed one. Chose the optional-argument shape over the per-node admission callback on measurement: a gate is a module singleton inside ONE replica's process with no cross-process state, so a per-node callback would have each replica's provider start its own counter at zero and admit itself — phantom enforcement. ZONE 2 FALSIFICATION, the bigger result: the 'advisory at the seam' claim held and is STRONGER than the card stated — a count-carrying verdict is necessary but NOT sufficient, and no consumer-side change can close it. The gate is consulted once per process at boot, where a replica has no membership view (nodeId is Math.random-generated per process at cluster.ts:123; nothing tracks live nodes) and no ordinal (OS_CLUSTER_REPLICAS is an operator-DECLARED count, identical in every replica). With cap 3 and 5 replicas all five compute the same verdict and none can tell whether it is one of the admitted 3, leaving only 'all join' (cap advisory) or 'all refuse' (the whole-cluster degrade the ruling rejects). Binding enforcement needs an atomic slot claim against the ILock/ICounter/IKV primitives this package already ships — filed as #8501, not shipped here. The PR documents the limitation in the module doc and changeset rather than implying enforcement.", "tests": "BASELINE (unmodified worktree): `pnpm --filter @objectstack/service-cluster test` -> 'Test Files 3 passed (3) / Tests 45 passed (45)'. RED (11 tests added, implementation byte-identical to origin/main, proven by `git diff --quiet packages/services/service-cluster/src/multi-node-gate.ts`): predicted RED before running, read 'Tests 9 failed | 47 passed (56)' — diagnostics name the mechanism: 'AssertionError: expected [ undefined ] to deeply equal [ 5 ]' (count dropped, never reaches the gate), 'expected { allowed: true, admitted: 5 } to deeply equal { allowed: true, admitted: 3, ...}' (provider's raw object passes through unclamped, no refused/capped), 'expected { allowed: true, admitted: -2 } to match object { admitted: +0, refused: 3 }' (degenerate counts unnormalized). GREEN after implementation: 'Test Files 3 passed (3) / Tests 56 passed (56)'. VACUITY TRAP named and closed: an assertion reading only allowed/reason, or one whose requested count never exceeds the cap, passes verbatim against a completely unwidened tree since refused would be 0 either way — every count pin therefore requests strictly MORE than the cap, and partial-cap pins assert `allowed === true` in the SAME expectation (a gate denying the whole cluster would also yield refused > 0, so the allowed half is what distinguishes the ruled behaviour from the rejected one). Measured proof it matters: 2 of the 11 new tests (the uncounted compat pins) were GREEN on main by construction and are reported as NON-DISCRIMINATING rather than counted as evidence. COMPATIBILITY PIN (the card's core risk): a provider written byte-for-byte as the EE one registers today — zero-arg arrow returning bare { allowed, reason } — is pinned in tests under BOTH an uncounted and a COUNTED call, and must admit everything requested (refused: 0) rather than have a refusal invented for it. TYPE LEVEL against the REBUILT dist/index.d.ts (service-cluster rebuilt first): the EE zero-arg shape, the count-aware follow-up shape and today's zero-arg CLI call site all compile clean (tsc exit 0). Reverse type verification fires as predicted, exit 2, errors naming MultiNodeVerdict: 'TS2322: Type string is not assignable to type number' (string admitted), 'TS2741: Property allowed is missing in type { admitted: number; } but required in type MultiNodeVerdict', 'TS2345: Argument of type string is not assignable to parameter of type number', 'TS2322: Type number is not assignable to type undefined' (refused is totalized). CONSUMER SWEEP, DOWNSTREAM direction (prefix form `--filter '...@objectstack/service-cluster'`, 16 packages downstream): @objectstack/cli, @objectstack/runtime and @objectstack/service-cluster-redis all typecheck Done AFTER building the CLI's 55-package closure; before that build they emitted 'Cannot find module' errors that read exactly like broken imports — a false red from the fresh worktree, not from this change.", "gates": "Re-derived against the ACTUAL changed paths via `node scripts/pm/dispatch-gates.mjs`, then run with true exit codes captured (a first pass wrongly read `tail`'s status and was redone). All exit 0: check:changeset-gate-self-tests, check:docs-audit-scope, check:objectui-changeset, check:test-source-alias, check:type-source-resolution, check-adr-0087-registration, check-changeset-fixed, check-changeset-no-major, check-empty-changeset. Convention-scoped, added on judgment and by the script's own convention detection: check:query-options-erasure (exit 0, 'baseline key set verified against ff1e9b6: no files added'), check:type-check-coverage (exit 0), check:nul-bytes (exit 0, 7643 files scanned) plus a manual control-byte self-scan over all four changed files (clean), check:error-code-casing (exit 0). ONE PRE-EXISTING RED, not from this change: check:objectui-pin-fresh exits 1 — it reads only .objectui-sha, which this PR does not touch (git status confirms the diff is 4 files, none of them that pin); it entered the derived list solely because the diff touches .changeset/, and its own output cites #3340 as its tracking issue.", "consumer_signature_for_cloud_followup": "Unchanged and still valid as-is: `registerMultiNodeGate({ allowMultiNode: () => resolveMultiNodeEntitlement(process.env.OS_LICENSE_KEY) })` at cloud apps/objectos-ee/objectstack.config.ts:92-94 keeps working and reads as 'no cap'. To adopt the cap, only the registration changes (maxNodes is already on the entitlement at cloud packages/security-enterprise/src/license/entitlement.ts:173,258): `registerMultiNodeGate({ allowMultiNode: (requested) => { const ent = resolveMultiNodeEntitlement(process.env.OS_LICENSE_KEY); if (!ent.allowed) return ent; const cap = getActiveEntitlement()?.maxNodes; return cap === undefined ? ent : { ...ent, admitted: Math.min(requested ?? cap, cap) }; } })`. Framework-side types exported for it: MultiNodeGate, MultiNodeVerdict { allowed: boolean; reason?: string; admitted?: number }, ResolvedMultiNodeVerdict { allowed: boolean; reason?: string; admitted?: number; refused: number; capped: boolean }. NOTE for sequencing: the framework's own CLI consumer (packages/cli/src/commands/serve.ts:1234-1247) still calls checkMultiNodeAllowed() zero-arg and re-declares the return type locally inside a dynamic-import cast, so it neither sees nor is broken by the widening — teaching it to pass a count and emit the ruling's loud warning was left out of scope per the dispatch's file surface, and is the other half of the follow-up alongside cloud.", "open_questions": [], "out_of_scope_findings": [ "filed as #8501: a licensed max_nodes cap still cannot be enforced by any replica — no cluster membership view and no slot claim, so a count-carrying gate verdict stays advisory; needs an atomic slot claim against ILock/ICounter/IKV (labelled `finding`, unassigned, no pm:queue, Blocked-by: #8367)", "not filed (self-tracking): check:objectui-pin-fresh is red on main because .objectui-sha is stale; the gate's own output names #3340 as its tracking issue, so a new card would be noise. Reported here so it is not mistaken for this PR's red." ] }Generated by Claude Code
os-zhuang commentedon Aug 13, 2026
⛔ CLOSED — but the 2026-08-13 ruling is NOT enforced. Read this before treating it as implemented.
Post-merge inventory,
domain:servicesseat #6021, sessionsession_01ARidKDYSCD56LaygrvDPnk. PR #8503 MERGED 2026-08-13T16:49:09Z via the merge queue (25 checkssuccess; queue entry confirmed by theenqueuedevent, ⛔ not by the auto-merge receipt, whose fields have come back empty every time this shift). Auto-close verified againstclosed_by_pull_requests→ #8503.pm:dispatcheddropped; nothing is in flight.max_nodescap is enforced. It is not. A cap of 3 with 5 replicas still starts 5.I am stating that at the top because this card is exactly the sort that gets read as a checkmark against a maintainer ruling. It is not one.
What actually changed
The seam can now express the ruled verdict:
allowMultiNode(requested?: number)returns a verdict that may carryadmitted, andcheckMultiNodeAllowedtotalizes it into{ allowed, reason, admitted?, refused, capped }.cappedmarks only a partial refusal, so the licensed-overflow case cannot be conflated with the unlicensed one. Backward compatible: the existing EE zero-arg provider keeps working and reads as "no cap".⭐ Why this card's own premise was wrong, recorded so it is not re-derived
This card states the widening is what makes the refusal binding, and triage's routing repeated it ("the seam-widening that makes it enforceable"). Measurement during implementation falsified that. It is necessary but not sufficient, and ⛔ no consumer-side change can close the gap:
packages/cli/src/commands/serve.ts:1234-1247).nodeIdis generated randomly per process (cluster.ts:123); nothing registers a node, tracks liveness, or counts live members.OS_CLUSTER_REPLICASis an operator-declared desired count, identical in every replica.With a cap of 3 and 5 replicas, all five compute the same verdict and none can determine whether it is one of the admitted 3. The only locally reachable outcomes are "all join" (cap advisory — the status quo) or "all refuse" (the whole-cluster degrade the ruling explicitly rejects). "Run N, refuse N+1" is not producible from a per-replica decision over a count alone.
⭐ The PR therefore documents the limitation in the module doc and changeset rather than implying enforcement. That was the right call and it is the reason this closure is safe to make.
The ruling has three clauses. This card delivered none of them — it delivered the vocabulary.
objectstack-ai/cloud#1275)ILock/ICounter/IKVprimitives this package already shipsos servecalls the gate zero-arg, so the partial-cap state is unreachable from the only call site, not merely unread⭐ #8504 is deliverable now and does not wait on #8501 — but its wording must be advisory while #8501 is open, because a warning that says "2 replicas refused" would be false today.
For the cloud follow-up
⛔ Not required, and safe to defer: the current zero-arg registration keeps working and reads as uncapped. To adopt the cap, only the registration changes (⚠️ Adopting it alone still does not enforce anything; it only makes the provider supply the number that #8501 would enforce.
maxNodesis already on the entitlement atpackages/security-enterprise/src/license/entitlement.ts:173,258) — the exact shape is in PR #8503's body.Anyone auditing "is the 2026-08-13 ruling implemented?" should read #8501 and #8504, not this card.
Generated by Claude Code
feat(service-cluster): carry an admitted node count through the multi…