Skip to content

feat(service-cluster): carry an admitted node count through the multi-node gate (#8367) - #8503

Merged
os-zhuang merged 1 commit into
mainfrom
claude/issue-8367-multinode-gate-count
Aug 13, 2026
Merged

feat(service-cluster): carry an admitted node count through the multi-node gate (#8367)#8503
os-zhuang merged 1 commit into
mainfrom
claude/issue-8367-multinode-gate-count

Conversation

@os-zhuang

Copy link
Copy Markdown
Contributor

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:22 on origin/main @ ff1e9b6a9:

allowMultiNode(): { allowed: boolean; reason?: string};

The card measured this against 62b6a2fb (what cloud pins). Re-verified on today's main — unchanged, so the premise holds.

Consumer enumeration

Complete, both repos:

SiteConsumption
packages/cli/src/commands/serve.ts:1234-1247the only runtime consumer. Calls checkMultiNodeAllowed() zero-arg, reads .allowed and .reason
packages/services/service-cluster/src/index.ts:70-77re-export
packages/services/service-cluster/src/multi-node-gate.test.tstests
cloud apps/objectos-ee/objectstack.config.ts:92-94the EE registration — a zero-arg arrow returning bare { allowed, reason } from resolveMultiNodeEntitlement

Two 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 result

interfaceMultiNodeVerdict{allowed: boolean;reason?: string;admitted?: number}interfaceMultiNodeGate{allowMultiNode(requested?: number): MultiNodeVerdict}functioncheckMultiNodeAllowed(requested?: number): {allowed: boolean;reason?: string;admitted?: number;refused: number;capped: boolean}
  • requested optional and admitted optional is what preserves the EE provider: a function of fewer parameters stays assignable, and a verdict with no admitted reads as "no cap" — it admits everything asked for rather than having a refusal invented for it.
  • refused / capped are totalized (always present) so no consumer writes ?? 0 over 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.
  • capped marks only a partial refusal and stays false for an outright allowed: 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 (nodeId is random per process, cluster.ts:123; nothing tracks live nodes) and no ordinal (OS_CLUSTER_REPLICAS is 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/IKV primitives 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 --quiet confirmed): 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 no refused/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 — refused would be 0 either way. Every count pin therefore requests strictly more than the cap, and the partial-cap pins assert allowed === true in the same expectation, because a gate denying the whole cluster would also yield refused > 0. Measured proof this 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 of the widening.

Type level, against the rebuilt .d.ts (dist regenerated 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 string admitted, a verdict missing allowed, a string count, and treating refused as optional are all refused, the errors naming MultiNodeVerdict.

Consumer sweep, downstream direction (--filter '...@objectstack/service-cluster' — the prefix form; 16 packages downstream): @objectstack/cli, @objectstack/runtime and @objectstack/service-cluster-redis typecheck clean after building the CLI's 55-package closure. Before that build they showed Cannot find module errors 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. resolveMultiNodeEntitlement keeps its current shape; only the registration changes, and maxNodes is already on the entitlement (packages/security-enterprise/src/license/entitlement.ts:173,258):

registerMultiNodeGate({allowMultiNode: (requested)=>{constent=resolveMultiNodeEntitlement(process.env.OS_LICENSE_KEY);if(!ent.allowed)returnent;constcap=getActiveEntitlement()?.maxNodes;returncap===undefined ? ent : { ...ent,admitted: Math.min(requested??cap,cap)};},});

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-registration agrees 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.mjs against 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-fresh exits 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

…-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
@vercel

vercelBot commented Aug 13, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

1 Skipped Deployment
ProjectDeploymentActionsUpdated (UTC)
objectstackIgnoredIgnoredAug 13, 2026 4:17pm

Request Review

@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 1 package(s): @objectstack/service-cluster.

4 hand-written doc(s) reference the affected code and may need an implementation-accuracy re-verification:

  • content/docs/deployment/environment-variables.mdx(via @objectstack/service-cluster)
  • content/docs/kernel/cluster.mdx(via @objectstack/service-cluster)
  • content/docs/kernel/services-checklist.mdx(via @objectstack/service-cluster)
  • content/docs/protocol/kernel/realtime-protocol.mdx(via @objectstack/service-cluster)

Advisory only. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs origin/main → pass the list as args.docs.

@github-actionsgithub-actionsBot added documentation Improvements or additions to documentation tests tooling labels Aug 13, 2026
@os-zhuangClaude

Copy link
Copy Markdown
ContributorAuthor

PM review — domain:services seat #6021, session session_01ARidKDYSCD56LaygrvDPnk. Verdict: ACCEPT, flip + arm pending CI (23 checks still running at time of writing; ⛔ nothing enqueued until every one concludes success).

⭐ The dispatch's named failure mode was hit, and reported instead of papered over

The dispatch order told this dev to watch for exactly one thing:

"a widened contract that looks like it implements the ruling while the excess replicas still start."

That is what was found. The gate is consulted once per process at boot; nodeId is random per process (cluster.ts:123), nothing tracks membership, and OS_CLUSTER_REPLICAS is an operator-declared count identical in every replica. So 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. Only "all join" (advisory) or "all refuse" (the degrade the ruling rejects) are reachable locally.

⚠️This falsifies the card's own load-bearing premise.#8367 states the widening is what makes the refusal binding, and triage's routing repeated it ("this card is the seam-widening that makes it enforceable"). It is not — it is necessary but not sufficient, and no consumer-side change can close the gap. Had this shipped quietly, the repo would carry a count-carrying gate, a satisfied-looking card, a closed ruling, and five replicas still starting under a cap of three.

⭐ 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 Fixes #8367 — deliberate, and I am ruling it correct

⚠️ I want this on the record because it is the #8131 shape and I got that one wrong. The argument for Part of is real: the card's stated goal is bindingness, and this PR does not deliver bindingness.

I am letting Fixes stand. The card's named deliverable — the count-blind seam — is fully delivered, and its motivation rested on a belief measurement has now falsified. Holding the card open would leave an anchor whose entire remaining scope is #8501's, i.e. a duplicate. ⛔ But the closure must be loud, not quiet: the post-merge comment on #8367 will state plainly that the 2026-08-13 ruling is not yet enforced and point at #8501, so nobody auditing "is the ruling implemented?" reads a closed card as yes.

Review points, all clearing

  • Backward compatibility — the hard constraint from the dispatch. Verified the right way: against the rebuilt.d.ts, not a cached read, with the EE provider's exact zero-arg arrow shape compiling clean. A fewer-parameter function staying assignable is correct TS, and the rejection direction was tested too. ⭐ The reasoning that an absent admitted reads as "no cap" rather than having a refusal invented for it is the right default — the opposite would have made every existing provider start refusing.
  • Per-node callback rejected on measurement, not taste: each replica's provider starts its own counter at zero in its own process, so the callback would enforce nothing while looking like enforcement. Rejecting the card's own suggested alternative for a stated reason is what Zone 3 is for.
  • Vacuity trap closed honestly — every count pin requests strictly more than the cap, and ⭐ 2 of the 11 new tests are reported as GREEN on main by construction and non-discriminating rather than counted as evidence. Self-reporting a weak pin is worth more than the pin.
  • The 16-package downstream sweep's false red (Cannot find module before the CLI's 55-package closure was built) is correctly identified as a fresh-worktree artifact, not a break. ⚠️ This is the third local-red-that-CI-disagrees-with this shift; naming it as an artifact rather than escalating it is the right call and the one I got wrong earlier today.
  • ADR-0087 reading is stated, not guessed, and lands where ADR-0087's changeset disposition has no category for published runtime TS interfaces with no metadata surface — the #8277 exemption argument is correct, unverifiable, and will be re-litigated #8299 says the gap is. Correct.

Docs drift advisory — measured, no action needed

The bot flagged 4 docs. I checked rather than forwarding it: no doc anywhere under content/docs mentions max_nodes/maxNodes at all (zero hits), and content/docs/kernel/cluster.mdx's only claim is that "when it denies, os serve logs a warning and downgrades" — which stays exactly true, since an outright allowed: false is unchanged and capped is deliberately kept false for it.

⭐ So there is nothing misleading to correct here, and ⛔ no doc edit belongs in this PR. Note for whoever lands #8501: cluster.mdx is where the enforcement story will need to appear, because that doc currently describes only the deny/downgrade path and an operator has no other place to learn whether a node cap binds.


Generated by Claude Code

Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentationImprovements or additions to documentationsize/mteststooling

Projects

None yet

2 participants

@os-zhuang@claude