Skip to content

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.

RFCStatusWhat it settles
0018-bis — Worker under loadDraftHow one worker turns a job into concurrent, resumable, content-cached scanner invocations, and how a fleet of them drains a backlog
0022 — Sandbox runtimesDraftWhere a scanner's process runs — bwrap, an OCI container, a Kubernetes pod — behind one port, and what every runtime must prove
0023 — che-code forkDraftCarrying 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 publishDraftPublishing arbitrary files under {name}/{version}/{filename} to a generic registry — the equivalent of GitLab's generic packages — on the shared local-registry machinery
0027 — HomebrewDraftProxying 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 chartsDraftThe 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 — JSRDraftThe 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 v2DraftAdding 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 accessDraftGiving IntelliJ a way to send a credential to a jetbrains-marketplace registry, which its plugin-host setting has no field for
0034 — full-ICU nodeDraftWhy 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.

RFCStatusWhat it settles
0001 — Subdomain routingImplementedReaching a registry by host name instead of by path
0002 — Vulnerability flagsImplementedWhat BatleHub knows about a package's CVEs, and who it tells
0003 — UI reworkImplementedThe design system the console and this site both wear
0004 — Admin compositionImplementedThe API the console was missing
0004-bis — What 0004 leftImplementedThe parts 0004 did not finish, and why they were not visible
0005 — One documentation treeImplementedMerging the two documentation trees, and putting the design system on the result
0005-bis — Two readers, one home eachImplementedSplitting 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 seeImplementedHiding blocked versions from every registry's listings, not just npm's, and stating which protocols cannot be filtered
0007 — The README, per versionImplementedStoring 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 deferredImplementedRendering 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 estateImplementedMaking 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 gapImplementedWhat 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 callsImplementedServing the paths each package manager really requests, and two mechanisms so the next invented endpoint fails the build
0010 — The toolchain layerImplementedProxying 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 accessImplementedGiving 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 visibilitySuperseded by 0015Making 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 requestImplementedLetting 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 readerImplementedEleven 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 disappearanceImplementedHow a package that vanished upstream is detected, held, and reported to the admin
0015 — Grants on the hierarchyImplementedHow 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 tombstonesImplementedWhat happens to a locally published version over time: reclaiming what nobody is using, and a coordinate that can never be occupied twice
0017 — Grants editorImplementedWho writes a package- or version-tier grant, and what §4.4 filters once they exist
0018 — Supply-chain quarantine and verdictsImplementedHow 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, rawImplementedWhat 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 assetsImplementedMaking 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 registriesImplementedHow an artifact published as a forge release asset reaches the protocol registry its clients read
0024 — rustup distImplementedProxying 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 apkImplementedProxying 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 cacheImplementedThe substituter protocol as a registry kind: narinfo listings, NARs as artifacts, Ed25519 narinfo signing in local mode, and blocking by store path
0031 — Ansible GalaxyImplementedThe 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. UserToken carried no groups and UserTokenAuthProvider resolved every token to groups: vec![], so a group: 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, and pat_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 only put_grant caller 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_listing and package_visibility had 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::Private resolves through a package-tier releases:read grant, and the ownership projection — the only writer — projects publish and the two owners: verbs, not read. So private meant admin-only, which is why the console offered three visibility values and not four. There is now a writer for that grant.

  1. 0010 — the toolchain layer. Accepted, no open questions, and top of the roadmap's own list. nodejs.org/dist has been mirrored as a generic registry all along, and generic_get addresses 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, answering 302 to four different third-party CDNs, so an estate that has closed egress everywhere else finds out when it provisions a build agent. nodedist ships at phase 4 and sdkman at phase 7, each behind its own heavy suite and not before.
  2. 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.rs returns Allow when 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 = true in 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_PENDING is 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.
  3. 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 is curl … | sh through 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.
  4. 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_ttl selects on cached_at and 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.
  5. 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.
  6. 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.
  7. 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 curl and 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 entryWhere 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 antivirusItem 2. RFC 0018 makes all four ArtifactScanner implementations rather than four rules beside cve_gate — see below
Third-party vulnerability flags · Vulnerability exposure reportingItem 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 estatesItem 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/hybrid mode, and Helm charts. docs/contributing/adding-a-registry.md is 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.

Released under the Apache 2.0 License. Made with ❤️ and too much ☕.