Design history
For someone asking why it is like this.
Every substantial change to BatleHub — a user-facing surface, a cross-crate refactor, a security-relevant default, anything expensive to undo — is argued out in an RFC before it is built. These are those documents, published unedited.
They are candid about defects that were live in shipped versions, and that is deliberate. For a self-hosted infrastructure product, the useful thing to know is not that a project has never been wrong; it is what it does when it finds out.
Every page here opens with a status banner, and the status is not uniform. The banner is generated from each document's own Status field rather than written on the page, so it cannot drift from the table it is quoting — an RFC that describes a proposal, published under a label saying it shipped, would be a claim about the product that is not true. The count and the three tables below come from those same rows, and from each document's own Short and Settles. The tables are shelves — what is still being argued, what is agreed and not yet built, and what is history — and a document changes shelf by having its Status row edited, never by being moved: the files stay in one directory because the links to them do.
Of the forty below, twenty-nine are implemented, ten are drafts and one is superseded.
In the works — ten proposals still being written or reviewed.
| RFC | Status | What it settles |
|---|---|---|
| 0018-bis — Worker under load | Draft | How one worker turns a job into concurrent, resumable, content-cached scanner invocations, and how a fleet of them drains a backlog |
| 0022 — Sandbox runtimes | Draft | Where a scanner's process runs — bwrap, an OCI container, a Kubernetes pod — behind one port, and what every runtime must prove |
| 0023 — che-code fork | Draft | Carrying BatleHub's editor changes on a fork of che-code as a rebased series over a mirror branch that only ever fast-forwards, and the CI that keeps it that way |
| 0025 — Generic publish | Draft | Publishing arbitrary files under {name}/{version}/{filename} to a generic registry — the equivalent of GitLab's generic packages — on the shared local-registry machinery |
| 0027 — Homebrew | Draft | Proxying brew's signed JSON API byte-exact and its GitHub Packages bottles as a typed registry, with the download gate as the only enforcement point and the mirror fallback that would turn a refusal into a bypass |
| 0029 — Helm charts | Draft | The classic index.yaml chart repository as a registry kind: URL rewriting in the served index, chart archives as artifacts, and index regeneration for local publishing |
| 0030 — JSR | Draft | The jsr.io registry as a registry kind: meta.json listings, module files as artifacts, and the npm-compatibility endpoint served by the npm adapter as a second upstream |
| 0032 — NuGet v2 | Draft | Adding the v2 OData surface to the nuget kind so choco and PowerShellGet can read and push through it, rendered from the same data as v3 |
| 0033 — Authenticated JetBrains access | Draft | Giving IntelliJ a way to send a credential to a jetbrains-marketplace registry, which its plugin-host setting has no field for |
| 0034 — full-ICU node | Draft | Why the fork's node carries the full ICU data, how it is shipped, and what would let the commit be dropped |
Ready to build — none accepted and waiting to be built.
Settled — thirty implemented, rejected or superseded: history rather than proposals.
| RFC | Status | What it settles |
|---|---|---|
| 0001 — Subdomain routing | Implemented | Reaching a registry by host name instead of by path |
| 0002 — Vulnerability flags | Implemented | What BatleHub knows about a package's CVEs, and who it tells |
| 0003 — UI rework | Implemented | The design system the console and this site both wear |
| 0004 — Admin composition | Implemented | The API the console was missing |
| 0004-bis — What 0004 left | Implemented | The parts 0004 did not finish, and why they were not visible |
| 0005 — One documentation tree | Implemented | Merging the two documentation trees, and putting the design system on the result |
| 0005-bis — Two readers, one home each | Implemented | Splitting the guide by audience, giving every instruction one home, cutting each page down to one subject, and turning the showcase back into an introduction |
| 0006 — A block every ecosystem can see | Implemented | Hiding blocked versions from every registry's listings, not just npm's, and stating which protocols cannot be filtered |
| 0007 — The README, per version | Implemented | Storing each version's own README, rendering it safely on the server, and making the package page answer — versions and documentation — for packages this instance holds nothing of |
| 0007-bis — The three 0007 deferred | Implemented | Rendering a README's images without telling their host who is reading, searching what a package says rather than what it is called, and asking for a version the page has told you exists |
| 0008 — mise in an air-gapped estate | Implemented | Making mise install work with no route off the site: mise.lock as the bill of materials, a server that will not dial out, and verification moved to the connected side |
| 0008-bis — Listings across the gap | Implemented | What a disconnected instance answers when a client resolves through a listing it does not hold: a bundle that carries the documents, or listings synthesised from what the instance holds |
| 0009 — Every endpoint the client actually calls | Implemented | Serving the paths each package manager really requests, and two mechanisms so the next invented endpoint fails the build |
| 0010 — The toolchain layer | Implemented | 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 |
| 0011 — Authenticated OpenVSX access | Implemented | Giving an editor that has no credential hook a way to send one: a contract file that may point at a secret rather than hold it, the pod's own Kubernetes identity, a loopback proxy for editors we do not build, and a sign-in entry in the Extensions view instead of a blank one |
| 0011-bis — Namespace-scoped visibility | Superseded by 0015 | Making a team's packages visible to that team and to the groups it grants read to: a namespace separator per ecosystem, reader groups with a per-package override, and groups on a PAT |
| 0012 — Signed URLs for the credential-less request | Implemented | Letting a client that sends no credential — Terraform's provider archive — download from a registry that is closed to everyone else |
| 0013 — What the console owes a reader | Implemented | Eleven things the package pages knew and could not act on — a search that survives a click, a version that is a link, a README you can read as source, the hosts an image may come from, and two lists that page on the operator's numbers rather than on a literal |
| 0014 — Upstream disappearance | Implemented | How a package that vanished upstream is detected, held, and reported to the admin |
| 0015 — Grants on the hierarchy | Implemented | How a request is authorized: one permission vocabulary with write verbs, grants that attach to a registry/namespace/package hierarchy, and namespaces that carry visibility, immutability and gate policy for everything beneath them |
| 0016 — Retention and tombstones | Implemented | What happens to a locally published version over time: reclaiming what nobody is using, and a coordinate that can never be occupied twice |
| 0017 — Grants editor | Implemented | Who writes a package- or version-tier grant, and what §4.4 filters once they exist |
| 0018 — Supply-chain quarantine and verdicts | Implemented | How an artifact is scanned before it is served, who may see why it is held, and how the SOC can intervene |
| 0019 — Forge registries: refs, releases, raw | Implemented | What a "version" is for GitHub/GitLab/Forgejo, how mutable refs are served, what raw content is allowed, and what metadata these registries hand to RFC 0018 |
| 0020 — Signed VSIX assets | Implemented | Making what a BatleHub VSX registry serves installable from a current editor's Extensions view: a signature asset per version, the registry's own Ed25519 key in Open VSX's format, the upstream's signature relayed in proxy mode, and the one setting a stock build still needs |
| 0021 — releases into registries | Implemented | How an artifact published as a forge release asset reaches the protocol registry its clients read |
| 0024 — rustup dist | Implemented | Proxying static.rust-lang.org as a typed registry, so a Rust toolchain can be blocked rather than merely cached: the channel manifest as the filtered listing, its checksum recomputed, and no signature replaced |
| 0026 — Alpine apk | Implemented | Proxying Alpine's APKINDEX and .apk tree with the index relayed byte-exact and the block enforced at the package, and local publishing with an RSA-signed index that every shipping apk trusts — signed without the banned crate |
| 0028 — Nix binary cache | Implemented | The substituter protocol as a registry kind: narinfo listings, NARs as artifacts, Ed25519 narinfo signing in local mode, and blocking by store path |
| 0031 — Ansible Galaxy | Implemented | The collections API v3 as a registry kind: the versions list as the chokepoint, download_url rewritten to this instance, and collection publish with its import-task poll in local mode |
0007 and 0008 were deferred behind 0009, and the reason was worth stating plainly: 0009 found six protocol defects that had all shipped green, and the common cause was tests written from our implementation rather than from what the client sends. Building further on that foundation before fixing it would put more surface on a floor we know does not hold. 0008's air-gapped case also depended directly on 0009's checksum-database and upstream-caching work.
0007-bis picks up the three open questions 0007 recommended a decision on and did not take — the image proxy, prose search, and a way to fetch a version the page has just told you exists. All three are the same shape: the page knows something it cannot act on. It has since landed, and it is the one in this set whose open questions were settled by measuring rather than by argument: two of the five were resolved against the recommendation it was drafted with, because 67 % of the images in real READMEs turned out to be SVG and because simple full-text search cannot find a README that says retrying when a reader types retry.
0009 has since landed, and with it that dependency: the Go checksum database is proxied and cached, so a second build resolves with no route off the site, and Terraform's checksum files no longer send an otherwise air-gapped provider install to the internet at its last step. 0007 has since landed on that foundation — reusing 0009's three rungs for its discovery read rather than inventing a second cache policy — and 0008 is waiting on scheduling rather than on 0009; see its own §2.7 for what that leaves.
0010 is the first to argue with what the other RFCs assumed rather than with what they built. 0006 made a block visible in every ecosystem's listings, and 0009 made every endpoint the one the client really calls — for the dependency layer. The layer underneath it, the JDK and the Node runtime a project builds on, is a row in the console that nothing enforces. Its two halves fail differently, which is why they are argued together: SDKMAN has never reached a BatleHub instance at all, while nodejs.org/dist has been mirrored as a generic registry all along — caching every byte and enforcing nothing, because a path-addressed mirror has no version to block. The cache working is what made that one invisible. It is also the rest of the estate 0008 described: mise covers some toolchains, SDKMAN and nvm cover the others, and an air gap has to hold for all of them.
0017 makes the same move against 0015, and it is the sharpest case of it here because 0015 is marked Implemented. It built the package and version tiers of the grant hierarchy and left both without a writer: chain::stored_nodes reads a version tier on every authorize that nothing in the tree can populate, and filter_listing/package_visibility were written for its own §4.4 rule 2 and have never been called. Neither is a bug today — with no version row every version answers the same, so the package-tier decision is the whole answer. Both become one the moment a version row can be written, which is why 0017 refuses to ship its writer and its filter in separate releases.
0018 and 0019 arrive as a pair, and each argues with something an earlier document assumed. 0002 argued the vulnerability model — what an external source asserts, how hard the proxy reacts — on the assumption that there is a finding to react to. 0018 is the argument that usually there is not yet: CveGateRule reads what VulnerabilityRepository already holds and returns Allow when the answer is nothing, so the gate an operator configured with block = true permits precisely the request it was bought for, the first pull of a version nobody has scanned. That is a defect in a shipped default rather than a missing capability, and it is why 0018 replaces the chain of independent gates with one persisted verdict instead of adding a fifth gate beside them. 0019 is the half 0018 cannot state for itself: on a git forge there is no version to hold, because main today and main tomorrow are one coordinate over different bytes. It resolves every ref to a commit before anything is fetched and hands 0018 the date, the publisher and the provenance its age and trust gates need. That is also the piece 0009 left: 0009 made every endpoint the one the client really calls, for protocols that have a version — and the three forges do not.
They read in order: each one argues with the state the previous one left.
What is next
The table above says where each document stands. This says which order the unbuilt ones are being taken in, and — more usefully to argue with — on what criterion. On 2026-09-02 all seven unbuilt documents were re-read against the tree and corrected in place, and the eleven-item build order that came out of it was completed on 2026-09-04. The plan page carries what is left of each of the seven and the order it is taken in, which supersedes the list below where the two disagree. The list is kept because its argument for the criterion still holds. It then does the same for the roadmap, which is the canonical list of what is unbuilt and is longer than this one: an RFC is written for work that is expensive to undo, and plenty of remaining work is not.
The criterion is that closing a hole that is open right now beats adding a capability that is missing. Both matter, and the second is what a release gets announced on. But a missing capability is a thing an operator does not have; an open hole is a thing they have already deployed, usually because this project's own documentation told them to configure it that way. Ordering by that turns out to group the remaining work more sharply than ordering by size or by theme would.
The list below was last rewritten when 0012, 0015 and 0016 were still proposals; all three landed, and what they left behind was two items rather than one. It is rewritten again here for 0018 and 0019, which enter above everything except 0010.
Both are now closed, which is why this list is shorter than the criterion above would suggest. They are recorded rather than deleted, because the shape of them is the argument for the criterion: they were the two smallest items here and the two most worth doing first.
Groups on a PAT — the one third of 0011-bis that 0015 absorbed by requirement and did not build.
UserTokencarried no groups andUserTokenAuthProviderresolved every token togroups: vec![], so agroup:subject in the shipped grant hierarchy matched no personal access token, every piece of token-driven automation read as an authenticated user with no groups, andpat_is_within_owner— the invariant check 0015 wrote for it — compared against a set that could only be empty.user_tokens.groups(migration 046) now holds a snapshot taken at creation and capped to the creator's own.0017 — the grants editor — the other half, and the half that had a document. The two deepest tiers of the hierarchy were built, read on every
authorize, and unwritable: the onlyput_grantcaller was the ownership projection, writing package rows with exactly three verbs. It sat high on the criterion for a reason that was not its size —filter_listingandpackage_visibilityhad no caller, which was correct only while no version row could exist, so the first one ever written would have turned two idle functions into two bypassed ones with no other code change and no error. The writer and the filter shipped in one change, and rule 3's cache key with them.Building it also settled the thing 0015 left that is easiest to miss, because the value involved was already shipped:
Visibility::Privateresolves through a package-tierreleases:readgrant, and the ownership projection — the only writer — projects publish and the twoowners:verbs, not read. Soprivatemeant admin-only, which is why the console offered three visibility values and not four. There is now a writer for that grant.
- 0010 — the toolchain layer. Accepted, no open questions, and top of the roadmap's own list.
nodejs.org/disthas been mirrored as agenericregistry all along, andgeneric_getaddresses the whole tree as one synthetic package — every byte cached, no version to block, nothing in explore, no per-version statistics. A working cache is what has kept that invisible, which is what puts it above the larger document below it: this is a hole that looks closed, not a capability that is missing. SDKMAN's broker compounds it, answering302to four different third-party CDNs, so an estate that has closed egress everywhere else finds out when it provisions a build agent.nodedistships at phase 4 andsdkmanat phase 7, each behind its own heavy suite and not before. - 0018 phases 1–2 — the quarantine. The sharpest instance of this page's criterion in the set, and the reason it sits above the two larger documents below it. The hole is not that BatleHub cannot scan; it is that the gate which says it does fails open on the only request that matters.
cve_gate.rsreturnsAllowwhen the OSV batch has not run or has errored — deliberately, and commented as such, since a database outage should not brick the proxy — so the first consumer of a new version is always unprotected. An operator who read[registries.cve_gate] block = truein our own documentation has deployed a control that is honest about outages and silent about newness, which is the exact shape this page ordered by. Phase 1 closes it alone: the verdict is persisted,SCAN_PENDINGis a deny rather than an absence, and age plus OSV is a working profile needing no scanner toolchain in the image. It is also the phase that waits on neither preliminary: the rejection survey blocks phase 2 and the postmortem input spike blocks phase 3, so this position is not a bet on either landing. The survey is the interesting one — 0018 asks every client something new, to be refused and then to recover from the refusal, and nine heavy suites cover ten of twenty-one kinds. Cargo, Go, Maven and the path-proxy family have no real-client cover at all today, which is worth having whatever happens to the quarantine. - 0019 phases 1 and 3. Phase 1 because 0018's worker cannot touch a forge without it: the shared rate-limit budget is the only thing keeping the proxy and the worker off each other's token, and the derived release → tag → commit date is what stops the age gate skipping every forge coordinate for want of a
published_at. Phase 3 on its own account —raw/{path}serves any file of any repository at any ref today, with no size ceiling, no allowlist and no way to require a pinned ref, which iscurl … | shthrough our own origin. That is item 1's shape again, a path that works, caches and enforces nothing, and it is a hole for the same reason. Phase 4, GitLab to parity, waits behind the rest. - 0014 phases 1–3 and 5 — detection and the eviction hold. Phase 5 is the only work in the set that prevents data loss rather than closing a policy gap:
EvictionService::run_ttlselects oncached_atand knows nothing about upstream, so an artifact upstream has deleted ages out on exactly the schedule of one it still has — the cache discards the last copy in the estate precisely because nothing is refreshing it. Phases 4 and 6–9 can wait; phases 1–3 cannot, because there is nothing to exempt from eviction until something confirms a disappearance. - 0002 — vulnerability flags and exposure. The largest build here at eleven phases, which is the argument for starting it early rather than for deferring it. It is the one whose absence is felt at the worst moment: when a compromise is announced, "who already pulled this" is the first question asked and today nothing can answer it. Phase 1 — version schemes — is self-contained, reusable well beyond flags, and the phase most likely to surface a surprise, so it is worth starting alongside 0010 rather than after it.
- 0008 — the air gap. After 0010, for the reason stated above. Its four open questions are all decidable today and none needs new measurement; closing them before it is scheduled is cheaper than closing them during.
- 0011 — authenticated OpenVSX. Last: the most open questions, the widest surface — a CLI, a contract file, an editor patch, a loopback proxy and an extension — and the only one whose work extends outside this repository. Its phases 1 and 2 are the exception and do not belong at the back with the rest: server-side Bearer and Kubernetes credential work, testable with
curland a mounted token, useful with no editor involved.
The roadmap's remaining entries, against that list
ROADMAP.md carries twenty unchecked entries against these seven items. They are not a second plan. Nine of them are the list above under a different name, and belong to whichever item owns them:
| Roadmap entry | Where it sits |
|---|---|
Node distributions (nodedist) · JVM toolchains (SDKMAN) | Item 1. They are RFC 0010, split into its two halves |
Sigstore / npm provenance · cargo verify-project · YARA rules · external antivirus | Item 2. RFC 0018 makes all four ArtifactScanner implementations rather than four rules beside cve_gate — see below |
| Third-party vulnerability flags · Vulnerability exposure reporting | Item 5. They are RFC 0002's two capabilities, and it is one RFC because they are one model. 0018 answers the narrow half of the second — who pulled this coordinate, exported from access_log — and does not replace the estate-wide join, which needs 0002's version schemes |
| Instance-to-instance transfer for air-gapped estates | Item 6, one layer up. RFC 0008 phase 4 builds a signed bundle with an import path and a defined interaction with dedup; this is the same format carrying approved artifacts rather than a toolchain plan, and it should reuse it rather than invent a second one |
One row of that table is this page correcting itself. Until 0018 the four verification rules sat in the list below as work needing no RFC — "each is a rule beside cve_gate and license_gate, and RFC 0002 phase 5 is about to touch that evaluation path anyway". 0018 §8 rejects exactly that: a gate per scanner re-derives "has this been scanned yet" four separate times and fails open four separate times, which is the single defect the whole document exists to remove. They are scanners behind one port now. The entry that read as the cheapest work on this page turned out to be four instances of the most expensive one.
One entry reads as remaining and is not. Web console redesign names three things as unbuilt — /packages and /explore duplicating the catalogue, two detail URL shapes, and "no localisation exists today, every string is hardcoded English". RFC 0003 landed all nine of its phases: /explore is a redirect to /packages in ui/src/router/index.ts, and ui/src/locales/ holds en.json and fr.json. The entry is stale, not open.
The remaining ten have no RFC, and most should not need one — they are the shapes this codebase already has, one more time:
- Two adapters — the generic mirror in
local/hybridmode, and Helm charts.docs/contributing/adding-a-registry.mdis the whole design. - Three external-source feeds — dynamic blocking rules, dynamic allowlists, IP reputation. One shape, three entries: a signed remote source merged into local policy, with the signature checked before it applies. That one is worth an RFC covering all three, because three ad-hoc fetchers each deciding what to trust is the defect RFC 0015 opens by describing.
- Two of RFC 0004-bis's own §13 — storage-backend migration (§13.3) and seeding from an incumbent (§13.4); the third, the bundle (§13.2), is the table row above. They have argued homes already; they are waiting on scheduling.
- Per-host TLS and broader fuzz targets — the first needs a reason before it needs a design, the second is standing hygiene.
- Clearing the upstream-detail absence cache on reload — one call on the reload path, and a test that a reload forgets an absence.
Two of the ten meet the criterion this page opens with, and neither is obvious from the list. The first is storage-backend migration. An operator who edits [storage] from filesystem to S3 strands every artifact already written — the bytes stay reachable only while the old backend is still configured, and nothing says so. The full resumable, dedup-aware migrate operation is the fix and is not small. A config warning is, and the machinery is already there: AppConfig::warnings() with a stable code, the same mechanism that raises license-gate.sbom-disabled. Worth taking ahead of its own entry.
The second is the absence cache. UpstreamDetailCoordinator remembers which coordinates an upstream answered 404 for, and clear_absent exists to forget them; no reload path has ever called it. So a reload that adds an upstream, fixes its base URL or repairs its credentials leaves every coordinate that failed under the old configuration still remembered as missing, and the operator's fix appears not to work until the process restarts — a hole whose whole symptom is that hot reload, the feature, looks broken.
Three things do not follow from that list and are worth stating separately.
0016 left a piece unbuilt and it is now unblocked. Retention's namespace and package tiers were waiting on the namespace blocks and the policy table, both of which shipped with 0015. NamespaceConfig refuses a retention key outright rather than ignoring one, so the gap is honest — but it is a gap, and it is small.
Two orderings above are constraints rather than preferences. 0010 precedes 0008 for the toolchain-coverage reason already argued further up this page. And 0002 precedes the open question 0014 raises about blocking a freed package name against re-registration, because closing that needs a name-level block row — a change to the block model 0002 owns, not one 0014 should invent beside it.
The decision owed before 0014 phase 2 has been taken. Its fourth open question — what outage_ratio means on a registry with three cached packages, where one missing package is 33 % and voids every sweep — got sharper when on_confirmed = "block" entered the document. 0014 §13 now answers it: min_probed = 10, and below the floor the block arm is refused outright, so the one place the population argument is weak is not the one place the destructive arm fires.
What would reorder the list is a different answer to the criterion in its second paragraph. Read as what should the next release be able to do that it cannot today, 0002 is the headline and 0010 is plumbing: they swap, and 0008 follows 0010 to the front as the pair it has always been.
0018 is the one item that does not move under either reading, and that is worth noticing rather than taking as proof it is right. An item that wins on every criterion is usually one whose cost has been argued less hard than its value, and the cost here is five phases, a second process role, a bwrap sandbox and six scanner adapters — against a phase 1 that is age plus the OSV data we already hold. If any part of this list deserves to be split harder than it has been, it is that one.
The RFC template itself is not published — it is a form you copy, not a document you read. It lives in the repository at docs/internal/0000-rfc-template.md.