Skip to content

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.


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-arm

GitHub-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.


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 true
leaf.checkIssued(real) true
leaf.checkIssued(imposter) FALSE

And 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) true
leaf.verify(skidImposter.publicKey) FALSE

So 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.


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.


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.


chainguard/node:latest-slim v25.9.0 against the shipped v26.8.1
node26-slim / 26-slim do not exist

The 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.


npm run lint zero errors
npm run check:versions every location agrees
npm run check:server-json validates against 2025-12-11
mcp-publisher validate server.json is valid
npm test 241/241 Node API, 2289/2289 operations
npm run test:mcp 1691/1691 across 72 files
npm run test:coverage exit 0
npm run conformance both eras match the baseline

Surfaces re-measured: 42 tools / 44,406 bytes for the default index, 120 / 107,652 curated, 545 / 424,810 for all.

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.