pool: track the bundled Node runtime as an observed, quarantined, locked input #40

Merged
pti merged 8 commits from impl/node-runtime-lock-recipe-engines into main 2026-09-04 14:06:29 +02:00
Owner

Implements design/harness/node-runtime-tracking.md, closing #38 permanently. Node runtime versions become observed, quarantined, locked inputs: Descriptor gains NodeLine, refresh observes the nodejs.org dist index per line under the standard write-once and quarantine rules with no bypass, update resolves the newest eligible patch and locks it, the resolved version is folded into recipe_revision with recipeFormatVersion 5 so a new eligible node release alone self-heals every pool, and the packument's engines.node is checked at resolve time with an actionable error. Routine node bumps now flow through refresh, update and sync with zero code changes — a patch is only needed to move major lines. Two work packages, adversarially reviewed twice: the first review found and the fix round closed an unaudited quarantine bypass in the no-eligible fallback and a silent no-op on a NodeLine move, both now pinned by mutation-verified tests. Generated with Claude Code. https://claude.ai/code/session_01NA8scMaA7UJSmBzWsPpPYc

Implements design/harness/node-runtime-tracking.md, closing #38 permanently. Node runtime versions become observed, quarantined, locked inputs: Descriptor gains NodeLine, refresh observes the nodejs.org dist index per line under the standard write-once and quarantine rules with no bypass, update resolves the newest eligible patch and locks it, the resolved version is folded into recipe_revision with recipeFormatVersion 5 so a new eligible node release alone self-heals every pool, and the packument's engines.node is checked at resolve time with an actionable error. Routine node bumps now flow through refresh, update and sync with zero code changes — a patch is only needed to move major lines. Two work packages, adversarially reviewed twice: the first review found and the fix round closed an unaudited quarantine bypass in the no-eligible fallback and a silent no-op on a NodeLine move, both now pinned by mutation-verified tests. Generated with Claude Code. https://claude.ai/code/session_01NA8scMaA7UJSmBzWsPpPYc
Node versions become observed, quarantined, locked inputs like package
versions instead of a hardcoded constant (defaultNodeVersion) that only a
code patch could ever move. Descriptor.NodeLine pins a harness's Node major
line (default "22", internal/pool's defaultNodeLine); RefreshNodeLine fetches
nodejs.org/dist/index.json and records every patch on that line into
observations.json under a "node:<line>" subject, reusing the existing
write-once first_seen_at / Observe / MergeObservations machinery. Update's
resolve path (selectNodeVersion) picks the newest ELIGIBLE observed patch
under the harness's own §9 quarantine policy, falls back to the newest
OBSERVED patch when nothing is eligible, and errors actionably naming
`wraptool harness refresh` when nothing has ever been observed — never a
silently reused hardcoded version. UpdateResult.NodeVersionNote reports the
selection and why whenever it changed.

Test fixtures across internal/pool, internal/pool/source, and cmd needed a
seeded/observed node-line candidate wherever they drive a real npm-sourced
resolve, since that precondition is new; cmd/harness.go itself is not yet
wired to call RefreshNodeLine in production (see the session report for the
follow-up this leaves open).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NA8scMaA7UJSmBzWsPpPYc
recipe_revision now hashes the node patch the lock WOULD resolve to right
now, computed before Decide runs rather than only inside the resolve
fallthrough (WP1's placement) — so a fresh eligible node release on
nodejs.org, with no package version change and no code change, makes
lockMatchesRecipe false and drives the same resolve self-heal every prior
recipeFormatVersion bump needed a code change for. Bumps recipeFormatVersion
4 -> 5 (a format-4-era lock has no node input in its hash at all, so it must
self-heal, not be reinterpreted) and extends the existing format self-heal
test lineage with the 4 -> 5 case.

Falls back to the existing lock's own recorded node version when nothing
can be selected right now (no node observation yet), so a pool that has
never refreshed cannot manufacture a spurious recipe mismatch out of thin
air — issue #38, design/harness/node-runtime-tracking.md WP2.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NA8scMaA7UJSmBzWsPpPYc
Evaluates the packument's engines.node constraint for the selected package
version against the resolved node version (Masterminds/semver) during
Resolve, reusing the same per-call packument cache backfillIntegrity already
threads through. A genuine mismatch fails Resolve, naming the package
version, its engines constraint, the resolved node version, and the
NodeLine to change — catching issue #38's class of bug at update time
instead of harness startup. A missing engines field, or one this package's
semver dialect can't parse, is a non-event, per design/harness/
node-runtime-tracking.md WP2 item 5.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NA8scMaA7UJSmBzWsPpPYc
Closes WP1's own explicit follow-up: without this wiring, every real
`wraptool harness install`/`update` for an npm-sourced harness errored with
"no observed Node release", since nothing in production ever called
pool.RefreshNodeLine. Threads a pool.NodeOptions-shaped seam through
harnessDeps (mirroring the existing newSource/resolveBackend seams) and
calls pool.RefreshNodeLine at both refresh call sites — runHarnessInstall's
composed flow and the bare `harness refresh` command loop — for
npm-sourced harnesses, using the harness's own Descriptor.NodeLine. Also
threads NodeLine into every UpdateOptions literal so a harness pinned to a
non-default line actually resolves against it.

cmd tests keep using WP1's seeded-observation fixtures for `update`-only
paths (which never refresh anything themselves) and add a fake
pool.NodeOptions.Client transport for the handful of tests that do exercise
a real refresh call site — no real network in tests either way.

design/harness/node-runtime-tracking.md WP2, issue #38.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NA8scMaA7UJSmBzWsPpPYc
MAJOR 1: selectNodeVersion's fallback to the newest OBSERVED (not eligible)
node patch was an unaudited quarantine bypass — a node patch published 2h
ago could ship on the very first refresh, and the fallback ignored
Prerelease entirely, making AllowPrereleases unreachable for node. Deleted
the fallback; selectNodeVersion now mirrors decision.go's
selectNewestEligible exactly, erroring with the quarantined candidate and
its remaining wait. No AllowYoung-style bypass exists for node.

MAJOR 2: nodeVersionForRecipe's fallback to the existing lock's recorded
node version didn't check it was on the REQUESTED line, so moving a
harness's NodeLine (e.g. "22" -> "24", the design's one deliberately
supported code change) made `harness update` silently report up-to-date
while still building the old line. The fallback now only applies when the
locked version's major matches the requested line (nodeVersionOnLine).

Adds mutation-pinning tests for both fixes (unrefreshed pool stays a
no-op; a newly eligible node release is preferred over the stale locked
one; a NodeLine move is never a no-op) plus a minimum_observation_age case
for node observations, per review round.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NA8scMaA7UJSmBzWsPpPYc
Threads the per-Resolve-call packuments cache into versionPublishedAt too,
so a Resolve fetches one package's packument at most once even when it
also needs the engines.node check and/or integrity backfill (NIT 8).
Rewords the engines.node mismatch error to name the pinned Node line and
say moving it is a wraptool code change, instead of instructing an end
user to edit a Go struct field name (NIT 7). Drops the "20 - 22" example
of an "unparsable" npm range — Masterminds/semver parses it fine (NIT 6).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NA8scMaA7UJSmBzWsPpPYc
printUpdateResult now renders result.NodeVersionNote (both install and
update share this one function, so one change covers both call sites,
MINOR 3) and its doc comment cites the WP1 work-package brief rather than
the design note, which never spelled the requirement out itself.

refreshNodeLineOnce dedupes the nodejs.org/dist/index.json fetch by Node
line within the bare `harness refresh` loop, so two npm-sourced harnesses
sharing a line (the default, empty one included) only trigger one fetch —
mirroring backendCache's own per-run memoization (NIT 9).

Adds a test exercising install's own refreshNodeLineIfNPM call site
directly (previously only the bare `refresh` command's seam call was
asserted), plus the refresh-loop dedup and NodeVersionNote-is-printed
cases (MINOR 4c).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NA8scMaA7UJSmBzWsPpPYc
docs: flip node-runtime-tracking to implemented, document the fixed behavior (review MINOR 5)
All checks were successful
Lint / lint (pull_request) Successful in 4m55s
e8ba2ccb89
design/harness/node-runtime-tracking.md's Status moves from proposed to
implemented (WP1 + WP2 both landed). CLAUDE.md's internal/pool and
cmd/harness.go entries gain the node-runtime-tracking summary: recipe_revision
is now package + provisioning image + node version + recipeFormatVersion 5,
node versions are observed/quarantined/locked inputs with no bypass
(mirrors selectNewestEligible exactly), and the harnessDeps.nodeOptions/
refreshNodeLineOnce cmd wiring. docs/harness-pool.qmd's shipped-behavior
list gains a bullet for the node-line lifecycle, explicit that there is no
--allow-young equivalent for node (accurate post MAJOR-1 fix).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NA8scMaA7UJSmBzWsPpPYc
pti merged commit 7d0e6a18d8 into main 2026-09-04 14:06:29 +02:00
Sign in to join this conversation.
No reviewers
No labels
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set

Reference
pti/wraptool!40
No description provided.