Skip to content

Surface a licensed max_nodes oversell to operators as telemetry (visibility only) - #12720

Merged
os-litant merged 2 commits into
mainfrom
claude/issue-12667-max-nodes-oversell-visibility
Aug 27, 2026
Merged

Surface a licensed max_nodes oversell to operators as telemetry (visibility only)#12720
os-litant merged 2 commits into
mainfrom
claude/issue-12667-max-nodes-oversell-visibility

Conversation

@os-litant

Copy link
Copy Markdown
Collaborator

Fixes#12667

Maintainer ruling 2026-08-27, quoted unchanged: 「其他接受」 — adopting option C on #8501. A licensed max_nodes oversell becomes visible to operators; the atomic slot-claim enforcement mechanism is deliberately not built here.

Where the reading can land — measured first, before anything was written

The card asked this to be settled before designing, and it is the decision the diff follows from.

  • A console surface would have been cross-repo, so it was not taken.packages/console in this repo is a prebuilt SPA and nothing else: its exports map contains only ./package.json, its files list is dist, and its README states the source of truth is @object-ui/console in objectstack-ai/objectui, frozen at the SHA in .objectui-sha. There is no console source here to add a panel to. Per the card's own instruction that half is reported rather than absorbed.
  • Telemetry lands entirely inside this repo, and is what this PR delivers. @objectstack/observability owns the metric-name conventions (SEMCONV) and the MetricsRegistry contract; os serve already builds and registers a backend from OS_OBS_*. The reading needed no new plumbing, and no new public route or client method — clause ② stays no.

Premises, re-measured on today's tree

All three still hold after the seam moved at #8503, and together they are why this is visibility rather than enforcement:

  1. the gate is consulted once per process at boot — one checkMultiNodeAllowed(...) call in packages/cli/src/commands/serve.ts, reached only when OS_CLUSTER_DRIVER names a remote driver;
  2. there is no cluster membership viewgenerateNodeId() in packages/services/service-cluster/src/cluster.ts is Math.random-seeded per process, and no join/leave registry exists;
  3. OS_CLUSTER_REPLICAS is an operator-declared count, identical in every replicadeclaresMultiNode() in split-brain-guard.ts reads it as a declaration, never as an observation.

So nothing in the process knows how many peers exist, and the surface must not read as though it does.

What this adds

Three names join SEMCONV in @objectstack/observability, emitted once per boot by os serve, each labelled with the verdict vocabulary #8367 / PR #8503 landed (admitted / capped / refused) rather than a second vocabulary:

metrickindreading
cluster_declared_nodesgaugethe replica count the operator declared
cluster_admitted_nodesgaugehow many of them the licence admits
cluster_node_cap_verdicts_totalcounterone increment per process boot that consulted the gate

describeMultiNodeCapTelemetry() sits beside the existing formatMultiNodeCapAdvisory() in serve.ts — the same consumer, extending the same advisory's reach rather than duplicating the #8504 boot warning, which is untouched and still fires. Deliberately kept a pure function so the tests can assert what an operator receives.

Three omissions are load-bearing, because each alternative would be a false number rather than a missing one: no declared gauge when nothing was declared (0 would read as a declaration of zero replicas); no admitted gauge when the gate expressed no cap (any number invents a limit nobody stated); nothing at all when the gate was never consulted.

The counter exists because the exporter is push-based with no gauge re-observation, so a one-shot gauge ages out of the backend — increase(cluster_node_cap_verdicts_total{verdict="capped"}[1h]) > 0 stays alertable after it has.

What this explicitly does NOT add

No enforcement, no membership view, no slot claim, no refusal behaviour. The cap stays advisory: every declared replica still joins, exactly as before. Nothing published gains a new contract, and no accept/reject behaviour changed anywhere.

The honest-naming assertion the card asked for is pinned in serve-multi-node-cap-telemetry.test.ts: every emitted metric name, label name and label value is swept against a membership-claim vocabulary (running, active, live, members, peers, observed, …), with a vacuity proof that the sweep rejects cluster_active_nodes and accepts cluster_declared_nodes. That vacuity proof earned its place immediately — the sweep was first written with \b anchors, which do not fire inside cluster_active_nodes because _ is a word character, so the rename this guard exists to reject would have sailed through green. The anchors are now explicit and the measurement is recorded in the file.

Verification

Gate union derived from the actual changed set with node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack, re-run on the final commit b7d45784c: 24 green. One was not measured — scripts/pm/check-half-states.mjs exits 3 with PREREQUISITE NOT MET because this container holds no valid GitHub credential; that is no reading either way, not a failure. pnpm check:type-check-debt --re-measure re-measured 31 ledger entries against a fully built workspace: none above its recorded number.

check:slot-lookup caught a real defect in the first draft of this change — the metrics lookup was assigned to an any-declared variable, growing serve.ts's erasure count 9 to 10. Typing it MetricsRegistry fixed the ratchet and also made the emission calls type-checked, which matters here: counter(name, labels, value) and gauge(name, value, labels) take their arguments in different orders, and under any a swap would have compiled and emitted garbage.

Suites: @objectstack/cli (the four multi-node / observability files) 41 passed; @objectstack/observability 85 passed; @objectstack/service-cluster 66 passed; pnpm --filter @objectstack/cli typecheck clean — and confirmed with tsc --noEmit --listFiles that both new test files are genuinely inside that program rather than excluded from it.

Ablation, two legs, each proved on disk before its colour was read:

  • the reading stops distinguishing verdicts — the verdict word pinned to 'admitted'. Removed-text count 0, injected-marker count 1, git diff non-empty. Result: 3 of 17 red, on exactly the verdict rows. This leg involves no dist: the suite imports ./serve.js by relative path inside the same package, so no build state can hide it.
  • the honest name becomes a membership claimcluster_declared_nodes renamed to cluster_active_nodes in SEMCONV. Because serve.ts consumes that through @objectstack/observability's exports (dist, not src), the package was rebuilt and scripts/ablation-dist-preflight.mjs confirmed the marker present in 4 built files before the run. Result: 8 of 17 red, including the membership sweep itself.

Both restore legs used git checkout HEAD -- absolute-path under an EXIT/INT/TERM trap, and are proved by state rather than exit code: empty git diff HEAD, empty git status --porcelain, and blob hashes identical to their HEAD blobs. The dist leg was rebuilt on restore too, and its --absent pre-flight passes for a marker unique to the mutation. Worth recording: the first --absent check reported a hit and it was not a failed restore — cluster_active_nodes legitimately appears in the new SEMCONV doc comment naming it as a forbidden spelling, and tsup preserves comments into dist. The mutated value was absent from source and dist alike. A marker that also occurs in the honest source cannot answer this question; that is a lesson about marker choice, not about the restore.

Changeset: .changeset/lucky-donkeys-vanish.md, minor on @objectstack/observability and @objectstack/cli — new metric names in a published SEMCONV and a new emission from a shipped command are both user-visible.

Refs: #8501 (the ruling and its four-facet record), #8367 and PR #8503 (the verdict seam), #8504 (the boot warning this extends, untouched), objectstack-ai/cloud#1275 (the 2026-08-13 overflow-semantics ruling, unchanged). None of those is addressed here.


Generated by Claude Code

…perator telemetry
Extends the reach of the boot-time cap advisory: the same advisory verdict is
now also emitted through the deployment's configured metrics backend, so an
operator can ask the question and alert on it long after the boot log scrolled
away.
Visibility only. The gate stays advisory, nothing is refused, and the surface is
named around what the operator DECLARED and what the licence ADMITS — this
process has no cluster membership view at all.
@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 3 package(s): @objectstack/cli, @objectstack/observability, @objectstack/service-cluster, touching 13 documentable anchor(s). ⚠️1 changed file(s) yielded no anchor (packages/services/service-cluster/src/multi-node-gate.ts), so the pages documenting them are NOT COVERED by this run — this is not a clean bill of health for those files.

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 4af6c4419d7669a56940ec8e7adbb133829fcbde.

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

What this run could not see
  • 1 changed file(s) yielded no anchor (packages/services/service-cluster/src/multi-node-gate.ts) — pages documenting those are invisible to this run
  • 2 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 4af6c4419d7669a56940ec8e7adbb133829fcbdepackageMentionDocs.

Which tree this was computed on

This run read content/docs from a04656bf99a737bd880d73a7971de5b90583832d — the merge of head b7d45784cebf6fb25d7ff66c4921b999555e9474 into base 4af6c4419d7669a56940ec8e7adbb133829fcbde, 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 a04656bf99a737bd880d73a7971de5b90583832d && git checkout a04656bf99a737bd880d73a7971de5b90583832d
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 4af6c4419d7669a56940ec8e7adbb133829fcbde b7d45784cebf6fb25d7ff66c4921b999555e9474 && git checkout -B drift-repro 4af6c4419d7669a56940ec8e7adbb133829fcbde && git merge --no-ff b7d45784cebf6fb25d7ff66c4921b999555e9474
node scripts/docs-audit/affected-docs.mjs --json 4af6c4419d7669a56940ec8e7adbb133829fcbde

⚠️ 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 4af6c4419d7669a56940ec8e7adbb133829fcbde → 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 Aug 27, 2026
@os-litant
os-litant marked this pull request as ready for review August 27, 2026 14:22
@os-litant
os-litant enabled auto-merge August 27, 2026 14:22
@os-litant
os-litant added this pull request to the merge queueAug 27, 2026
Merged via the queue into main with commit 37e82ebAug 27, 2026
34 checks passed
@os-litant
os-litant deleted the claude/issue-12667-max-nodes-oversell-visibility branch August 27, 2026 15:06
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

Development

Successfully merging this pull request may close these issues.

Surface licensed max_nodes oversell to operators — visibility without the slot-claim mechanism (ruled C on #8501)

2 participants

@os-litant@claude