Uh oh!
There was an error while loading. Please reload this page.
feat(service-cluster): carry an admitted node count through the multi-node gate (#8367) - #8503
Conversation
…-node gate (#8367) `registerMultiNodeGate` consumed `{ allowMultiNode(): { allowed, reason } }` — a bare boolean verdict with no node count in the contract — so the only refusal a license could express was denying the whole cluster. The maintainer ruled on 2026-08-13 (recorded on objectstack-ai/cloud#1275) that a licensed `max_nodes` overflow must refuse the excess replicas, run up to the paid limit and warn loudly, explicitly NOT whole-cluster degrade. That verdict was previously inexpressible. A gate verdict may now carry `admitted`, and `checkMultiNodeAllowed(requested?)` forwards the caller's intended count and returns a normalized verdict carrying totalized `refused` / `capped`. `capped` marks only a PARTIAL refusal, so the licensed-overflow case cannot be conflated with the unlicensed one. Backward compatible: `requested` is an optional parameter and `admitted` an optional return field, so the existing zero-arg boolean-shaped provider that @objectstack/security-enterprise registers stays valid and reads as "no cap". The counts are ADVISORY at this seam and the code says so: the gate is consulted once per process at boot, where a replica has no membership view and no ordinal, so no replica can tell whether it is one of the admitted N. Binding enforcement needs an atomic slot claim, tracked separately. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01ARidKDYSCD56LaygrvDPnk
The latest updates on your projects. Learn more about Vercel for GitHub. 1 Skipped Deployment
|
📓 Docs Drift CheckThis PR changes 1 package(s): 4 hand-written doc(s) reference the affected code and may need an implementation-accuracy re-verification:
|
os-zhuang
commented
Aug 13, 2026
PM review — ⭐ The dispatch's named failure mode was hit, and reported instead of papered overThe dispatch order told this dev to watch for exactly one thing:
That is what was found. The gate is consulted once per process at boot; ⭐ Equally right: the PR documents the limitation in the module doc and changeset instead of implying enforcement, and files the real mechanism as #8501 rather than smuggling a new runtime subsystem (slot claims, TTL, refusal posture) into a signature-change card. That is the correct scope discipline. On |
Uh oh!
There was an error while loading. Please reload this page.
Fixes#8367
The multi-node gate could not express the verdict the maintainer ruled for. This widens it so a license can admit N nodes and refuse the excess — and is explicit, in code and in the changeset, that the resulting counts are advisory at this seam rather than enforcement.
Measured signature before the change
packages/services/service-cluster/src/multi-node-gate.ts:22onorigin/main@ff1e9b6a9:The card measured this against
62b6a2fb(what cloud pins). Re-verified on today'smain— unchanged, so the premise holds.Consumer enumeration
Complete, both repos:
packages/cli/src/commands/serve.ts:1234-1247checkMultiNodeAllowed()zero-arg, reads.allowedand.reasonpackages/services/service-cluster/src/index.ts:70-77packages/services/service-cluster/src/multi-node-gate.test.tsapps/objectos-ee/objectstack.config.ts:92-94{ allowed, reason }fromresolveMultiNodeEntitlementTwo properties of the CLI site shaped the design: it passes no count, and it re-declares the function's type locally inside a dynamic-import cast rather than importing it — so a widening does not reach it implicitly and cannot break it implicitly either.
Shape chosen: optional parameter, optional
admitted, totalized resultrequestedoptional andadmittedoptional is what preserves the EE provider: a function of fewer parameters stays assignable, and a verdict with noadmittedreads as "no cap" — it admits everything asked for rather than having a refusal invented for it.refused/cappedare totalized (always present) so no consumer writes?? 0over a third-party gate's output. Normalization of non-finite, fractional and negative counts happens at the seam — contract-first, per AGENTS.md PD Add comprehensive test suite for Zod schema validation #12.cappedmarks only a partial refusal and staysfalsefor an outrightallowed: false, so the licensed-overflow case and the unlicensed case cannot be conflated.The per-node admission callback was measured and rejected. A gate is a module singleton inside one replica's process, with no cross-process state. Each replica's provider would start its own counter at zero and admit itself, so the callback would look like per-replica enforcement while enforcing nothing. An honestly advisory verdict beats a silently vacuous callback.
The "advisory at the seam" claim held — and it is the bigger finding
Verified, and it is stronger than the card stated: a count-carrying verdict is necessary but not sufficient, and no consumer-side change can close the gap. The gate is consulted once per process at boot, where a replica has no membership view (
nodeIdis random per process,cluster.ts:123; nothing tracks live nodes) and no ordinal (OS_CLUSTER_REPLICASis a declared count, identical in every replica). With a cap of 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/IKVprimitives this package already ships. That is a new runtime mechanism with its own design decisions, filed separately as #8501 (out of scope here, not addressed by this PR). This PR therefore documents the limitation in the module doc and the changeset instead of implying enforcement.Reverse verification
Predicted before running, then measured.
Runtime — tests added with the implementation byte-identical to
origin/main(git diff --quietconfirmed): predicted RED, read 9 failed / 47 passed. The diagnostics name the mechanism:expected [ undefined ] to deeply equal [ 5 ]— the count is dropped entirely, never reaching the gate.expected { allowed: true, admitted: 5 } to deeply equal { allowed: true, admitted: 3, ...}— the provider's raw object passes through unclamped, with norefused/capped.expected { allowed: true, admitted: -2 } to match object { admitted: 0, refused: 3 }— degenerate counts pass through unnormalized.After the implementation: 56 passed (56), up from a 45-test baseline.
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 —refusedwould be 0 either way. Every count pin therefore requests strictly more than the cap, and the partial-cap pins assertallowed === truein the same expectation, because a gate denying the whole cluster would also yieldrefused > 0. Measured proof this matters: 2 of the 11 new tests (the uncounted compat pins) were GREEN onmainby construction, and are reported as non-discriminating rather than counted as evidence of the widening.Type level, against the rebuilt
.d.ts(distregenerated first, so this is not a cached read): the EE provider's exact zero-arg shape, the count-aware follow-up shape, and today's zero-arg call site all compile clean. The rejection direction fires as predicted — a stringadmitted, a verdict missingallowed, a string count, and treatingrefusedas optional are all refused, the errors namingMultiNodeVerdict.Consumer sweep, downstream direction (
--filter '...@objectstack/service-cluster'— the prefix form; 16 packages downstream):@objectstack/cli,@objectstack/runtimeand@objectstack/service-cluster-redistypecheck clean after building the CLI's 55-package closure. Before that build they showedCannot find moduleerrors that read exactly like broken imports — a false red from the fresh worktree, not from this change.The consumer signature this leaves for the cloud follow-up
Mechanical, as promised.
resolveMultiNodeEntitlementkeeps its current shape; only the registration changes, andmaxNodesis already on the entitlement (packages/security-enterprise/src/license/entitlement.ts:173,258):Not doing so is safe: the current zero-arg registration keeps working unchanged and reads as uncapped.
Changeset and ADR-0087
.changeset/multinode-gate-admitted-count.md,minor— additive public API on@objectstack/service-cluster.ADR-0087 reading, stated rather than guessed: it governs metadata protocol surface (spec properties,
engines.protocol, conversion/deprecation registries) and contains no changeset disposition at all. This change widens a TypeScript interface in a service package with no metadata or authorable surface — no spec property, no Zod schema, no protocol version — so no ADR-0087 registration applies.check-adr-0087-registrationagrees on the real diff. The category gap itself is tracked in #8299 and is untouched here.Gates
Path-derived via
node scripts/pm/dispatch-gates.mjsagainst the actual changed paths, plus the convention-scoped ones: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,check:query-options-erasure,check:type-check-coverage,check:nul-bytes,check:error-code-casing— all exit 0.One pre-existing red, not from this change:
check:objectui-pin-freshexits 1. It reads only.objectui-sha, which this PR does not touch; it entered the derived list solely because the diff touches.changeset/, and its own output cites #3340 as its tracking issue.Generated by Claude Code