RFC 0010 — The toolchain layer: SDKMAN and nvm
| Field | Value |
|---|---|
| Status | Accepted — every open question resolved (§11), two of them against this document's own first draft: SDKMAN's rendered version table is filtered rather than exempted, and an unknown publish date is now the operator's explicit choice rather than a hard-coded allow. Implementation may start; nodedist ships at phase 4 and sdkman at phase 7, each behind its own heavy suite |
| Short | The toolchain layer |
| Settles | Proxying the JDK and the Node runtime themselves, not only what they build: SDKMAN and the nodejs.org/dist tree as registry kinds, and making a blocked toolchain a refusal rather than a claim |
| Author | Max Batleforc maxleriche.60@gmail.com |
| Co-author | Claude Opus 5 (1M context) noreply@anthropic.com |
| Created | 2026-08-18 |
| Supersedes | — |
| Touches | crates/core, crates/config, crates/adapters, crates/web, server, cli, ui, docs |
1. Summary
A BatleHub instance can proxy every Maven artifact a JVM build resolves, and none of the JVM it resolves them with. It can proxy every npm package a Node build installs, and not the Node. The toolchain layer — the JDK, Gradle, the Kotlin compiler, the Node runtime itself — sits underneath everything this project already mediates, and reaches a developer's machine from api.sdkman.io and nodejs.org without passing anything BatleHub owns.
This RFC adds two proxy-only registry kinds:
sdkman— the SDKMAN API, for JVM toolchains. A protocol BatleHub does not speak at all today.nodedist— thenodejs.org/distfile tree, for Node. A tree BatleHub already mirrors asgeneric, and cannot enforce a single policy on.
They are different problems with one thesis. SDKMAN needs an adapter because the protocol is missing. nvm needs one because the bytes already flow — the generic kind mirrors nodejs.org/dist today, and its own documentation uses that as the worked example — while a blocked Node version remains a row in the admin console that nothing enforces. Serving the bytes was never the hard part.
Before / after
# today — the toolchain is the layer that goes straight out
$ sdk install java 21.0.5-tem
→ api.sdkman.io → broker.sdkman.io 302 → github.com/adoptium (200 MB, direct)
$ nvm install 22.11.0
→ nodejs.org/dist/index.tab → nodejs.org/dist/v22.11.0/… (direct)
# …or through a `generic` registry, which caches the bytes and enforces nothing.
# with this RFC
$ export SDKMAN_CANDIDATES_API=https://batlehub.example.com/proxy/sdkman/sdkman
$ export SDKMAN_BROKER_API=https://batlehub.example.com/proxy/sdkman/sdkman/broker
$ export NVM_NODEJS_ORG_MIRROR=https://batlehub.example.com/proxy/node/nodedist
$ sdk install java 17.0.20-tem # administratively blocked
Stop! 17.0.20-tem is not a valid java version.
$ nvm install 22.11.0 # administratively blocked
Version '22.11.0' not found - try `nvm ls-remote` to browse available versions.Both refusals are the client's own message on its own error path. Neither required a patched client: SDKMAN and nvm both read their base URLs from environment variables they already consult.
2. Motivation
The toolchain is the last unproxied layer. RFC 0009 made BatleHub serve what Maven, Gradle, npm and Bundler actually request. All of them run on a runtime no BatleHub instance has ever seen. An operator who has blocked a CVE-bearing library and cannot block a CVE-bearing JDK or a Node release has covered the smaller of the two.
For Node, the bytes already flow and the policy does not. A
genericregistry pointed atnodejs.org/distcaches every tarball — the worked example indocs/registries/generic.mdis literally that. Butgeneric_get(crates/web/src/handlers/proxy/generic.rs:56) buildsPackageId::new(reg, "repo", "_"): one synthetic package, no version, no identity. Soindex.tabis relayed unfiltered, no Node version can be blocked, nothing appears in explore, and per-version statistics do not exist. This is the more dangerous of the two gaps, because the cache working makes it look solved.For the JVM, the protocol is simply absent.
genericgets closer than expected — I verified a completesdk installthrough twogenericregistries — and thensdk list javareturns 400, becausegeneric_gettakes only a path and SDKMAN'sversions/listrequires?current=&installed=. The stopgap is real, is documented in §9, and is not the feature.A block on a toolchain is currently a claim, not a control. RFC 0006 §1 set the standard that a blocked version is hidden from every listing an ecosystem serves. Between them these two managers have six documents that name versions — SDKMAN's
versions/all,candidates/default, the renderedversions/listandvalidate, and Node'sindex.tabandindex.json— and BatleHub filters none of them.The broker's redirect is egress that survives an air gap.
broker.sdkman.io/download/…answers302to a third-party CDN (github.com,repo.maven.apache.org,services.gradle.org,groovy.jfrog.io— four different hosts across the seven candidates I sampled), and__sdkman_secure_curl_downloadpasses--location, so the client follows it. An estate that has closed egress everywhere else finds out at the moment it provisions a build agent. RFC 0008 §2 made this argument formise; this is the rest of it..nvmrcand.sdkmanrcare bills of materials nobody reads. One is a single line naming a Node version or alias; the other iscandidate=versionpairs. Both pin exactly what a repository builds with, both are already in the repositories, andbatlehub registry suggestalready scansmise.tomlandmise.lockfor the same purpose. §6.9 warms from them.
3. Goals / non-goals
Goals
sdk install,sdk list,sdk use,sdk defaultandsdk upgrade, andnvm install,nvm ls-remoteandnvm use, all work against a BatleHub registry with no patched client — only environment variables the clients already read.- A blocked toolchain version is absent from every listing document the two ecosystems serve, and the client's own "not found" path runs rather than a download that fails halfway.
- Toolchain archives are cached under a stable per-version coordinate, so the second build agent to ask for a 200 MB JDK or a 50 MB Node tarball does not leave the site.
- Every hop of SDKMAN's redirect chain is validated server-side, and upstream credentials never cross an origin.
- Node's
SHASUMS256.txtreaches the client byte-identical, so nvm's checksum verification keeps meaning what it means.
Non-goals
- Local/hybrid mode for either kind. Neither has a publish protocol: SDKMAN candidates are onboarded by the SDKMAN team, and Node releases are built by the Node project. Both join the proxy-only group with
github,forgejo,gitlab,jetbrainsandgeneric. - Hosting a private toolchain. "Publish our internal JDK build as an SDKMAN candidate" needs a version-catalogue model this RFC does not design, and half of it shipped under the same registry kind would be worse than none.
- READMEs. Neither protocol carries prose about a release.
- Reflowing SDKMAN's rendered
versions/listtable. It is filtered (§4.4), but a removed version in the non-Java grid layout leaves its cell blank rather than pulling the remaining versions up a row. Re-packing a column-major fixed-width grid is a rendering job with no compatibility promise behind it, where blanking a cell is a two-line change that cannot corrupt the rest of the table. - Rewriting
SHASUMS256.txt. It is signed by a sibling.asc/.sig; §7. - Mirroring either installer.
get.sdkman.ioand nvm'sinstall.shonraw.githubusercontent.comare shell scripts on hosts with no registry protocol. §11 decision 9 states the rule. nvm install --ltsreading a stale local alias. nvm caches LTS aliases under$NVM_DIR/alias/lts/; §4.4 states the resulting limit honestly rather than pretending the block is total.
4. User-facing design
4.1 Configuration
[[registries]]
name = "sdkman"
type = "sdkman"
mode = "proxy" # the only mode; see §4.5
upstreams = ["https://api.sdkman.io/2"] # the candidates API
broker_url = "https://broker.sdkman.io" # the download broker
[[registries]]
name = "node"
type = "nodedist"
mode = "proxy"
upstreams = ["https://nodejs.org/dist"] # the default; io.js takes its own block
# An age gate on either kind must say what it does with an unknown publish
# date — there is no default here, on purpose (§4.5, §6.7).
[[registries.rules]]
kind = "release_age_gate"
min_age_secs = 86400
deny_missing_timestamp = falsebroker_urlis a newRegistryConfigfield, absent by default, meaninghttps://broker.sdkman.io. It sits beside cargo'sindex_url(crates/config/src/schema/registry.rs:94) and is documented the same way: a second upstream URL that one kind needs and the rest ignore. It is rejected on any other type.upstreamsbehaves as everywhere — a list tried in order, defaulting tohttps://api.sdkman.io/2andhttps://nodejs.org/dist. SDKMAN's/2is part of the URL rather than synthesised, because the client's own variable carries it and an operator pointing athttps://beta.sdkman.io/2must be able to say so.path_allowis used by neither: both kinds are typed rather than path-addressed, sois_path_addressed()stays false for both.- io.js takes a second
nodedistregistry pointed athttps://iojs.org/dist, not a second field. Unlike SDKMAN's broker it is a separate (and long-dead) ecosystem rather than a second facet of one service, and itsindex.tabhas nine columns where Node's has eleven.
4.2 The client side
BH=https://batlehub.example.com/proxy
# SDKMAN — both variables are set in sdkman-init.sh only when empty
export SDKMAN_CANDIDATES_API="$BH/sdkman/sdkman"
export SDKMAN_BROKER_API="$BH/sdkman/sdkman/broker"
# nvm
export NVM_NODEJS_ORG_MIRROR="$BH/node/nodedist"Exporting them before sourcing sdkman-init.sh or nvm.sh — in /etc/profile.d, a Containerfile, or a CI job's env: block — is enough. nodedist also serves the other clients that read the same tree: fnm's FNM_NODE_DIST_MIRROR, n's N_NODE_MIRROR, and the NODEJS_ORG_MIRROR that docs/registries/generic.md already documents for mise.
Authenticated instances add a ~/.netrc entry for the proxy host. Neither client has a place to put a header — __sdkman_secure_curl and nvm_download both build their own curl invocation — and libcurl reads ~/.netrc without being asked:
machine batlehub.example.com
login <your-user-id>
password <your-token>SDKMAN_SERVICE is the installer's variable and matters only when bootstrapping SDKMAN itself; §11 decision 9.
4.3 Coordinates
| Request | PackageId | Cache key |
|---|---|---|
sdkman broker/download/java/21.0.5-tem/linuxx64 | java / 21.0.5-tem / linuxx64 | sdkman/java/21.0.5-tem/linuxx64 |
sdkman candidates/java/linuxx64/versions/all | java/linuxx64, version unused | metadata, versions |
sdkman candidates/default/java | java, version unused | metadata, sdkman-default |
nodedist v22.11.0/node-v22.11.0-linux-x64.tar.xz | node / v22.11.0 / node-v22.11.0-linux-x64.tar.xz | node/node/v22.11.0/node-v22.11.0-linux-x64.tar.xz |
nodedist v22.11.0/SHASUMS256.txt | node / v22.11.0 / SHASUMS256.txt | node/node/v22.11.0/SHASUMS256.txt |
nodedist index.tab | node, version unused | metadata, versions |
nodedist has exactly one package, and its name is node (iojs on an io.js registry). That reads oddly until you try the alternative: the thing an admin blocks is a Node release, and a package-per-platform or package-per-file model would make "block Node 22.11.0" eight separate operations. One package, many versions, many files per version is what the ecosystem actually is.
4.4 Behaviour rules
SDKMAN's platform axis. Every version-bearing SDKMAN endpoint is addressed by {candidate}/{version}/{platform}, where platform is a closed set of eight values produced by infer_platform in the installer: linuxx64, linuxx32, linuxarm32hf, linuxarm64, darwinx64, darwinarm64, windowsx64 and exotic. The candidate is the package, the platform is a sub-coordinate: one cache entry per platform, one blocked-version set per candidate.
What is filtered, and what is not.
| Kind | Document | Treatment |
|---|---|---|
sdkman | candidates/{c}/{plat}/versions/all | Filtered. Comma-separated text; blocked versions removed. |
sdkman | candidates/default/{c} | Filtered. A single version string — the "newest" pointer, repaired to name a version still allowed, as dist-tags.latest is for npm. |
sdkman | candidates/{c}/{plat}/versions/list | Filtered. The rendered sdk list table, in both of its layouts — see below. |
nodedist | index.tab | Filtered. TSV, one release per row; blocked rows removed, header preserved. |
nodedist | index.json | Filtered. The same data as a JSON array — read by fnm and mise. Filtering one and not the other would leave a second unfiltered answer to the same question. |
nodedist | SHASUMS256.txt | Never touched. §7. |
SDKMAN renders versions/list two different ways, and both are addressable. The table is produced for a human, but it is fixed-width and every version in it appears in a field a filter can find:
# Java — a vendor-grouped table whose last column is the install identifier
Vendor | Use | Version | Dist | Status | Identifier
--------------------------------------------------------------------------------
Corretto | | 21.0.12 | amzn | | 21.0.12-amzn
| | 17.0.20 | amzn | | 17.0.20-amzn
# everything else — a column-major grid of version strings
4.0.0-rc-6 3.9.5 3.6.3 3.1.1
3.10.0-rc-1 3.9.2 3.6.0 3.0.4
> * 3.9.9 3.8.4 3.2.5In the Java layout a blocked version is one whole line, matched on the Identifier column — which is exactly the string sdk install java <id> takes. The one complication is that the Vendor cell is written only on the first line of each vendor's block, so removing that line would orphan the group: the vendor name is promoted into the next surviving line of the same block, in the same fixed-width field.
In the grid layout a blocked version is one cell, blanked in place to its own width. The remaining versions do not move up. That keeps the column widths, the > * + markers and the legend intact, and a gap in a list of versions is a smaller wrong than a re-packed table that no longer aligns.
Neither layout is parsed for meaning — no column is interpreted, nothing is re-rendered. Both filters find a version string in a known field and remove it, which is why an upstream cosmetic change degrades to "a row we failed to remove", never to a corrupted document.
The two chokepoints. Each ecosystem has one place where "install this exact version" is decided, and in both cases it is a document above:
- SDKMAN calls
candidates/validate/{c}/{v}/{plat}and refuses anything that does not answervalid. A blocked version answersinvalid, producing "Stop! 17.0.20-tem is not a valid java version." and no download. - nvm resolves every install through
index.tab—nvm_remote_versionsetsVERSION='N/A'when the pattern matches nothing in it, including a fully specifiednvm install 22.11.0. A blocked row therefore produces "Version '22.11.0' not found" and no download.
That symmetry is the reason both kinds are in one RFC: the enforcement point is the listing document in both, and neither needs a new refusal mechanism.
The honest limit on nvm. nvm writes LTS aliases into $NVM_DIR/alias/lts/ when it reads index.tab, and nvm install --lts will use a cached alias without re-reading the index. A version blocked after a machine last ran nvm ls-remote can therefore still be requested by alias — at which point the tarball fetch is refused and nvm reports a download failure rather than a clean "not found". The block holds; the message degrades. Documented on the registry page rather than discovered.
SDKMAN response headers. The broker's 302 carries X-Sdkman-ArchiveType and, for some candidates, X-Sdkman-Checksum-<ALG>; __sdkman_download greps ^X-Sdkman out of the response and __sdkman_checksum_zip verifies against it. BatleHub forwards every X-Sdkman-* header from the final response of the redirect chain, as an allow-listed prefix. Nothing in the CLI reads ArchiveType today, but dropping the checksum family would silently disable the one integrity check the client performs.
SDKMAN hook scripts. hooks/pre and hooks/post return bash the client sources and executes. Relayed byte-exact: no rewriting, no sanitiser, no URL substitution. §7.
4.5 Validation
AppConfig::validate() rejects:
| Condition | Rationale |
|---|---|
type = "sdkman" or "nodedist" with mode = "local"/"hybrid" | Neither has a publish protocol; the registry would answer nothing. supports_local_mode() already produces this error for generic and jetbrains. |
broker_url on a registry whose type is not sdkman | Same class as index_url on a non-cargo registry: a silently ignored option is a misconfiguration that looks like a proxy bug. |
broker_url that is not an absolute http/https URL | It is joined with a path and fetched; a relative value fails at install time instead of at boot. |
A release_age_gate rule on an sdkman or nodedist registry with no explicit deny_missing_timestamp | Both kinds can hand the rule a package with no publish date, and the two answers — quarantine everything undated, or exempt it — are opposite security postures. The field already exists and defaults to false; inheriting that default silently on a registry where it decides the whole gate is how an operator ends up believing a toolchain is quarantined when it is not (§6.7). |
Warnings (logged and surfaced to the admin):
| Condition | Behaviour |
|---|---|
path_allow on an sdkman or nodedist registry | Ignored, with a warning naming the option — neither is path-addressed, and an operator who set an allowlist believes something is being enforced. |
sdkman upstreams whose path does not end in /2 | Served as given. SDKMAN versions its API in the path; a URL without it is more likely a typo than a choice, but it is not ours to reject. |
nodedist upstreams pointing at a host with no index.tab | Served as given, warned at reload. The registry will answer artifacts and fail listings, which is worth saying once at boot rather than once per nvm ls-remote. |
5. Architecture
5.1 Two shapes of the same gap
SDKMAN needs a client because BatleHub cannot form the requests. nodedist needs one because BatleHub cannot form the coordinate — the bytes already arrive, addressed by a path, under a package called repo with a version called _.
The heavy line in the SDKMAN half is the load-bearing decision. The client could follow the broker's redirect itself — --location is already passed — and then BatleHub would have proxied a policy decision and cached nothing. Following it server-side is what makes this a cache instead of a referral, and it reuses ssrf::fetch_following_redirects, which validates the initial URL and every Location against the SSRF guard and re-issues without credentials once the chain leaves the registry's origin.
5.2 The SDKMAN listing coordinate
RegistryClient::fetch_version_document takes (package: &str, kind: DocumentKind) — no platform — so SDKMAN's platform has to travel inside package, the way Terraform's modules//providers/ prefix already does. That is fine for the fetch and wrong for the block: ProxyService::version_document derives the blocked-version lookup from the same string (crates/core/src/services/proxy/handle.rs:427 and :452), so an admin would block java/linuxx64 17.0.20-tem and leave the same JDK installable on macOS. §6.2 normalises it.
nodedist has no such problem: the platform is part of the file name, which lives in PackageId::artifact, and the version stands alone.
5.3 Where a block becomes effective
The invariant: a version removed from a listing is also unresolvable by exact name, and both read the blocked set on each request rather than a snapshot. A user who types a version the listing no longer shows — from a lockfile, an .nvmrc, or memory — hits the same wall as a user who browses.
6. Detailed design
6.1 crates/core — the registry kinds
RegistryKind::Sdkman and RegistryKind::Nodedist are added to the enum and to ALL. Six exhaustive matches then refuse to compile until answered, which is the intended pressure. Both answer supports_local_mode() = false, requires_explicit_upstream_in_proxy_mode() = false and is_path_addressed() = false; the rest:
sdkman | nodedist | |
|---|---|---|
listing_filter() | Filtered("versions/all", ["versions"]), Filtered("candidates/default", ["sdkman-default"]), Filtered("the rendered versions/list table", ["versions-list"]) | Filtered("index.tab", ["versions"]), Filtered("index.json", ["index-json"]) |
readme_support() | None("SDKMAN describes a distribution, not a package: no document in the protocol carries prose about a candidate") | None("a Node release is a set of tarballs and a checksum file; the dist tree carries no prose") |
upstream_detail() | Document("versions") | Document("versions") |
fetchable_by_version() | None("addressed by platform as well as version") | None("a Node release is a set of files — one per platform, plus headers, source and checksums — so \"fetch this version\" has no single meaning") |
warm_artifact() | None — the platform is not a constant | None — the file name is not a constant |
Maven's row already establishes the fetchable_by_version shape for "a version is a set of files", and Terraform's for "a version needs a platform". Neither is new vocabulary.
docs:listing-coverage and docs:readme-coverage generate their published tables from the first two rows, so each registry page's support table is produced, not written.
6.2 crates/core — blocking
blocking::strip gains two arms.
Sdkman, dispatching on ctx.document:
DocumentKind::Versions→with_text(doc, sdkman::strip_versions_csv). A comma-separated list, filtered by splitting on,and rejoining. No parser.DocumentKind::SDKMAN_DEFAULT(a newSecondary("sdkman-default")) → if the named version is blocked, replace it withbest_latestover the filteredversions/allfor that candidate. That needs a second document, which is the composition RubyGems'GEMarm already delegates to its handler; the same is done here (§6.5).DocumentKind::SDKMAN_VERSIONS_LIST(a newSecondary("versions-list")) →with_text(doc, sdkman::strip_rendered_list), which picks its layout by looking for the| Identifierheader:- Vendor table — drop lines whose
Identifierfield is blocked. If the dropped line carried theVendorcell for its block, write that cell into the next surviving line of the block, padded to the same width. If no line of the block survives, the vendor disappears with it, which is correct. - Grid — replace a blocked version's cell with spaces of the same width, leaving every other cell where it is. The
Usemarkers (>,*,+) sit in their own leading field and are untouched.
Both operate on whole fixed-width fields and neither re-renders the document, so the failure mode of an upstream layout change is a version we did not remove — visible, and caught by the fixture test — rather than a table we corrupted.
- Vendor table — drop lines whose
Nodedist:
DocumentKind::Versions→with_text(doc, nodedist::strip_index_tab). Keep line 1 (nvm strips it withsed 1d, so a lost header silently eats the newest release), drop rows whose first tab-separated field is blocked, leave every other column untouched. The column count is not assumed: Node'sindex.tabhas eleven columns and io.js's has nine.DocumentKind::INDEX_JSON(a newSecondary("index-json")) →with_json(doc, nodedist::strip_index_json), dropping array entries byversion.
There is no "newest pointer" to repair in either Node document: index.tab is ordered newest-first and nvm derives lts/* aliases from column 10 of whatever rows survive, so removing a row moves the alias by construction. That is worth stating because it is the one place this design gets something for free.
The blocked-set lookup is normalised for SDKMAN. RegistryKind gains:
/// The name a blocked-version lookup should use for `package`.
///
/// Defaults to the name itself. `sdkman` addresses its listing documents by
/// `{candidate}/{platform}` because `fetch_version_document` has nowhere else
/// to put the platform, but a block is a statement about the candidate: an
/// admin blocking a JDK means all eight platforms, not the one whose listing
/// they happened to be looking at.
pub fn blocking_package_name<'a>(&self, package: &'a str) -> &'a strProxyService::version_document calls it for both the ListingContext and blocked_versions_for. Every other kind returns package unchanged, so the change is inert everywhere but here — and it is a named function with a reason attached rather than an if kind == Sdkman at a call site.
6.3 crates/config
RegistryConfig::broker_url: Option<String>, documented as SDKMAN-only.validate()needs no new registry-type list: it parsesregistry_typeintoRegistryKind, so both variants are picked up automatically. The rejections and warnings of §4.5 are added beside the existingindex_urlchecks.CURRENT_CONFIG_VERSIONdoes not move. Every existing config file remains valid; the new field is optional and the new types are additive.
6.4 crates/adapters
registry/sdkman/ — a directory, not a flat file, per the layout rule in CLAUDE.md: the platform axis, the second base URL and the header allow-list would otherwise crowd out the request logic.
client.rs—SdkmanRegistryClient { http, api_base, broker_base, … }.resolve_metadata→GET candidates/validate/{c}/{v}/{plat};invalidbecomesCoreError::NotFound. The cheapest existence check the protocol offers, and the same question the rule engine needs answered.published_atisNone— SDKMAN publishes no dates (§6.7).fetch_artifact→broker_base/download/{c}/{v}/{plat}throughssrf::fetch_following_redirects, returning the final response's body and itsX-Sdkman-*headers.fetch_version_document→Versions,SDKMAN_DEFAULTandSDKMAN_VERSIONS_LISTas text withcontent_type: "text/plain; charset=UTF-8", matching upstream. The rendered list carries the client'scurrent/installedquery string through to upstream, and is part of the metadata cache key — two clients with different installed sets must not share an entry.list_versions(package)→ parses{candidate}/{platform}out ofpackageand readsversions/all.
models.rs— the platform enum and its parser, so an unknown platform is a400at the edge rather than a segment forwarded upstream.tests.rs—mockito, spanning both files, per the standalone-tests.rsexception.
registry/nodedist.rs — a flat file. The tree has no DTOs worth a module: index.tab is TSV parsed by split('\t'), index.json is an array of objects with a version field, and there is no second protocol facet.
resolve_metadata→ reads the release date out of the cachedindex.taband populatespublished_at, falling back to aHEADon the artifact path when the version is not in the index. Node's dist tree has no metadata API, but column 2 of the listing is the release date and the listing is already fetched and cached — so the age gate works for Node without a second request. It isNoneonly for a release the index no longer lists, which is the case §6.7 makes the operator decide about.fetch_artifact→{base}/{version}/{file}, streamed. No redirect chain: the tree serves its own bytes.fetch_version_document→Versions(index.tab,text/plain) andINDEX_JSON(index.json,application/json).list_versions("node")→ the first column ofindex.tab.
FetchedArtifact grows one field — headers: Vec<(String, String)> — for the X-Sdkman-* allow-list. Every other client leaves it empty.
6.5 crates/web — handlers and routes
handlers/proxy/sdkman/:
| Route | Handler |
|---|---|
GET …/sdkman/candidates/all | candidates_all — passthrough |
GET …/sdkman/candidates/list | candidates_list — passthrough |
GET …/sdkman/candidates/{c}/{plat}/versions/all | versions_all — filtered |
GET …/sdkman/candidates/{c}/{plat}/versions/list | versions_list — filtered, forwarding ?current=&installed= |
GET …/sdkman/candidates/default/{c} | candidate_default — filtered, composed against versions/all |
GET …/sdkman/candidates/validate/{c}/{v}/{plat} | validate — blocked ⇒ invalid |
GET …/sdkman/hooks/{phase}/{c}/{v}/{plat} | hook — byte-exact passthrough |
GET …/sdkman/healthcheck | healthcheck |
GET …/sdkman/broker/download/{c}/{v}/{plat} | download — proxy_stream |
GET …/sdkman/broker/version/sdkman/{channel}/{track} | selfupdate_version |
handlers/proxy/nodedist.rs:
| Route | Handler |
|---|---|
GET …/nodedist/index.tab | index_tab — filtered document |
GET …/nodedist/index.json | index_json — filtered document |
GET …/nodedist/{version}/{file} | dist_file — proxy_stream, covers tarballs, SHASUMS256.txt, .asc, .sig |
Two obligations from the existing rules, both non-negotiable:
- Validate at the edge. Every path segment reaches a storage key.
validate_package_nameon the SDKMAN candidate, the platform enum on the platform,validate_path_safeon the Node file name, and a../separator rejection on both versions — before anything else, for a clean400.validate_coordinateinProxyService::handleandensure_safe_keyin the storage backends remain the deeper guards; the handlers do not lean on them. body = Ton every success response, orcrates/web/tests/openapi_contract.rsfails. The text documents take theProtocolDocumentmarker fromhandlers/schemas.rs;downloadanddist_filetakeArtifactBytes.
Route ordering matters in both. SDKMAN's candidates/default/{c} and candidates/{c}/{plat}/versions/all are both multi-segment under candidates/, and default must register first or a candidate named default is unreachable. nodedist's index.tab must register before {version}/{file}. The conformance fixture (§10) asserts the matched pattern, so both orderings are checked rather than commented.
6.6 server
builders.rs's exhaustive match on RegistryKind forces two arms: SdkmanRegistryClient from resolve_urls(®.upstreams, "https://api.sdkman.io/2") plus reg.broker_url.as_deref().unwrap_or("https://broker.sdkman.io"), and NodeDistRegistryClient from resolve_urls(®.upstreams, "https://nodejs.org/dist"). No main.rs change beyond what the builder already does for every kind.
6.7 Rules
DenyLatestRule and BlockListRule need nothing — they read the coordinate. ReleaseAgeGateRule reads published_at, and the two kinds differ in how often they can produce one:
nodedistusually can:index.tabcolumn 2 is the release date, and §6.4 populatespublished_atfrom the cached listing. The gate works. It is unknown only for a release the index no longer lists.sdkmannever can: the protocol publishes no dates at all. Every package reaches the rule withpublished_at: None.
The rule already has the knob for this. ReleaseAgeGateRule::deny_missing_timestamp (crates/core/src/rules/release_age.rs:20) decides whether a missing timestamp denies or is skipped, and ReleaseAgeGateConfig exposes it as deny_missing_timestamp, defaulting to false. Nothing new is invented here; what changes is that the default stops applying silently to these two kinds.
On sdkman and nodedist, an age gate must state the field. Config validation rejects a release_age_gate rule on either kind that does not (§4.5). The reason is that on sdkman this single field is the rule: with false the gate is inert and every JDK passes; with true the gate denies every JDK, because no SDKMAN package will ever carry a date. Both are legitimate postures — "we do not quarantine toolchains" and "we do not serve anything whose age we cannot verify" — and neither is a sensible thing to arrive at by inheriting a default written for npm. The earlier draft of this RFC hard-coded the permissive answer; that was the wrong shape, because it made a security decision on the operator's behalf and left no trace of having made it.
The registry pages state the consequence in one line each, so it is read before it is discovered: on sdkman, deny_missing_timestamp = true means no artifact downloads at all; on nodedist, it means current releases pass and de-listed ones are refused.
6.8 ui and docs
ui/src/config/registryTypes.ts— aREGISTRY_TYPE_DEFSentry per kind, with theexportlines of §4.2 as the setup snippet and the~/.netrcblock for authenticated instances. Thenodedistentry names nvm, fnm,nand mise, because nobody searches a console for "nodedist".- Two new pages under
docs/registries/, whose support and endpoint tables are generated (task docs:readme-coverage,docs:listing-coverage,docs:endpoints) rather than written. docs/registries/index.mdand the/registries/sidebar indocs/.vitepress/config.ts— one sidebar per page, ortask docs:audiencefails.docs/registries/generic.mdkeeps itsnodejs.org/distexample and gains one line pointing atnodedistfor anyone who needs policy on it.docs/operations/egress.md— the CDN hosts SDKMAN's broker redirects to.
6.9 cli — .nvmrc and .sdkmanrc
batlehub registry suggest already scans a project for mise.toml and mise.lock. It gains two more inputs:
.nvmrc— a single line, a version or an alias (lts/*,node)..sdkmanrc—candidate=versionpairs,#-commented.
Both produce the registry blocks of §4.1 and the client environment variables of §4.2, and both feed cache warming. Warming needs a platform that neither file names, so it comes from config — a warm_platforms list on the registry, defaulting to the platform the server runs on. Guessing all eight would fetch 1.6 GB of JDK to satisfy a one-line file.
6.10 tests/heavy — two new suites
Both kinds get a heavy suite, tests/heavy/sdkman.sh and tests/heavy/nvm.sh, with a config.sdkman.toml and config.nvm.toml beside them, task test:sdkman-heavy / test:nvm-heavy targets, entries in task test:heavy, and two more rows in the heavy-client matrix in .github/workflows/test.yaml. They are not optional extras; §10 says why.
Three things about these two clients that lib.sh does not already handle, and that an implementer will otherwise hit one at a time:
sdkandnvmare shell functions, not binaries.heavy_needcheckscommand -vbefore anything is sourced, so it cannot be asked for them. The prerequisite check is for the binaries the managers themselves shell out to, and the client's existence is asserted after sourcing its init script —type -t sdk,type -t nvm— inside the samebash -lthe test drives.- SDKMAN's Java post-install hook needs
zipandtar. The hook the API returns for a Linux JDK runs/usr/bin/env tar zxfthen/usr/bin/env zip -qyrto repackage the tarball as the zip__sdkman_install_candidate_versionunzips. A runner withoutzipfails inside a script BatleHub relayed and did not write, which is the single most confusing failure this suite can produce.heavy_need zip zipandheavy_need tar tar, up front. - Both managers write into
$HOME.$SDKMAN_DIRand$NVM_DIRare redirected into the run's temp directory, so a suite cannot install a JDK over the runner's own toolchain, and two concurrent matrix jobs cannot collide.
Deliberately untouched, so reviewers do not go looking:
crates/adapters/src/registry/path_proxy.rs— thegenericstopgap keeps working exactly as today. This RFC does not deprecate it; §9 says what migrating costs.crates/core/src/services/local_registry.rs— no local mode, so noget_sdkman_versionsorget_nodedist_versions.blocking::dispatch_multi— neither kind has a whole-registry index, so no snapshot lag and noFILTERED_ELSEWHEREentry.
7. Security considerations
SHASUMS256.txtis never rewritten, and that is a hard rule. Each Node version directory carriesSHASUMS256.txtalongsideSHASUMS256.txt.ascand.sig, andnvm_get_checksum/nvm_compare_checksumverify every download against it. Editing it would break the detached signature for anyone who checks it and silently invalidate nvm's own integrity check for everyone who does not. This is the same reasonlisting_filter()already records for deb, rpm and pacman — editing a signed index invalidates it and the client rejects the whole repository, a worse failure than the one filtering fixes. Blocking a Node version removes it fromindex.tab; it does not doctor its checksums.The proxy relays executable shell. SDKMAN's
hooks/preandhooks/postreturn bash the client sources and runs as the invoking user. This is SDKMAN's design, it is what the client does today directly fromapi.sdkman.io, and BatleHub neither adds nor removes trust by carrying the bytes. What it must not do is modify them: rewriting a URL inside a hook would turn a relay into a party that authored part of the script. Byte-exact passthrough, documented on the registry page so an operator knows the relationship they are in.Redirect chains are attacker-adjacent. SDKMAN's broker names the download host, and that host is not SDKMAN. Following the chain with
ssrf::fetch_following_redirects— rather than reqwest's default policy, which is what thegenericstopgap gets — validates every hop against the SSRF guard and drops upstream credentials the moment the chain leaves the registry's origin. A compromised or mistaken broker response cannot make BatleHub fetch an internal address, nor collect the operator's configured upstream credentials.nodedisthas no redirect chain at all.Header forwarding is an allow-list, not a passthrough. Only
X-Sdkman-*from the final response crosses back. Forwarding upstream headers wholesale would carrySet-Cookie— the broker sits behind Cloudflare and does set one — into a response BatleHub authenticates itself.validateansweringinvalidis a deliberate small lie. The version exists upstream; BatleHub says it is not valid here. The alternative — answeringvalidand refusing the artifact — writes an error body to the binary path, fails the post-install hook ontar tzf, and tells the user "Download has failed, aborting!", which sends them looking for a network fault. The audit log records the refusal with its rule, so the operator-facing record is accurate even though the client-facing string is the protocol's only available "no". nvm needs no equivalent: absence fromindex.tabis its "not found".Path traversal. Candidate, version, platform and Node file name are all attacker-controlled and reach a storage key. §6.5 validates each at the edge; the traversal regression test of §10 is the one every registry kind carries.
New unauthenticated surface: none. Every route sits under
/proxy/{registry}/…and inherits the registry's RBAC, readingreleases:readfor artifacts and the listing authorisation for documents, exactly as the other twenty kinds do.
8. Alternatives considered
| Alternative | Why rejected |
|---|---|
Leave Node on generic | It caches and enforces nothing: one synthetic package, no version, so no blocking, no explore, no per-version statistics, and index.tab relayed unfiltered. The cache working is what makes this the more dangerous gap. |
Ship the two-generic-registries recipe as the SDKMAN answer | sdk list returns 400 (§2.3), and the same identity problem applies. It stays documented as a stopgap in §9. |
Name the Node kind nvm | A registry kind names a protocol, not a client — goproxy is the precedent. The nodejs.org/dist tree is read by nvm, fnm, n, volta and mise; naming it after one of them would make the other four look unsupported. The UI and docs lead with "nvm" so it is still findable. |
Filter index.tab and leave index.json | Two encodings of one document; filtering one leaves an unfiltered answer to the same question for fnm and mise. RFC 0006's PyPI row already settled this shape. |
Rewrite SHASUMS256.txt to drop blocked files | Breaks the detached .asc/.sig signature and nvm's own verification. §7. |
Relay SDKMAN's 302 to the client | Zero caching — the 200 MB JDK still leaves the site, from a host the operator did not configure. BatleHub would mediate a policy decision and none of the bytes. |
One registry per SDKMAN host (sdkman-api + sdkman-broker) | Two config blocks, two RBAC surfaces, two rule sets for one logical registry — and a block on one silently not applying to the other. |
A broker_url-style second field for io.js | io.js is a separate ecosystem with a different index.tab shape, not a second facet of one service. A second registry block is the honest model, and costs an operator three lines they will almost certainly never write. |
Put SDKMAN's platform in PackageId::version (21.0.5-tem@linuxx64) | Blocked-version matching, best_latest and every existing version comparison would operate on a string that is not a version. |
A DocumentKind per SDKMAN platform (versions-linuxx64, …) | Possible — the set is closed at eight — but it multiplies a &'static str discriminant by a dimension it was not built for, where blocking_package_name (§6.2) solves the same problem in one function. |
Leave SDKMAN's rendered versions/list unfiltered | It is the table sdk list prints. Leaving it whole means the console advertises a JDK that sdk install then refuses — the exact failure RFC 0006 §1 exists to prevent. The layouts turned out to be addressable by fixed-width field (§4.4), so the objection that survived was cosmetic, not structural. |
| Re-pack the grid layout after removing a cell | A column-major fixed-width grid re-flowed on the fly is a rendering job that can misalign the whole table; blanking one cell cannot. §3. |
A new per-registry unknown_release_date option | ReleaseAgeGateConfig::deny_missing_timestamp already exists and already means exactly this. A second option for one question would be two places to look and one of them wrong. |
Let deny_missing_timestamp default to false on these kinds | On sdkman that field is the rule, and inheriting a default silently is how an operator ends up believing toolchains are quarantined when nothing is. §6.7. |
9. Rollout and compatibility
- Default behaviour when unconfigured. Nothing changes. Both are new registry types; an instance with neither configured behaves exactly as before. The new
broker_urlfield is optional and rejected on other types (§4.5), so no existing config file changes meaning. - Config migration. None.
CURRENT_CONFIG_VERSIONstays at1. - Operator prerequisites. Egress to
api.sdkman.io,broker.sdkman.io,nodejs.org, and the CDN hosts SDKMAN's broker redirects to —github.com(andobjects.githubusercontent.com),repo.maven.apache.org,services.gradle.org,groovy.jfrog.ioat minimum. That list is documented as observed, not exhaustive: the broker can add a host without telling anyone, which is itself an argument for warming ahead of an air gap. - Migrating a
genericNode mirror tonodedist. Changetypeand droppath_allow; the client variable changes from…/genericto…/nodedist. Cached artifacts do not carry over —genericstores under{registry}/repo/_/{path}andnodedistunder{registry}/node/{version}/{file}— so the cost is one cold fetch per Node release still in use. The same applies to the SDKMAN stopgap. The two coexist rather than one replacing the other, andgenerickeeps itsnodejs.org/distexample for operators who want a mirror with no policy. - Rollback. Remove the registry block. Cached artifacts remain in storage under their keys, unreferenced, and the ordinary retention path collects them. Nothing else is persisted.
10. Test plan
Unit (
crates/adapters/src/registry/sdkman/tests.rs,mockito):versions/allparses and round-trips;validatemapsinvalidtoNotFound; the broker's302is followed and the final body streamed; a redirect to a private address is refused by the SSRF guard;X-Sdkman-*survives the chain andSet-Cookiedoes not; an unknown platform is rejected before a request is made.Unit (
crates/adapters/src/registry/nodedist.rs):index.tabparses with eleven columns and with io.js's nine;index.jsonparses;list_versionsreturns the first column; a version directory file streams.Unit (
crates/core/src/services/blocking/sdkman.rs): a blocked version is removed from a comma-separated list, at the first and last positions and when it is the only entry;candidates/defaultnaming a blocked version is repaired to the newest allowed one; an empty blocked set leaves the document byte-identical. For the rendered table, against captured fixtures of both layouts: a blocked Java row is removed by itsIdentifier; blocking the first row of a vendor block promotes the vendor name into the next surviving row at the same column width; blocking every row of a vendor block removes the vendor entirely; a blocked version in the grid layout is blanked in place with every other cell, the> * +markers and the legend byte-identical; an unrecognised layout is passed through and logged rather than mangled.Unit (
crates/core/src/services/blocking/nodedist.rs): the header row survives filtering — the case that matters, because nvm strips line 1 unconditionally and a lost header eats the newest release; a blocked row is removed from bothindex.tabandindex.json; blocking the newest LTS moves thelts/*alias nvm derives from column 10; a nine-column io.js table is filtered without touching its columns; an empty blocked set is a byte-identical passthrough.Unit (
crates/core/src/entities/registry_kind.rs): the existingevery_kind_answers_*tests cover both new variants automatically — that is the point of their exhaustiveness — andevery_advertised_filter_is_reachable_from_dispatchmust resolve all four advertised documents to a real filter.Unit (
crates/config/src/schema/tests.rs): arelease_age_gaterule on ansdkmanornodedistregistry withoutdeny_missing_timestampis a config error naming the field; with it, either value loads; on any other registry type the field stays optional and the default is unchanged.Integration (
crates/web/tests/local_sdkman_registry.rsandlocal_nodedist_registry.rs, both new): a blocked version is absent from the listing and refused on exact resolution, in one test per kind; a blocked version is absent from the renderedversions/listin both layouts;versions/listforwards its query string and does not 400; anodedistrelease carries thepublished_atread fromindex.tab, and one absent from the index is denied whendeny_missing_timestamp = trueand served when it isfalse;SHASUMS256.txtis served byte-identical to the upstream fixture;sdkman_download_traversal_version_returns_400andnodedist_file_traversal_returns_400, per the mandatory pattern; the artifact is served from cache on the second request.Conformance (
crates/web/tests/protocol_conformance.rs): one entry per route of §6.5, each a literal request line taken fromsdkman-cli's andnvm.sh's sources with the file and line recorded insource. SDKMAN'scandidates/default/{c}againstcandidates/{c}/{plat}/versions/all, andnodedist'sindex.tabagainst{version}/{file}, are the two pairs that prove route ordering. These land asnot_yetin phase 1 and clear as the phases implement them — the ratchet only shrinks.Heavy (
tests/heavy/sdkman.shandtests/heavy/nvm.sh, both new): the load-bearing part of this plan, and a shipping requirement rather than a follow-up. Everything above this line is written from our own reading of two bash codebases. The two facts the whole enforcement design rests on — thatvalidateansweringinvalidmakessdk installstop, and that a version absent fromindex.tabmakesnvm install <exact-version>stop — are read, not observed.protocol_conformance.rsmarks that distinction in itssourcefield for exactly this reason, and RFC 0009 §12 is the record of what it costs to skip: seven ecosystems driven by their real clients found twelve bugs that every test in this repository passed, and writing the scripts found six more. A registry kind whose refusal path has never been seen refusing is not finished.Each suite follows the existing shape —
heavy_init, aconfig.<suite>.toml, the server behindhttp_tap.py,heavy_mark/heavy_wire/heavy_wire_afterasserting on the wire rather than on our own logs,heavy_done, andCOVERAGE=1running the server undercargo llvm-covso the client's path counts toward coverage. What each one drives:sdkman.shnvm.shinstall sdk install java <v>, JDK on disk and runnable (java -version)nvm install <v>, thennode -vlisting sdk list javarenders, andsdk listnames candidatesnvm ls-remotelists versionsthe refusal a blocked version: sdk installprints SDKMAN's "is not a valid … version" and installs nothinga blocked version: nvm install <exact>prints "Version … not found" and installs nothingthe filter heavy_wire_noton any upstream request for the blocked artifact — the block must stop the request, not merely the installsame rendered listing the blocked version is absent from sdk list java's table (§4.4's Java layout) and from a non-Java candidate's gridthe blocked version is absent from nvm ls-remoteintegrity the JDK unpacks — i.e. the relayed post-install hook ran and X-Sdkman-*survivednvmreports no checksum mismatch, i.e.SHASUMS256.txtcame through byte-exact (§7)cache a second install of the same version makes no upstream artifact request ( heavy_wire_after)same The "the block must stop the request" row is the one that cannot be written as a unit test: it asserts a negative about upstream traffic, and only a tap in front of a real client can see it.
Existing suites that must pass unchanged:
crates/web/tests/openapi_contract.rs(every new200declares a body),task docs:listing-coverage:checkanddocs:readme-coverage:check(the generated tables regenerate to what is committed),task docs:audience,docs:structureanddocs:links(both new registry pages in exactly one sidebar).
11. Decisions and open questions
Resolved
| # | Question | Decision |
|---|---|---|
| 1 | One SDKMAN registry or two, given two upstream hosts? | One, with broker_url beside cargo's index_url. Two would split RBAC, rules and blocking across a boundary the protocol does not have. |
| 2 | Local/hybrid mode? | Proxy-only, both. Neither has a publish protocol; hosting a private toolchain is a separate feature (§3). |
| 3 | Follow SDKMAN's broker 302 or relay it? | Follow it server-side, through ssrf::fetch_following_redirects. Relaying caches nothing and proxies only the policy. |
| 4 | Serve SDKMAN's hooks/* at all? | Yes, byte-exact. The CLI cannot install without them, and modifying them would make BatleHub a co-author of executed shell. |
| 5 | Filter SDKMAN's rendered versions/list? | Yes. An earlier draft left it Qualified on the grounds that it is a human view; that was wrong by this project's own standard — RFC 0006 §1 is that a blocked version is hidden from every listing, and a sdk list that advertises a JDK sdk install then refuses is precisely the failure it names. Both layouts turned out to be addressable by fixed-width field (§4.4), so what remained of the objection was cosmetic. Rows are removed and cells blanked; nothing is re-rendered (§6.2). |
| 6 | How does a blocked version fail? | SDKMAN: validate answers invalid. Node: the row is absent from index.tab. Both are the client's own not-found path (§7). |
| 7 | Forward upstream response headers? | X-Sdkman-* only, from the final response. Wholesale forwarding would carry the broker's Set-Cookie. |
| 8 | (was open) Normalise the platform out of SDKMAN's blocked-set lookup? | Yes — RegistryKind::blocking_package_name (§6.2). Per-platform blocked entries is the class of defect that passes every test and is wrong in production: an admin blocking a JDK means all eight platforms. The function is inert for the other twenty-one kinds, and a named function with a reason beats a special case at a call site. |
| 9 | (was open) Mirror the installers — get.sdkman.io, nvm's install.sh? | No. BatleHub proxies registries, not installers. Adding nvm is what settled it: two managers, two installers, two hosts with no registry protocol and no seam beyond a hard-coded URL. Telling operators to curl | bash from BatleHub is a different trust relationship than proxying a registry, and one exception would become the rule. The broker/download/sdkman/… endpoints are served (§6.5) because they are registry endpoints; fetching the bootstrap script once is documented as an air-gap prerequisite. |
| 10 | (was open) Cache TTL for the mutable pointers? | The registry's existing metadata TTL, for all of them. candidates/all, candidates/default/{c}, index.tab and index.json are the same class of document — a pointer that moves when upstream ships. A second TTL knob for four documents would be configuration nobody tunes correctly, and the failure it guards against is asymmetric: default moving late means sdk upgrade is quiet for an hour, while a too-short TTL puts every nvm ls-remote and every shell hook on the upstream path. |
| 11 | (was open) .sdkmanrc and .nvmrc as warming inputs? | In scope, as phase 6 (§6.9). One file was a nice-to-have; two of them, plus the mise.toml/mise.lock scanning batlehub registry suggest already does, is a pattern. Neither file names a platform, so warming reads a warm_platforms config list defaulting to the server's own — guessing all eight SDKMAN platforms would fetch 1.6 GB of JDK to satisfy a one-line file. |
| 12 | Name the Node kind nvm or nodedist? | nodedist. A registry kind names a protocol, not a client (goproxy is the precedent), and the same tree serves fnm, n, volta and mise. The console and the docs lead with "nvm" so it is findable by the name people know. |
| 13 | io.js — a second field or a second registry? | A second registry. A separate ecosystem with a nine-column index.tab, not a second facet of one service like SDKMAN's broker. |
| 14 | What does the age gate do when the upstream publishes no date? | The operator says, per registry, and config validation makes them. ReleaseAgeGateConfig::deny_missing_timestamp already exists and already answers this; on sdkman and nodedist it becomes mandatory rather than defaulted (§4.5, §6.7). An earlier draft hard-coded "allow" for sdkman — a security decision taken on the operator's behalf and left with no trace. nodedist additionally stops being a case of this most of the time: §6.4 populates published_at from index.tab, so the gate genuinely works for current Node releases. |
Still open
None. The RFC is ready for sign-off.
12. Implementation phases
Each phase leaves the tree green — builds, clippy clean, tests pass.
| Phase | Content |
|---|---|
| 1 | RegistryKind::Sdkman and ::Nodedist with their six exhaustive answers; broker_url in RegistryConfig, and the mandatory deny_missing_timestamp validation, with their tests; the conformance fixture entries as not_yet. Useful alone: the generated support tables gain two honest rows saying what is and is not covered. |
| 2 | NodeDistRegistryClient and its routes — index.tab, index.json, {version}/{file} — with edge validation, OpenAPI bodies, and published_at read from the cached index. nvm install works here. |
| 3 | Nodedist blocking: strip's arm, the index-json document kind, the header-row and LTS-alias tests. Node policy is code-complete at the end of this phase — and unproven. |
| 4 | tests/heavy/nvm.sh with its config.nvm.toml, task test:nvm-heavy, an entry in task test:heavy and a row in the heavy-client CI matrix (§6.10, §10). nodedist ships at the end of this phase, and not before. |
| 5 | SdkmanRegistryClient — resolve_metadata, fetch_artifact through the SSRF-checked redirect chain, fetch_version_document, list_versions — with its mockito suite. No routes yet. |
| 6 | SDKMAN handlers and routes, builders.rs wiring, then Sdkman blocking: strip's three arms including both rendered-table layouts, the sdkman-default and versions-list document kinds, blocking_package_name, and validate refusing a blocked version. |
| 7 | tests/heavy/sdkman.sh and the rest of §6.10 — the zip/tar prerequisites, the redirected $SDKMAN_DIR, the second CI matrix row. sdkman ships at the end of this phase, and not before. |
| 8 | .nvmrc/.sdkmanrc in batlehub registry suggest, and warm_platforms warming from them. |
| 9 | ui setup snippets, both registry pages with their generated tables, the egress host list, and the generic-migration notes. |
The heavy suites are phases 4 and 7 rather than one phase at the end, and that placement is the point: each manager is verified when it is finished, by the only oracle that can fail on the bugs this RFC is most likely to contain. A plan that defers both to phase 9 is a plan that discovers in week four that the refusal path never refused.