v3.8.0 release notes
The carried-forward list had five live items. Re-measuring them found that two were never actually blocked: one on hardware this repository was already using, and one on a document nobody had written. Neither needed new capability, budget or hardware.
That is the second release running in which re-measuring the list changed what it says. v3.7.0 found two items already done and still being carried; this one finds two whose stated obstacle had evaporated.
arm64, measured on real hardware at last
Section titled “arm64, measured on real hardware at last”The list has said the same thing since v3.3.0, and v3.7.0 repeated it unchanged:
arm64 performance is still unmeasured. The timings came from QEMU on an amd64 host and measure the emulator; real numbers need arm64 hardware.
mcp-docker-build.yml, line 47:
build-arm64: runs-on: ubuntu-24.04-armGitHub-hosted arm64 runners, free for public repositories, already in use in this repository and succeeding on every recent run. There is even a careful comment three lines above that label explaining its runner-label security property — so the hardware was not merely present, it had been reasoned about. The note and the workflow coexisted for five releases without anyone connecting them.
performance-benchmarks.yml now has a benchmark-arm64 job on that runner. It records the machine
it measured — arch, kernel, CPU count, model, memory, Node version — because a benchmark whose
machine is unrecorded is a number nobody can reuse, a lesson this project learned from a baseline
that claimed the wrong machine. It also asserts uname -m is aarch64 and fails otherwise.
Two corrections to that guard came out of review, and the first is the sort of mistake worth being
loud about. The assertion was the fifth step, after npm ci and npx grunt configTests — both
of which execute code from the pull request. A guard that fires after the thing it guards against
has already run is decoration. It is now the first step, ahead of even the checkout.
The second is what it proves. It establishes the job ran on aarch64. It does not establish the
runner is GitHub-hosted — a self-hosted arm64 runner would pass, and Actions has no syntax for
“hosted only”. The actual controls are that this repository’s single self-hosted runner is x64
(labels self-hosted, Linux, X64, agy, verified against the API) and that pull_request, unlike
pull_request_target, hands fork code no secrets and a read-only token. The assertion is the cheap
tripwire for the day the first of those changes and nobody updates this workflow.
It reports and does not gate, which review showed needs pinning down: read as “cannot fail the
workflow” the phrase is false. What it means is that the job applies no performance threshold —
no comparison against benchmarks/baseline.json, none against the amd64 job, and no number it
produces can fail the build. There is no arm64 noise floor yet, and this project has set three
thresholds from a first plausible measurement and moved two within days; the numbers accumulate
first.
It can still fail — on the architecture assertion or on npm ci — and continue-on-error was
suggested and declined. It would silence a tripwire whose whole purpose is to be loud, and hide
an install failure that means the numbers are absent rather than fine. Silencing a check to make a
sentence true is the wrong direction, so the sentence was made precise instead. It also does not compare arm64 against amd64 — those are different machines, and not doing
that is what the whole v3.4.0–v3.6.0 line of work was about.
It ran before this release merged, which took a second fix to arrange — see below — and the first
numbers are recorded in
docs/internal/measurements/v3.8.0-arm64-first-run.md:
4 CPUs, 15.6 GB, kernel 6.17.0-1022-azure, Node v26.8.1, which is the version the published
image ships rather than the declared floor. Entropy (1KB) at 39,914 ops/s down to Gzip (100KB)
at 2, total wall time 30.91s.
That first run also found a defect in the job itself: model: came back empty, because arm64
/proc/cpuinfo has no model name field — an x86 convention — and the step’s || echo unknown
fallback could not fire, since grep matching nothing still leaves xargs exiting 0. An empty
field is worse than “unknown”: it reads as a machine with no model rather than as a question the
step failed to answer. It now falls back to the implementer/part pair.
cert_chain
Section titled “cert_chain”Parse X.509 certificate, Parse X.509 CRL and Public Key from Certificate each handle one
certificate. Nothing relates two, and the questions people have about a bundle are all relational:
is it in the right order, is an intermediate missing, does each certificate actually sign the next,
which link expires first.
It is both registry-tool categories at once — a loop with a decision inside it (ordering an unordered bundle) and a statistic computed across inputs (a chain’s validity window is the intersection of its members’, which is the number that matters and the one no per-certificate operation can produce).
No new dependency: node:crypto’s X509Certificate parses and verifies.
The design changed because a measurement contradicted it
Section titled “The design changed because a measurement contradicted it”The first draft claimed the interesting case was “the issuer name matches but the signature does not” and reported it as a link-level warning. That claim was removed on the strength of a measurement — a real imposter minted with the identical subject to the real intermediate and a different key:
real.subject === imposter.subject trueleaf.checkIssued(real) trueleaf.checkIssued(imposter) FALSEAnd then a reviewer disputed the conclusion, and was right. The measurement is real; the
inference from it was not. checkIssued compares issuer/subject names, authority and subject key
identifiers, and key usage — it never checks the signature. That imposter was rejected because its
subjectKeyIdentifier did not match the leaf’s authorityKeyIdentifier. A metadata mismatch,
mistaken for cryptography.
The disproof is a certificate carrying the real intermediate’s subject and a
subjectKeyIdentifier equal to the leaf’s authorityKeyIdentifier, over a different key:
leaf.checkIssued(skidImposter) trueleaf.verify(skidImposter.publicKey) FALSESo the branch is reachable, it is exactly the substitution signature the tool exists to surface, and the withdrawn warning is restored — now saying what it means rather than “that should not happen”.
That mistake had a second, worse consequence. Edge selection took the first checkIssued match
and stopped, so a certificate shaped like that captured the link and the real issuer sitting in the
same bundle was never considered: a good chain with one hostile certificate in it was reported as
broken. Selection now evaluates every candidate and a verifying signature always wins, keeping a
metadata-only match only as a fallback so a substitution is reported rather than silently dropped.
The adversary is committed as tests/mcp/fixtures/cert-skid-imposter.pem and the case is pinned by
a test that fails against the old logic.
Two further checks came with it: an issuer must carry basicConstraints CA:TRUE to be permitted to
sign, and a self-signed top must actually verify against its own key.
The general lesson is not about X.509. A single negative result was taken as proof of a mechanism when it only ruled out one way of failing — and the wrong conclusion was then written into the implementation, the header comment, the model-visible description, and four documents.
Then the replacement detection was wrong too, and a real bundle showed it. It asked whether an
orphan shared a subject with a certificate in the chain — which finds nothing in the case that
matters, because when an imposter replaces the real intermediate the real one is absent, so its
subject is not in the chain either. It reported false for the very certificate it was written to
catch.
The right question is about the name the chain is looking for: every certificate names its issuer, the top of the chain names one that is not present, and that is the slot an imposter occupies. A certificate bearing exactly that name which signed nothing is a certificate claiming to be your issuer on a key that signed nothing — and it is now named as that rather than counted as “1 certificate not part of the chain”.
A missing root is reported as normal, not as a defect: a server’s fullchain.pem legitimately
omits it. Reporting that as a failure trains a reader to ignore the tool.
That last sentence was the intent from the start and the code did not honour it until review said
so — the note went into problems, which self_consistent counted. See
What review caught below; it is the third design change this tool took in one
release, each of them from a measurement or a reader rather than from review in the abstract.
Tested against a real openssl-generated PKI committed as fixtures — root, intermediate, leaf and
imposter, certificates only, no private keys. Every test that touches validity passes as_of
explicitly, because a test whose result depends on the day it runs will fail on some future morning
for no reason.
Task-level scoring: the blocker was a document
Section titled “Task-level scoring: the blocker was a document”The charter required the design to survive “must not be a flaky gate” in writing before any code. That document did not exist, which made it the actual blocker — and writing it was always within reach.
It is written, and it concludes against building the harness. See
docs/planning/v3/task-level-scoring.md for the reasoning; the
short version is that a gate whose pass/fail depends on a model’s output cannot be made
non-flaky without a tolerance so wide it stops discriminating, and every cheaper design that is
stable turns out to measure something other than what the charter wanted.
Recording a decision not to build is the point of the exercise. The item leaves the carried-forward list either way.
Two numbers that were quietly wrong
Section titled “Two numbers that were quietly wrong”Neither was found by looking for it. Both turned up in the output of gates run for other reasons, which is the argument for reading a gate’s output rather than its exit code.
describeSurface told operators the all surface costs “~86k tokens per tools/list”. Wrong in
both dimensions. No tokenizer has ever been in this repository, so the figure was bytes/4 wearing a
token label — the exact convention this project spent v3.2.0 documenting as a mistake — and the
payload has since grown to 424,810 bytes, about 106k under that same convention, so even the
disguised number had drifted by a fifth. It is now ~415 KB, with the history in a comment beside
it so the next person does not re-derive it.
The coverage row in AGENTS.md had lines and statements transposed — 95.7% lines / 96.6%
statements against an actual 96.6% lines / 95.5% statements. vitest.config.mjs carries a whole
paragraph warning about this exact confusion, added because “a reviewer read them as swapped
once”, and the row directly downstream of that warning had them swapped for six releases. The
figures are now quoted in the order vitest’s own summary prints them, and the threshold tuple beside
them is labelled with its different order rather than left to be inferred.
The benchmark workflow did not run on changes to the benchmark workflow
Section titled “The benchmark workflow did not run on changes to the benchmark workflow”The arm64 job above was written, committed, and opened as a pull request that had no benchmark check on it at all.
performance-benchmarks.yml filters its triggers by path, and every path it lists names code the
benchmarks measure — mcp-server.mjs, the generated bridge, src/core/**, benchmarks/**. None
named the workflow itself. An edit to it did not run it, so the one class of change whose
correctness this workflow is the only judge of was the class it never executed on.
Stated plainly: a new CI job would have merged having never run once, while these notes described its behaviour in the present tense. It was not caught by review — it was caught by looking for a benchmark result and finding none. That failure mode is silence, and a missing check reads exactly like a passing one unless somebody counts.
The workflow’s own path is now in both filters. src/node/lib/** and src/node/tools/** stay out
on purpose: a run costs two dependency installs across two architectures, and v3.4.0 established
this runner pool is heterogeneous enough that extra runs on unrelated changes buy noise. The rule
this establishes is the narrow one that was actually broken — a gate must trigger on changes to
itself.
What review caught
Section titled “What review caught”Four threads on the pull request, all four correct.
cert_chain contradicted itself on the commonest valid input in the world. The missing-root
message went into problems, and self_consistent was problems.length === 0 — so an ordinary
server fullchain.pem came back self_consistent: false beside an assessment saying that a missing
root “is not a defect”. A reader who believes the flag distrusts a good bundle; a reader who believes
the prose learns to ignore the flag.
The tests encoded the defect rather than catching it: the case named “says a missing root is
expected rather than calling it a defect” asserted the text appeared in problems and never
checked self_consistent at all. Notes and problems are now separate fields, self_consistent
counts defects only, and both directions are pinned.
The surface figures were half-updated. Adding one registry tool moved every surface by one tool
and a few thousand bytes; the byte figures were re-measured everywhere and the tool counts were not.
README.md said “545 tools” and “all 544” three sentences apart. Most tellingly, the round-trip
sentence claimed the index plus one schema was 42,415 bytes when the index alone had just been
documented at 44,406 — a claim arithmetic alone refutes, needing no measurement at all.
check:versions caught none of it, because it discovers and checks operation counts across 37
locations and has never covered surface counts. A partial update reported itself as complete —
v3.7.0’s lesson arriving for a different claim.
tests/mcp/tool-surface-figures.test.mjs closes the cheap half: the live documents must agree with
the canonical table, and the round-trip figure must exceed the index it contains. Verified by
reintroducing both real defects and watching them fail. It deliberately does not claim the
numbers are right — three documents can be consistently stale and it will pass. Only
npm run measure:surfaces establishes that, and this keeps them from diverging in between.
A second pass found three more, and the first is the sharpest thing review caught all release.
The tool description still promised “an issuer whose name matches but whose signature does
not” — the branch this release had already established as unreachable and removed from the
implementation and the header comment. A stale comment misleads whoever opens the file; a stale tool
description misleads the caller, because it is what the model reads when choosing a tool. A
model needing exactly that detection would select cert_chain and get silence — output that is not
wrong, but does not contain what was advertised.
Three of this release’s cert_chain corrections turn out to be one correction arriving in three
places — implementation, header comment, model-visible description — each fixed separately because
nothing tied them together. That is the same shape as v3.7.0’s operation-count sweep and this
release’s own surface figures: a claim removed from code has to be chased everywhere it was
repeated.
Also: the missing-root note asserted “the root is not in this bundle” when the condition only establishes that the issuer above the top certificate is absent — for a bare leaf, that is the intermediate. It now names both possibilities and says it cannot distinguish them from outside. And a test comment contradicted its own assertion, describing the second half of a test while sitting above the first.
Measured again, and still declined
Section titled “Measured again, and still declined”chainguard/node:latest-slim v25.9.0 against the shipped v26.8.1node26-slim / 26-slim do not existThe shell-free base image’s stated trigger has still not fired. Opt-in feature adoption remains permanently deferred: it needs telemetry from other people’s deployments, which this project deliberately does not collect.
Ecosystem re-checked: SDK 2.0.0, conformance 0.2.0-alpha.11, server.json schema 2025-12-11
current, no MCP specification revision after 2026-07-28. v4.0.0 stays unscheduled.
Verification
Section titled “Verification”npm run lint zero errorsnpm run check:versions every location agreesnpm run check:server-json validates against 2025-12-11mcp-publisher validate server.json is validnpm test 241/241 Node API, 2289/2289 operationsnpm run test:mcp 1691/1691 across 72 filesnpm run test:coverage exit 0npm run conformance both eras match the baselineSurfaces re-measured: 42 tools / 44,406 bytes for the default index, 120 / 107,652 curated, 545 / 424,810 for all.
Upgrading
Section titled “Upgrading”Nothing breaks. One new analysis tool, one new CI job that reports rather than gates, a design document that declines to build something, two new gates that were each proven able to fail, and four corrected numbers — one of them operator-visible in a startup log line.
cert_chain’s output shape changed during review, before any release carried it: normal
observations now come back in notes and self_consistent counts defects only. Nothing depended on
the previous shape, because the previous shape had never shipped.