...we can't have... #674

Merged
Griefed merged 112 commits from develop into alpha 2026-09-14 19:28:51 +02:00
Owner
No description provided.
The gate maps a descriptor path to a loader through a flat, version-blind
constant. Two eras make that wrong, and both are measured against the live
Modrinth API on 2026-09-10 by opening the actual jars:

NeoForge renamed its descriptor at Minecraft 1.20.5, not at 1.20.2.
`architectury-api` and `jei` both ship META-INF/mods.toml at 1.20.2/1.20.4 and
META-INF/neoforge.mods.toml at 1.20.6+. All 13 refused rows on the live grinder
sit at 1.20.2 or 1.20.4, and their filenames say `neoforge`
(botarium-neoforge-1.20.4, decorative_blocks-NeoForge-1.20.4,
emitrades-neoforge-...+mc1.20.4). That is NeoForge's descriptor for the range,
not an author mis-tick.

Forge before 1.13 declares itself in `mcmod.info`, which the gate cannot see.
`SkyHanni-6.0.0-mc1.8.9.jar` carries mcmod.info plus a fabric.mod.json and
neither toml, so the *visible* Fabric descriptor flipped it from the gate's
fail-open default to a refusal.

Two existing expectations are corrected here, because both encoded the same
conflation: they read NeoForge's **package** rename (1.20.2, which is what ends
binary jar parity and what LoaderCompatibility is about) as its **descriptor**
rename (1.20.5, which is what this gate reads).

- `aNeoForgeBootAboveMinecraft1201StillRefusesAForgeJar` asserted the refusal at
  1.20.2 and 1.20.4; renamed to `...RefusesAForgeJarOnceTheDescriptorsDiverge`
  and narrowed to 1.20.6+.
- `aForgeJarIsRefusedForANeoForgeBoot` asked at 1.20.4 and now asks at 1.21.1,
  keeping the DamageVignette shape pinned where the descriptor can still catch
  it. Its doc records what the gate gives up in the 1.20.2-1.20.4 band and why
  that is affordable: a genuinely Forge-only jar ticked NeoForge now reaches a
  container and dies on `Missing language javafml version [46,)`, which
  `runtimeMismatchMarkers` already scores INCONCLUSIVE rather than as sideness.
  One wasted boot in the false case buys a real verdict in the thirteen true
  ones, and refusing on ambiguity is what this object's fail-toward-accept
  design already forbids.

-api had it right all along -- `NEOFORGE_TOML_MINIMUM_MINECRAFT = "1.20.5"` --
and serverpackcreator-api/CLAUDE.md states it in words; this object simply held
a second, version-blind copy.

Red: 3 guards. The two new ones fail on the missing era knowledge;
`aLegacyForgeJarIsStillRefusedForQuilt` fails because a mcmod.info-only jar
currently declares nothing at all and is accepted for every loader.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Closes the 13 NeoForge rows and the legacy-Forge era. `LoaderDescriptors` in
-api is now the one home for "which descriptor evidences loader L on Minecraft
V", `ModScanner.scannerFor` asks it for the era boundaries it used to own
privately, and `JarSelfDeclaration.declaredLoaders` takes a Minecraft version
and asks the same object instead of consulting its own flat map.

Consequences, each measured:
- Before 1.20.5 a lone META-INF/mods.toml names Forge AND NeoForge, because both
  read it there and its presence distinguishes neither. That is what unblocks
  botarium, decorative-blocks, agricraft, blue-skies, do-api, emitrades,
  faster-random, majrusz-library, rebornstorage, refined-storage-addons and
  you-shall-not-spawn.
- From 1.20.5 mods.toml names Forge alone and neoforge.mods.toml names NeoForge,
  so `bellsandwhistles` (neoforge.mods.toml, ticked Forge) is still refused.
- Before 1.13, mcmod.info and META-INF/fml_cache_annotation.json name Forge.
  Recognition only -- no mcmod.info *scanner*: it carries no sideness field, so
  a legacy jar still reads descriptorRead=false and is kept.

Two modelling points worth keeping. `descriptorsFor` answers the **gate's**
question (what evidences a jar was built for a loader), not the scanner's (which
one file to parse) -- which is why NeoForge's set carries neoforge.mods.toml at
every version, so a NeoForge-only jar cannot pass as a Forge mod on 1.20.1. And
`LegacyFabric`'s set is deliberately empty: it reads Fabric's descriptor, so no
jar can carry evidence against it, which preserves exactly the exemption the old
`descriptorLoaders.values` gave it by omission.

Also fixed as a side effect of the consolidation: `neoForgeUsesNeoToml` had no
`runCatching` where `forgeUsesToml` did, so `scannerFor("NeoForge", "26")` threw
an ArrayIndexOutOfBoundsException out of ModScanner -- and "26" is a legitimate
shape under the newer scheme. Both eras now share one `atLeast` helper, so
neither can lose the fallback the other has. Pinned separately.

`LoaderCompatibility`'s 1.20.1 Forge/NeoForge parity rule is untouched: a jar
loading unchanged is a different claim from which file a loader reads, and
merging the two dates is what made this gate wrong.

clientside 528, api 412 (1 skipped), 0 failures.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`forgeUsesToml` wrapped its comparison in `runCatching { … }.getOrDefault(true)`
and `neoForgeUsesNeoToml` did not, while `SemanticVersionComparator` indexes
`versionNumbers[1]` and calls `toInt()` unguarded. So `scannerFor("NeoForge",
"26")` threw ArrayIndexOutOfBoundsException, and `""` / `"1.x.y"` threw
NumberFormatException, straight out of `ModScanner` -- while the Forge arm
answered. `"26"` is not malformed: it is a legitimate shape under the newer
`YY.x[.y]` scheme this codebase supports.

The blast radius was the published module rather than only the grinder:
`ModListCompiler` does not wrap its `scannerFor` call, so this aborted a
generation. `anUnparseableMinecraftVersionFallsBackToTheModernForgeScanner`
covered only Forge, so nothing noticed.

Already fixed in "fix(clientside): read a jar's loader at the descriptor era it
was built in", where both eras moved onto one `atLeast` helper -- so this lands
green and is mutation-verified instead: dropping the `runCatching` from that
helper fails 2 of the 7 guards here.

Worth recording: the first draft of this guard failed against *correct* code.
`assertDoesNotThrow({ … }, message)` resolves to JUnit's `Executable` overload
in Kotlin and returns `kotlin.Unit`, so the assertion compared a scanner against
Unit. The explicit type argument picks the value-returning overload, and the
comment says so.

api 412 tests, 0 failures (1 skipped).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Griefed's report: the field was meant to carry the full filename as the platform
publishes it, and it most often carries a pattern instead.

Measured over 400 live rows from grinder.serverpackcreator.de: **not one** value
ends in `.jar`, and **270 (67%)** are byte-identical to `NamePattern` -- so the
column titled "Filename" is redundant two thirds of the time and has never once
answered the question it is named for. It holds
`FilenameStemDeriver.deriveStem` run over the sampled file, i.e. `iris-fabric-`
where `iris-fabric-1.7.5+mc1.21.1.jar` was wanted.

`LoaderVerdict.sampleFile` has held the right value all along -- it is
`sample?.fileName`, documented as "the file-name the jar-scan ran against". It is
`Grinder.grind`'s hand-written 18-field copy that never carried it, which is the
same mapping claude-docs/ANALYSIS-AUDIT.md flagged on 2026-09-05 as asserted only
five fields deep. Another field lost in the same place.

Asked through `VerdictField.FILENAME.text(...)` rather than through the field, so
the guard needs no name for it: a rename cannot go red, only fail to compile,
while the column's rendered text can. Red with an empty cell.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Griefed's report, closed. The column titled "Filename" carried a derived stem,
not a filename: `FilenameStemDeriver.deriveStem` was run over the sampled file,
so it read `iris-fabric-` where `iris-fabric-1.7.5+mc1.21.1.jar` was wanted.
Measured over 400 live rows -- not one value ended in `.jar`, and 270 (67%) were
byte-identical to `NamePattern`, so the column was redundant two thirds of the
time and never once answered the question it is named for.

`LoaderVerdict.sampleFile` already held the right value ("the file-name the
jar-scan ran against"). `Grinder.grind`'s hand-written 18-field copy simply never
carried it -- the same mapping claude-docs/ANALYSIS-AUDIT.md flagged on
2026-09-05 as asserted only five fields deep. `GrindVerdict.filenamePattern` is
now `fileName` and is fed from `sampleFile`; the derived stem is gone from
`LoaderVerdict` entirely, since nothing rendered it afterwards and a correct unit
no caller reaches is a defect this repository keeps rediscovering.

The real name serves the stem's documented purpose strictly better: it keeps the
loader token a rename history erases *and* the version that identifies the build.

`RecordedVerdictMappingTest` is the guard that should have caught this. It puts a
distinct sentinel in "every field the mapping copies" -- and sentinelled the
derived stem, which round-tripped fine, while `sampleFile` stayed `null` in the
fixture. It now sentinels `sampleFile`.

`theFilenamePatternIsNotWhatGetsPublished` becomes
`theSampledFilenameIsNotWhatGetsPublished`, and matters more than before:
publishing a stem would have stopped excluding the builds it misses, while
publishing a full filename would narrow a user's fallback list to one build of one
loader. /as-properties still serves `suggestedEntry` alone.

Feed schema: `filenamePattern` -> `fileName`. The plugin reads and renames with
it; its table never displayed the field and its exclusion logic never used it, so
an older deployed plugin shows that column empty rather than breaking. The query
parameter stays `filename`, so bookmarks and filters keep working.

The README's column list omitted `Filename` outright and now names it, with the
two patterns' difference spelled out.

clientside 528, grinder 514 (29 skipped), plugin-grinder 73, 0 failures.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Quilt lets a dependency name an alternative, and Quilt Loader treats the
requirement as met when that id is present. Read from the live jars on
2026-09-10, four of the five refused Quilt rows declare exactly this:

  { "id": "quilt_resource_loader", "versions": "*",
    "unless": "fabric-resource-loader-v0" }

geophilic, terralith, trek and true-ending all ship it. `QuiltScanner` reads
`id` and `versions` and drops `unless`, so the requirement looks hard,
`quilt_resource_loader` resolves to QSL, and QSL publishes nothing past
Minecraft 1.21 -- measured: `qsl` for Quilt 1.21.1 returns 0 versions while
`fabric-api` for 1.21.1 returns 36. Mods that run everywhere are refused
everywhere.

`fabric-resource-loader-v0` is a Fabric API module, which `KnownModIds` already
resolves to `fabric-api` by shape, so the alternative is not merely expressible
-- it is already resolvable.

Pinned end-to-end through real staging rather than through the scanner, because
`ModDependency` has no field for an alternative yet and a new field cannot go
red, only fail to compile. Red: the pack stages the candidate alone.

The counterweight passes already: a requirement with no `unless` still refuses.
That is `shatterbyte-lib`/`notenoughrecipebook`, whose OctoLib-QUILT jar
hard-requires `quilt_base` and genuinely targets a Quilt+QSL pairing that does
not exist for 1.21.1 -- UNVERIFIABLE is correct there and must stay so.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Closes the four remaining Quilt rows. `ModDependency` gains a defaulted
`unlessProvided: List<String>`, `QuiltScanner.readUnless` fills it from a
`depends` entry's `unless`, and staging honours it two ways:

- `stageableRequirements` drops a requirement whose alternative is already in
  the pack, alongside the optional/bundled/environment/already-resolved cases.
- `alternativeFor` plans the alternative when the primary could not be staged,
  through the same `planManifestDependency` the primary went through -- so it
  inherits the whole mapping ladder, the version constraint and the confidence
  rule. First alternative that stages wins; if none does, the original refusal
  is handed back untouched so it still names the id the descriptor asked for.

Reached only from an `Unsatisfied` primary, which is the order the descriptor
implies -- `unless` names a substitute, not a preference -- and only for a plan
that would refuse. An `Unmapped` primary never refuses, so spending resolves on
its alternatives would buy nothing.

Measured: geophilic, terralith, trek and true-ending all declare
`{"id": "quilt_resource_loader", "versions": "*", "unless":
"fabric-resource-loader-v0"}`, QSL publishes nothing past Minecraft 1.21 (`qsl`
for Quilt 1.21.1 -> 0 versions) and `fabric-api` for 1.21.1 -> 36. The
alternative was already resolvable: `fabric-resource-loader-v0` is a Fabric API
module and `KnownModIds` maps it by shape.

`unlessProvided` is a **list** because `unless` takes every shape `depends`
does -- a bare id, an object carrying one, or an array of either -- and only ids
are kept: a consumer asking "what would satisfy this" needs the id, while
enforcing a range on the substitute is the loader's business.

Mutation-verified: making `readUnless` return empty fails
`anUnlessAlternativeSatisfiesTheRequirement` and nothing else.

-api addition is source-compatible (`ModDependency` is not a data class, so its
equality is identity and a new defaulted parameter breaks no caller). Owes a row
in claude-docs/API-BEHAVIOUR-CHANGES.md: a Quilt jar's scan now reports what its
`unless` clauses name.

api 413 (1 skipped), clientside 530, grinder 514 (29 skipped),
plugin-grinder 73, 0 failures.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Griefed's rule: aim for the newest Release of a mod for any modloader, and pick
Beta or Alpha only when no release is available.

The reported case, read from the live Modrinth API on 2026-09-10:
`hybrid-aquatic` publishes 16 stable Forge releases (1.5.0-forge ... 1.6.9-forge,
all Minecraft 1.20.1, all with a real META-INF/mods.toml) beside 10 [Sinytra]
betas. The grinder booted the beta `[1.20.4] [Sinytra] Hybrid Aquatic 1.4.4.jar`,
whose only descriptor is a fabric.mod.json, and published UNVERIFIABLE for a
project with sixteen ordinary Forge builds.

Newest-Minecraft-first does not merely permit that -- it prefers it. Authors
publish experimental newer-Minecraft ports on the beta channel while the stable
line sits on an older version, so the ordering steers into betas precisely for
the projects that have a stable alternative.

The channel is read nowhere today: `grep releaseType\|version_type` hits only
-api's Mojang version metadata, and `ModFile` has no such field, so Modrinth's
`version_type` and CurseForge's `releaseType` are both discarded at the platform
boundary.

Driven through the real ModrinthPlatform over canned JSON rather than hand-built
ModFiles -- the channel has to survive the platform parse to matter, and a test
that constructs the value under test cannot see a producer dropping it. That is
also what lets this pin go red rather than merely fail to compile, which a new
input field otherwise would.

Red: 1 guard, the reported defect. The other four are the boundaries it must not
cross -- newest Minecraft still wins within a channel, a project with no release
still yields a candidate (faster-random publishes an alpha and zero Forge
releases, so filtering would stop grinding it), an absent version_type reads as
a release, and the loader rule is untouched.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Griefed's rule, closing the hybrid-aquatic row. `ReleaseChannel` is read from
Modrinth's `version_type` and CurseForge's `releaseType` (1/2/3), carried on
`ModFile`, and `pickBootableCandidate` walks the channels in declaration order.

The channel is the **outermost** preference, so a stable build on an older
Minecraft beats a beta on a newer one. That is deliberate and not merely
permissive: newest-Minecraft-first *prefers* a beta, because authors publish
experimental newer-Minecraft ports on that channel while the stable line sits on
an older version -- so the old ordering steered into betas for exactly the
projects that had a stable alternative. Measured on hybrid-aquatic: 16 stable
Forge releases on 1.20.1, and the grinder booted a [Sinytra] beta on 1.20.4
whose only descriptor is a fabric.mod.json.

A preference, never a filter. Every channel is tried in turn, so a project
publishing only betas -- or only an alpha, as faster-random does for Forge -- is
ground exactly as deeply as before. And both readers fail toward RELEASE for an
absent or unrecognised value, so a platform that renames the field degrades to
the previous newest-Minecraft-first ordering rather than to "everything is an
alpha".

Consequence worth expecting: for a project whose stable line trails its betas,
the verdict is now about the stable build on an older Minecraft. That is the
build a user's pack installs, and sideness rarely differs across versions -- and
where a crash is contested, `pickRecheckCandidates` still samples other versions
and loaders. That sampler is deliberately left channel-blind: its job is
diversity, and narrowing it would shrink the disproof budget.

Mutation-verified: dropping the channel loop fails
`aReleaseIsPreferredOverANewerMinecraftBeta` and nothing else.

clientside 535, grinder 514 (29 skipped), 0 failures.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The patch-version fallback shipped on 2026-09-09 and is **dead on CurseForge by
construction**. `resolveDependency` narrows its single page with
`gameVersion=<exact>`, so every returned file carries the exact version;
`patchNeighboursOf` sources neighbours only from the files in hand; and
`preferenceLadder` tries the exact rung first with the same `compatibleAt`. A
neighbour version can therefore only appear as a co-tag on a file the exact rung
already matched, so the neighbour rung can never find anything the exact rung did
not. Not "rarely useful" -- logically unreachable-productive.

Measured 2026-09-10: `better-combat-by-daedelus` and `combat-roll`, both
CurseForge candidates, are published UNVERIFIABLE for `playeranimator` on Forge
1.20.2 while PlayerAnimator publishes Forge builds for 1.20.1 and 1.20. Those are
exactly the rows the fallback was written to close, and neither moved -- the six
it did close were all Modrinth.

The narrowing itself must stay: it is what fixed the architectury-api window bug,
which DependencyFileWindowTest pins. So the fix is to ask for the neighbours too,
not to stop asking for the exact version.

Driven through the real CurseForgePlatform over a recording fetcher and into real
staging -- a fake platform cannot exhibit this, because the defect *is* the query
CurseForge is sent.

Red: the dependency is not staged, and the recorded queries show only the exact
version was ever asked for.

Worth recording: the first version of this fixture **passed against unfixed
code**. It matched the requested `gameVersion` with `contains`, and one release of
a line is often a prefix of another (`26.1` of `26.1.2`), so the older file was
answered to a request for the newer version. The fixture now parses the query
value and compares it exactly.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The patch-version fallback shipped on 2026-09-09 and was inert on CurseForge
by construction. `resolveDependency` narrows its single page with
`gameVersion=<exact>`, so every file it returns carries the exact version;
`patchNeighboursOf` sources neighbours *only from the files in hand*, and
`preferenceLadder` tries the exact rung first with the same `compatibleAt`. A
neighbour version could therefore only ever appear as a co-tag on a file the
exact rung had already matched, so the neighbour rung could never find anything
the exact rung did not. All six rows that fallback closed were Modrinth, whose
version endpoint returns a project's whole history in one response.

Measured 2026-09-10: `better-combat-by-daedelus` and `combat-roll`, both
CurseForge, published UNVERIFIABLE for `playeranimator` on Forge 1.20.2 while
PlayerAnimator publishes Forge builds for 1.20.1 and 1.20.

`BootVerifier.resolveDependencyAcrossTheLine` asks for the exact version first
and alone, and only when nothing usable comes back asks again for the line's
other releases -- so the common case is still one request and the extra ones
are paid for exactly where the boot would otherwise be refused outright. The
neighbours come from SPC's own Minecraft release list rather than from the
files, because on CurseForge the files cannot name a version nobody asked
about, and they are ordered by the extracted
`BootCandidateSelector.patchNeighboursIn` -- the same nearest-first,
tie-to-newer rule the in-hand fallback uses, so the two cannot drift.

The widening is a second `ModPlatform.resolveDependency` overload defaulting to
the narrow one, not an extra parameter: Modrinth already returns everything, so
the default is the correct behaviour for it, and every existing implementation
stays valid. `CurseForgePlatform` overrides both, the narrow one delegating.
The widened answer keeps the exact version in the union and de-duplicates by
file name, since one file can be tagged for several releases of a line.

The `gameVersion` narrowing itself stays -- it is what fixed the
`architectury-api` window bug, where a library publishing 1000+ files has
nothing older than current Minecraft in its newest 50.

Mutation-verified: forcing `alsoVersions` back to `emptyList()` fails both
guards of `CurseForgeDependencyLineTest` and nothing else in the 537-test
suite. The test tree also compiles from clean (`--rerun-tasks`), which is what
the module's incremental-compilation landmine asks for after a signature change.

api 413/0, clientside 537/0, grinder 514/0.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Ten of the public grinder's 42 UNVERIFIABLE rows are a platform mis-tick and
nothing else: `bellsandwhistles-0.4.5-1.21.1.jar` carries only
`META-INF/neoforge.mods.toml` and is ticked Forge; `Highlighter-1.19.4-forge-
1.1.5.jar` is ticked Fabric. The jars run fine under the loader they were built
for, so refusing them publishes a verdict about our reading of a web form.

Red as committed, and the red is the missing implementation: two guards fail
with `expected <[Forge, NeoForge]> but was <[Forge]>` -- the loader-version
policy is asked once per staging attempt, so its argument list is the
re-selection history, and today there is no second attempt. The other three
pass by construction and are the counterweights the fix must not break: the
fixture really does contradict a Forge boot, a declared loader with no build for
that Minecraft still refuses, and a jar declaring the requested loader is staged
once and left alone.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Ten of the public grinder's 42 UNVERIFIABLE rows are a platform mis-tick:
`bellsandwhistles-0.4.5-1.21.1.jar` carries only `META-INF/neoforge.mods.toml`
and is ticked Forge, `Highlighter-1.19.4-forge-1.1.5.jar` is ticked Fabric. A
loader tick is a web form; the descriptor is what the file was built against and
what the loader reads at runtime, so refusing those jars published a verdict
about our reading of the page rather than about the mod (Griefed's call: the jar
wins).

`Prepared.Failed.declaredLoaders` is a sibling channel to
`declaredMinecraftConstraint`, deliberately not a widening of it -- the landmine
in this module's CLAUDE.md says exactly why: re-selecting a *version* cannot
answer a *loader* mismatch, and a refusal that offered the Minecraft retry this
set would re-stage the jar down its whole version list, learning nothing each
time. The two are mutually exclusive per refusal and `prepareBootPack` takes at
most one retry, the loader one first, because where a jar disagrees about both,
no other Minecraft version makes it a mod for this loader.

The acceptability rule stays in one place: `JarSelfDeclaration.contradiction` now
asks `contradictingLoaders`, which the refusal site asks again for the set. A
second copy of that rule would be free to drift into accepting what the gate
refuses.

What keeps it from being an amnesty: the declared loader must have a build for
the Minecraft being booted, so a `mods.toml` naming Forge and NeoForge on 1.19.4
still refuses; the retry goes through `stageBootPack`, so a second contradiction
surfaces rather than loops; and it stages into the **requested** loader's scratch
directory, because staging wipes what it uses and the loader whose descriptor was
borrowed builds its own verdict from its own pack and console -- the cross-loader
crash re-check's reasoning. `loaderToVerifyUnder` prefers a loader the platform
also tagged (two statements agreeing beats either alone) and is otherwise
alphabetical, purely for determinism: picking by file name is the
silently-plausible-value trap.

The verdict still says what ran. `BootOutcome.bootedLoader` is stamped from the
staged pack, so a re-selected boot has `bootedLoader != loader`, which
`loaderDisprovingTheCrash` already requires to be equal before one loader may
clear another's crash -- pinned in `ClientsideVerifierCrossLoaderTest`, not
restated.

Mutation-verified twice, each failing exactly its own guards and nothing else in
the 542-test suite: dropping the bootability gate fails
`aDeclaredLoaderWithNoBuildStillRefuses`; never setting `declaredLoaders` fails
the two re-selection guards.

clientside 542/0, grinder 514/0.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`LearnedModIds.mappingsFor` already answers a *list*, because one mod id can be
served by several projects and keeping only the first prover made the answer
depend on grind order. `KnownModIds` answered exactly one, so the unlearned half
of that list could never express the same fact.

`KnownModIds.mappingsFor` is that shape, returning `listOf(mappingFor(...))` --
one entry for every id today, so no caller's answer changes -- and
`LearnedModIds.mappingsFor` takes the list, filtering each entry against what a
staged jar has already proved rather than filtering one.

Behaviour-preserving: the test edits are reference-only (`KnownModIds.mappingFor`
-> `mappingsFor` inside the `orElse` lambdas, `ModIdMapping.None` ->
`listOf(ModIdMapping.None)`), with no assertion, argument or expected value
changed -- the carve-out this repo's refactor discipline states for a moved or
rewrapped symbol. clientside 542/0, unchanged from the previous commit.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Verified against the live Modrinth API on 2026-09-11: `create` publishes
`loaders = [forge, neoforge]` and nothing for Fabric, while the Fabric port is a
separate project `create-fabric` publishing `[fabric, quilt]` -- and both declare
the mod id `create`, because keeping it is what makes a port a drop-in. `tacz`
404s as a slug; the project is `timeless-and-classics-guns`.

Red as committed, and both reds are the missing implementation: the Fabric boot
stages only the candidate (`expected <[create-fabric-1.0.0.jar,
some-addon-1.0.0.jar]> but was <[some-addon-1.0.0.jar]>`), and `tacz` maps to the
slug guess `tacz` rather than to the project. The two green guards are the
counterweights the fix must not break -- the fork costs no request where the
original answers, and an ordinary id still yields exactly one mapping.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A cross-loader port keeps the original's mod id -- that is what makes it a
drop-in -- so one id legitimately names two projects, of which only one publishes
for the loader being booted. Verified live 2026-09-11: `create` publishes
`[forge, neoforge]` and nothing for Fabric, the Fabric port is the separate
project `create-fabric` (`[fabric, quilt]`), and a Fabric mod declaring `create`
was refused for a dependency that exists. `tacz` is the other half: the slug
404s, and the project is `timeless-and-classics-guns`.

`KnownModIds.alternatives` is the fork table and is an **alternative, never a
replacement**: the primary is tried first and the fork only when it answers
nothing, so no Forge or NeoForge boot is redirected to a project with no Forge
build. `pickDependencyFile`'s loader filter is what actually decides -- the same
division of labour `LearnedModIds` already relies on for the forks it learns from
staged jars, which cannot help the first time because the candidate that would
teach it is the one being refused.

A fork is an Alias rather than a Guess: it is a project we know serves the id.
That only matters where the primary is an alias too, since a Guess primary
already cannot refuse whatever follows it.

**No CurseForge refs are invented.** The numeric ids could not be verified in
this session, and a wrong one stages somebody else's mod, so both new entries
carry `null` there. A table entry with no ref for a platform now falls through to
that platform's slug guess instead of resolving to nothing -- unobservable for
the four existing entries, which all carry both refs, and it is what keeps
CurseForge's existing `tacz` guess alive.

Mutation-verified: dropping the fork alternatives fails
`ForkedProjectDependencyTest.aFabricBootReachesTheFabricFork` and nothing else in
the 546-test suite.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Griefed's call. `tacz` resolves to `timeless-and-classics-guns`, whose Minecraft
1.21.1 build is published on CurseForge only, so a Modrinth candidate was refused
for a jar any launcher installs. The candidate's platform is part of the question
being asked; a dependency is scenery -- the pack needs the library loaded, and
which site hosts it says nothing about whether the pack boots with it.

**Only the manifest route can cross**, because only it knows the mod *id*: a
platform ref is that platform's own identifier and names nothing on the other
side. That also bounds the cost -- it is reached from a requirement that is
required, declared by the jar, and already unstageable here, whose only other
outcome is a refused boot -- and it sits *above* `askLinkedProjects`, which fires
on the same state and pays a whole jar download.

Both failing states cross, not just `Unsatisfied`. The difference between
`Unmapped` and `Unsatisfied` is about *our* platform's confidence in its own
mapping, not about whether the other site has the mod; gating on `Unsatisfied`
alone left the commonest case out, which is what the first cut of this did, and
the guard caught it.

`stagedFromPlatform` is new beside `stagedFromRef`: a ref learned under the wrong
platform resolves to nothing there, and the next candidate would trust it.
`resolveDependencyAcrossTheLine` takes the platform to ask, so the version-line
widening applies to the other site too rather than being silently narrower there.

Both production call sites hoist `supportedPlatforms(...)` and pass the others.
With no CurseForge key the list is empty and nothing changes anywhere.

Mutation-verified: never consulting the other platform fails exactly
`CrossPlatformDependencyTest`'s three behavioural guards and nothing else.

The guard could not be committed red on its own -- it asserts a constructor
parameter this commit introduces, and a non-compiling test is not a pin. The
mutation above is that boundary: revert the `for (other in alternatePlatforms)`
loop and those three go red with `expected <[Modrinth/somelib,
CurseForge/somelib]> but was <[Modrinth/somelib]>`.

clientside 550/0, grinder 514/0, app 149/0.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Found while reading the UNVERIFIABLE rows, both at the platform boundary:

- Modrinth: a dependency entry may carry a `version_id` and a **null**
  `project_id` -- an author pinning one exact build. `filesOf` reads only
  `project_id`, so such an entry vanishes from `requiredDependencies` and
  `relatedDependencies` alike, with no log, and `askLinkedProjects` cannot
  recover it either because it reads the same list.
- CurseForge: `asText()` on a JSON-null `modId` returns the literal `"null"` --
  the documented `textOrNull` hazard, and the one place it was still live. The
  red output shows it exactly: `expected <[306612]> but was <[null, 306612]>`.

Red as committed, all four for the missing implementation: the pinned dependency
resolves to nothing (`expected <[P7dR8mSH]> but was <[]>`), no `/version/{id}`
request is made at all, and the CurseForge list carries the fabricated ref. The
memoisation guard is in from the start because an un-memoised lookup is a request
per version of the project -- the cost shape this module already paid for once
with CurseForge's paging.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Two platform-boundary defects found while reading the UNVERIFIABLE rows, plus the
stale docs beside them.

**A version-pinned Modrinth dependency was dropped in silence.** An entry may
carry a `version_id` and a null `project_id`; `filesOf` read only `project_id`,
so it vanished from `requiredDependencies` *and* `relatedDependencies` -- never
staged, and invisible to `askLinkedProjects`, which reads the same list. The
loader then refused the pack and the candidate wore the verdict.
`projectBehind` reads either shape and resolves a pin with one GET of
`/version/{id}`, **memoised**, because `resolve` walks a project's whole version
list and a pin is normally repeated by every version of it. It fails toward
dropping, exactly as before, rather than recording a ref that names nothing. The
pinned *build* is deliberately not honoured: `pickDependencyFile` chooses among a
project's files by loader, Minecraft version and obtainability, and a pin would
override all three to satisfy a constraint the loader does not enforce.

**A JSON-null CurseForge `modId` became the literal ref `"null"`**, resolved to
nothing, and was reported as an unmet dependency named `null` -- the documented
`textOrNull` hazard, and the one place it was still live.

Docs corrected in the same pass: `NeoForgeTomlScanner`'s own KDoc said the
boundary was Minecraft 1.16.5 while the dispatch constant one file over says
1.20.5, and it now points at `LoaderDescriptors.neoForgeUsesNeoToml` rather than
restating a literal. `serverpackcreator-api/module.md`'s modscanning section
named `Scanner`, `JsonBasedScanner` and `ScanningException`, none of which exist
any more, and omitted `LoaderDescriptors`, `ModJarScanner`, `QuiltPackScanner`,
`FabricFamilyScanner` and `ScannedMod`.

api 413/0, clientside 554/0.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The module `CLAUDE.md` gains the six fixes as landmines a future session must not
rediscover -- the descriptor era having exactly one home, what the gate
deliberately gives up on Minecraft 1.20.2-1.20.4, Quilt's `unless`, the channel
being a preference and never a filter, why the patch fallback was inert on
CurseForge by construction, the loader re-selection being a sibling channel
rather than a widening of the Minecraft one, and the two id-resolution routes.

`REFACTOR-LOG.md` carries the attribution table, the measurements and the
equivalence result: api 412 pre-existing guards / 0 failures, clientside 523 / 2,
both failures being the one deliberate change and both already restated at
1.21.1 in the HEAD tree, with three files adapted reference-only.

Root `CLAUDE.md`: the clientside row's count and narrative, and three lessons
worth carrying -- a fixture value that is a prefix of another can make a guard
pass against unfixed code (ask why it *passed*, not only why it failed); a guard
that cannot compile is not a red pin, and both honest ways out beat a fake
boundary; and duplicated knowledge drifts toward whichever copy is easier to
reach, now the third instance of that exact shape, so delete the duplicate rather
than correct it.

`API-BEHAVIOUR-CHANGES.md` gains the two published-surface rows (`LoaderDescriptors`
plus the `scannerFor` crash it fixed, and `ModDependency.unlessProvided`).
`BACKLOG.md` gains B36 (Sinytra Connector as a boot strategy) and B37
(search-then-confirm for a mod id no registry resolves), each with the reason it
waited and enough context to pick it up cold.

api 413/0, clientside 554/0, grinder 514/0, app 149/0, plugin-grinder 73/0.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`unzip -l` on the actual artifacts rather than a test, per the root `CLAUDE.md`'s
"ask a real runtime" rule:

  architectury-11.1.17-neoforge.jar (NeoForge, MC 1.20.4) -> META-INF/mods.toml
  architectury-13.0.11-neoforge.jar (NeoForge, MC 1.21.1) -> META-INF/neoforge.mods.toml

That pair *is* the era boundary the loader gate now encodes: a NeoForge build
below 1.20.5 carries the file the gate used to read as Forge's, and nothing in
either archive distinguishes the two loaders there. And `geophilic`'s and
`terralith`'s Quilt builds both declare `quilt_resource_loader` with
`unless: fabric-resource-loader-v0`, in the shape `QuiltScanner` reads -- an
object under `quilt_loader.depends`, not a bare string.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Read-only passes over `86d3d441b..develop` (21 commits). Two measurements were
made rather than taken on trust, and both changed the findings:

- **Every pin re-run at its own commit.** Eight of nine were genuinely red.
  `851098c17`'s test tree does not compile there at all -- its
  `declaredLoaders(jar, mc)` call needs a signature that first exists in the
  next commit -- and `0c1001104` landed green, which its message discloses and
  offers a mutation check for. That mutation is re-verified here: dropping the
  `runCatching` from `LoaderDescriptors.atLeast` fails exactly 2 of its 7 guards.
- **`fabric-api-0.116.17+1.21.1.jar`, read from the live artifact.** Its
  descriptor is `id=fabric-api`, `provides=["fabric"]`, and
  `fabric-resource-loader-v0` exists only as a nested jar -- which is what makes
  the new `unless` drop arm unable to fire for the case its own comment names.

Findings: no HIGH. Eight MEDIUM and four LOW in the refactor audit; five MEDIUM
and six LOW in the analysis, of which three are defects with concrete failure
scenarios. Both files also gained a "verified clean, do not re-litigate" list --
the published-surface check on `ModDependency`, `LegacyFabric`'s empty descriptor
set not reaching scanner dispatch, the `refactor:` label being genuine, B6's
dedupe still closed, and the neighbour ordering being pinned already.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
All three ran before being committed, and all three reds are the missing
implementation:

- `anUnlessAlternativeAlreadyBundledCostsNoDownload` -- `expected
  <[Terralith…jar]> but was <[Terralith…jar, fabric-api…jar]>`. The `unless`
  drop arm reads `providedIds` only, and the alternative is a **jar-in-jar**:
  read from the live `fabric-api-0.116.17+1.21.1.jar`, its descriptor declares
  `id=fabric-api`, `provides=["fabric"]`, and `fabric-resource-loader-v0` exists
  only as `META-INF/jars/fabric-resource-loader-v0-0.116.17.jar`. So the id lands
  in `bundledIds` and an arm testing only the other set cannot fire for the case
  its own comment names. Asserted through a recording downloader, because the
  observable cost is a fetch that should not happen.
- `aFailedPinLookupIsAttemptedOnce` -- 4 attempts against 1 expected. The memo is
  written only after a successful read, so a dead `version_id` is re-asked once
  per dependency list per version node. The count also shows the lookup happens
  twice per node (`requiredDeps` and `linkedDeps` each ask), which the memo hides
  on the success path.
- `aJarDisagreeingAboutBothRecordsBothChannels` -- `expected <~1.16.5> but was
  <null>`. Enforcing "exactly one retry" by nulling the Minecraft channel loses a
  reachable boot: where the declared loader has no build for this Minecraft the
  loader retry cannot fire and the version retry has been erased.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The drop arm added with the `unless` clause read `providedIds` only, so it could
not fire for the case its own comment names. Measured on the live artifact:
`fabric-api-0.116.17+1.21.1.jar` declares `id=fabric-api` and
`provides=["fabric"]`, and ships `fabric-resource-loader-v0` as one of 49 nested
jars -- `META-INF/jars/fabric-resource-loader-v0-0.116.17.jar`. A nested id lands
in `bundledIds`; `providedIds` holds only what a *staged* jar declares as its own
identity. So the canonical alternative was invisible to the arm meant to see it,
the requirement survived, and `alternativeFor` re-resolved the project and
re-downloaded a library the loader already had on the classpath.

Both sets are now consulted, each with the comparison its neighbours use:
`bundledIds` case-sensitively (two ids read by the same scanner, per the arm above
it) and `providedIds` lowercased (descriptors spell ids inconsistently and a miss
there costs the whole boot).

No verdict was ever wrong -- `injected` dedupes by file name, so
`MAX_INJECTED_DEPENDENCIES` was never mis-counted -- the cost was a redundant
download per affected boot and a comment that was false.

Mutation-verified: dropping `bundledIds` from the arm fails exactly
`anUnlessAlternativeAlreadyBundledCostsNoDownload` and nothing else.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`projectBehind` wrote its memo only after a successful read, so a `version_id`
that answers nothing -- a deleted version, a rate-limited response, a timeout --
was re-asked for every dependency list of every version node that pins it, with a
WARN each time. Measured by the pin: two versions pinning one dead id produced
**four** requests, because `filesOf` asks once for `requiredDependencies` and
again for `relatedDependencies`. `resolve` reads a project's whole version list,
so this is the cost the success memo exists to prevent, left open for the case
that is already going badly.

`unresolvablePins` is a second collection rather than a sentinel value in
`projectOfVersion`: a map whose values sometimes mean "no project" is a map every
later reader has to be warned about. The behaviour is otherwise unchanged -- a pin
nothing can resolve is still dropped, which the same guard asserts.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`refuseForSelfDeclaration` nulled `declaredMinecraftConstraint` whenever the
loader channel was populated, to enforce "exactly one retry" through the *data*.
That loses a reachable boot. Where the declared loader has no build for the
Minecraft being staged, `reselectOnLoaderContradiction` cannot fire -- and the
version retry that could have has already been erased.

The live shape: a `mods.toml`-only jar requested as NeoForge on Minecraft 1.20.6,
whose own descriptor accepts an older release. At 1.20.4 that same file *is* a
NeoForge descriptor (both loaders read `mods.toml` below 1.20.5), so re-selecting
the version finds a genuine **NeoForge** boot where the loader retry could only
have borrowed Forge's -- a weaker piece of evidence for a NeoForge row, since
`bootedLoader` then names a loader the verdict is not about.

Both channels now carry what the jar actually said, and `prepareBootPack` owns
the order: loader retry first, because no other Minecraft version makes a jar
into a mod for a loader whose descriptor it does not carry; the version retry
only when that one does not apply. "At most one retry" is unchanged and is still
pinned by the two guards asserting the staging sequence is `Forge, NeoForge` and
not one longer.

Mutation-verified: restoring the `takeIf` fails exactly
`aJarDisagreeingAboutBothRecordsBothChannels` and nothing else in the 557-test
suite.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Characterization, all green when written -- the code was right, the guards were
absent -- so each is **mutation-verified** rather than trusted, and each mutation
failed exactly its own guard and nothing else in the 566-test suite.

- `UnlessClauseShapesTest` (-api, new): every shape Quilt's `unless` takes -- bare
  string, object with `id`, array mixing both, a clause holding only unusable
  entries, no clause, and a bare-string *dependency* that cannot carry one. The
  field shipped with one assertion anywhere, in a `-clientside` integration test
  writing the bare-string form, so two of the three shapes this **published**
  parser handles were unexercised. Parsing is the case this repo requires a test
  for first, because a wrong branch yields a plausible value rather than an error.
- `curseForgeReleaseTypesBecomeChannels` + `aCurseForgeReleaseBeatsANewerMinecraftBeta`:
  CurseForge's half of the release-channel rule had no assertion at all --
  `fromCurseForge` has one call site and the existing CF fixtures set
  `releaseType:1` incidentally. Mutation: forcing it to RELEASE fails both.
- `theChannelPreferenceNeverOverridesLoaderAvailability`: every existing channel
  guard passes `{ true }` for availability, so nothing held the channel filter
  *inside* the gate. Mutation: hoisting it above the gate fails this one, and
  would otherwise have made a project whose only release targets an unsupported
  Minecraft unverifiable.
- Three `loaderToVerifyUnder` guards: the platform-tagged preference, the
  alphabetical tie-break (asserted from both set orders, since the point is that
  it does not depend on iteration order), and nothing-bootable yielding no
  choice. Mutation: dropping the tagged preference fails the first.
- `anEntryWithNoRefForAPlatformFallsBackToThatPlatformsGuess` + `aForkIsNeverOfferedTwice`:
  the reason both new registry entries carry a deliberately-`null` CurseForge ref
  -- without the fall-through the entry would have *removed* that platform's
  existing guess.
- `anUnmappableIdCrossesForExactlyOneExtraResolve`: `Unmapped` is where most
  unresolvable manifest ids land, so it is the state that decides what the
  cross-platform fallback spends of an API key's quota, and the first cut of that
  feature left it out entirely. One extra resolve per id, home platform first.
- `anUnreadableMinecraftVersionStillAnswersTheModernDescriptors` +
  `anUnknownLoaderEvidencesNothing`: the pre-boot **gate** asks `descriptorsFor`,
  not `scannerFor`, so pinning only the dispatch left the more consequential
  caller uncovered.

api 421/0, clientside 566/0.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Four doc-truth fixes the audit turned up, plus one newline.

**The rename outlived its own landmine.** `LoaderVerdict.filenamePattern` became
`fileName` on 2026-09-10, and three places still named the old field -- including
the landmine that says never to publish it, which cited a guard
(`theFilenamePatternIsNotWhatGetsPublished`) that no longer exists under that
name either. A landmine nobody can grep for is a landmine nobody will find. Both
module docs now name `fileName`, say what it became (the artifact's own name,
verbatim, where it used to be a stem of one file), and cite
`theSampledFilenameIsNotWhatGetsPublished`. `RecordedVerdictMappingTest`'s KDoc
gets the same correction, and its example stops being hypothetical: `fileName =
suggestedEntry` is exactly what that field held for two thirds of the store's
rows until it was fixed.

**"`resolveDependency` stays single-page on purpose" was half true** after the
version-line fix. It is one page *per asked version*: the version being booted
and nothing else, until that answers nothing usable, then one more per patch
neighbour -- which is the only way a version line is reachable on a platform that
cannot show a caller what it did not ask about.

**The channel rule now states its scope.** It applies to `pickBootableCandidate`
only; `pickDependencyFile` and `pickRecheckCandidates` are channel-blind on
purpose -- a dependency only has to load, and the crash re-check is spending its
budget on diversity of Minecraft line and loader, which a channel filter would
narrow. Written because the unscoped sentence invites exactly that "fix".

And `ModScanner.kt` ends with a newline again, in the file whose era predicates
moved out.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Eleven findings closed with code and six with guards, each mutation-verified.
Four are facts about commit shape on history already merged into `develop`:
rewriting them would mean rebasing 21 commits to produce a red nobody ever
observed, which is manufactured evidence -- the same remedy the root CLAUDE.md
chose for `358675fbf`. The pin table is what iteration 1 adds instead: proof it
happened, measured.

Also recorded: the first A-2 mutation run reported the wrong failing test,
because a regex edit left an orphaned `when` body, the build never compiled, and
the parser read the previous run's XML. "No results" is a third outcome and has
to be handled as one.

api 421/0, clientside 566/0, grinder 514/0, app 149/0, plugin-grinder 73/0 --
1,723 tests.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Auditing the fixes is the point of repeating, and the second pass caught an error
in the first one: iteration 1's doc fix named `LoaderVerdict.fileName`, and no
such property exists -- `ab188dff4` deleted `filenamePattern` and re-purposed the
pre-existing `sampleFile`, while `fileName` belongs to the grinder's
`GrindVerdict`. Correcting a stale name with a second wrong one is worse than
leaving it alone.

Three more: a whole test class (`FilenamePatternTest`) documents a subject that
stopped existing on 2026-09-10, which pass 1 missed because it greps as
`pattern` and never as `filenamePattern`; the producer of that column is
asserted nowhere, since every guard for it either injects the value or pins the
mapping downstream; and iteration 1's own retry-order fix is pinned as data
rather than as behaviour.

Equivalence for iteration 1 re-run first: `develop`'s unmodified test tree
against its production code, api 413/0 and clientside 554/0 with no compile
errors.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Three iteration-2 findings, and one correction to iteration 1.

**`FilenamePatternTest` documented a subject that no longer exists.** It opened
"pins the filename pattern: the second, narrower entry derived from the one file
actually sampled" and closed with "the two columns are deliberately different,
and this is the pin that says so" -- but that column stopped being a derived
entry on 2026-09-10 and now carries the artifact's name verbatim. Its assertions
never noticed, because they call `FilenameStemDeriver.deriveStem` directly and
that function is unchanged. Renamed to `SampledArtifactNamingTest`, which is what
the guards are about, and the doc now says which consumer each half speaks for:
`sampleFile` for the report column, single-file `deriveStem` for
`Prepared.Ready.candidateStem`, which is how blame attribution separates the
candidate's stack frames from a dependency's.

**Nothing asserted the producer.** Every guard for that column either injected
the value into a grinder fixture or pinned the mapping one layer downstream --
the arrangement `DependencySlugTest` exists to warn about. The new guard drives
the real `ClientsideVerifier` over a real published name (`[1.20.1-Forge] Hybrid
Aquatic 1.6.9.jar` -- spaces, brackets, version) and asserts `sampleFile`
verbatim beside `suggestedEntry` from the same run, so the two fields are *shown*
to differ rather than described as differing. Mutation-verified: re-deriving a
stem there, which is exactly the code that was removed, fails it and nothing
else.

**And iteration 1's finding M-4 was wrong.** The behavioural guard written for it
went red against the supposedly-fixed code, which is how the error surfaced: the
Minecraft range is read by `scannerFor(loader, minecraftVersion)` -- the
*mismatching* loader's own scanner -- so on a loader mismatch it can never be
read, and the two channels are mutually exclusive **by construction** rather than
by the refusal nulling one. `aLoaderMismatchLeavesNoRangeToRetryOn` pins that
invariant, which is the more valuable fact, and the data-level guard is
re-documented to stop overclaiming. Iteration 1's change survives on different
grounds: the retry order no longer depends on an invariant proved in another unit,
so a scanner that ever merged descriptors the way `QuiltPackScanner` merges
Fabric's would make this guard go red instead of silently suppressing a range.

api 421/0, clientside 568/0, grinder 514/0.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
An audit log that keeps a wrong finding is worse than one that never made it, so
M-4 is corrected where it was written: its failure scenario assumed the jar's
Minecraft range is readable on a loader mismatch, and it is not -- the range
comes from the mismatching loader's own scanner.

The reusable lesson is about method rather than about that field: writing the
behavioural guard is what tested the finding. M-4 survived a code read, a diff
read and a mutation check, and was killed by asserting the consequence
end-to-end and watching it fail on supposedly-fixed code. A mutation check proves
a guard notices its own line changing; it cannot tell you the line matters.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The third pass looked where the first two had not: at the consumers of the value
this range turned from a derived stem into a verbatim, author-controlled
filename, at the seam the cross-platform fallback is wired through, and at the
"no new compiler warnings" item neither earlier pass had actually checked.

Mostly clean, and the reasoning is recorded so a fourth pass does not re-derive
it: every HTML cell goes through an escaper covering `& < > " '`, the CSV
exporter is RFC-4180, `/verdicts.json` goes through Jackson, `/boot-log?name=`
cannot escape its store, the `!==` identity the alternate-platform filter depends
on really holds (`ClientsideVerifier` hands the factory the same instance it
picked), and carrying the backtrack's file-name exclusions across platforms is
correct rather than a leak. No new compiler warnings: every one the five touched
compile tasks emit predates the range.

Two LOW findings: the Project cell's `href` accepts any scheme -- escaping stops
markup, not `javascript:`, and the report server is one `SPC_GRINDER_HOST` away
from being served -- and one pre-existing unnecessary safe call in a file the
range touched.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Red as committed: `expected <false> but was <true>` -- `javascript:alert(...)`
reaches the Project cell's href today. HTML-escaping a URL stops markup from
breaking out of the attribute and does nothing about a scheme the browser
executes, and this report server carries no authentication and binds loopback
only until `SPC_GRINDER_HOST` says otherwise. A verdict's `projectUrl` is not the
daemon's own string: it is whatever an operator queued, or CurseForge's
`links.websiteUrl`. Neither is hostile today, which is exactly when the allowlist
is cheap.

The second guard is the counterweight -- an ordinary `https://` row is still a
link -- so the fix cannot be "stop linking".

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`esc` stops a value breaking out of the href attribute and leaves `javascript:`
a working link. The Project cell's URL is not the daemon's own string -- it is
whatever an operator queued, or the `links.websiteUrl` a platform published -- and
this report server carries no authentication and binds loopback only until
`SPC_GRINDER_HOST` says otherwise. `http`/`https` are linked; anything else is
shown as escaped text, because a reader still has to see which project the row is
about.

Not reachable from a hostile party today, which is the cheapest moment to close
it: the alternative is closing it after the first row that is.

Mutation-verified: forcing `followable` back to `true` fails exactly
`aProjectUrlWithAnUntrustedSchemeIsShownButNotLinked` and nothing else in the
516-test grinder suite.

Also, Boy-Scout in a file this range touched: `outcome.decidedBy?.ruleId` inside
`if (outcome.decidedBy == BootDecision.OPERATOR_RULE)`, where it is already
smart-cast, was the one compiler warning in the range's own diff -- pre-existing,
from 2026-09-05, and now gone. Every other warning the touched compile tasks emit
predates the range (deprecated nightconfig `valueMap()`, `Locale` constructors,
Jackson URL overloads).

clientside 568/0, grinder 516/0.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Pass 1 found the defects a careful read finds. Pass 2 found pass 1's own mistake,
because it wrote the behavioural guard pass 1 had only argued for and that guard
went red against supposedly-fixed code. Pass 3 found almost nothing in the code
and earned its keep by recording what it ruled out -- escaping in three
renderers, path traversal, a reference-identity comparison across a module seam,
the backtrack's exclusions crossing platforms, and the compiler-warning inventory
-- so a fourth pass starts from a shorter list rather than the same one.

Suite counts in the root table refreshed from the run: api 421, clientside 568,
grinder 516.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The `unless` drop arm's doc now says **both** id sets and why the bundled one is
not optional -- the canonical alternative is a nested jar, measured on the live
`fabric-api-0.116.17+1.21.1.jar`, so an arm consulting `providedIds` alone could
not fire for the case it was written for.

The two disagreement channels are recorded as mutually exclusive *by
construction*: the Minecraft range is read by the mismatching loader's own
scanner, so it can never be read on a loader mismatch, and the guard that pins
that is what would go red if a scanner ever merged descriptors.

And the grinder's report notes the href scheme allowlist beside the `?name=`
sentence it belongs with, since both are the same class of "this string is not
ours" on a server with no authentication.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
docs: the whole branch proved equivalent to develop for every pre-existing guard
Some checks failed
Continuous / Build JAR (push) Has been cancelled
Continuous / Build AppImage (x86_64) (push) Has been cancelled
Continuous / Build AppImage (aarch64) (push) Has been cancelled
Continuous / Build Install4J Media (push) Has been cancelled
Continuous / Continuous Pre-Release (push) Has been cancelled
Docker Test / build image (push) Has been cancelled
Documentation / Writerside webhelp (push) Has been cancelled
Documentation / Help image (push) Has been cancelled
Qodana / scan (push) Has been cancelled
Qodana / notify (push) Has been cancelled
Test / build (push) Has been cancelled
5a38e8da2d
413 + 554 + 514 = 1,481 pre-existing guards run against the branch's production
code, zero failures and zero compile errors, so nothing an existing test could
see has moved. The three behaviours that did change each carry their own new
guard and each is mutation-verified.

Full build green including the frontend suite: 1,730 tests, 0 failures.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Signed-off-by: Griefed <griefed@griefed.de>
Red. Reproduces the reported CurseForge/aether row: the Forge verdict named
`aether-1.12.2-v1.5.4.1.jar` while its DEPENDENCY_FAILURE detail described
`aether-1.20.1-1.5.2-neoforge.jar`, the jar staging had actually selected.

Measured against the live CurseForge API on 2026-09-11 and reproduced here
with the same shape:

  - the 1.20.1 build is tagged ['NeoForge', '1.20.1', 'Forge'] and
    pickBootableCandidate orders newest-Minecraft-first inside a release
    channel, so the Forge boot staged it; its META-INF/mods.toml declares
    modId = "curios", mandatory = true, versionRange = "[5.3.1+1.20.1,)"
  - the 1.12.2 build was re-uploaded in 2025, so it is the newest-*uploaded*
    Forge-tagged file, which is what `loaderFiles.firstOrNull()` returns; its
    mcmod.info declares `"dependencies": []` and CurseForge lists curios
    against it only as relationType 1 (EmbeddedLibrary), which
    CurseForgePlatform correctly ignores

Both halves were true of different files, so a reader checking the row against
the platform page correctly concluded the dependency resolution had gone wrong.
What had gone wrong was the attribution.

Observed failure, against the shipped manifest's oldest and newest
Forge-capable releases:

  expected: <themod-26.2-1.5.2.jar> but was: <themod-1.1-v1.5.4.1.jar>

with the log line `Not booting themod on Forge: Could not download
themod-26.2-1.5.2.jar.` beside `Could not download themod-1.1-v1.5.4.1.jar for
Forge; jar-scan unavailable.` -- the two selections disagreeing in one run.

Both guards are executed rather than asserted against a re-implementation of
the selection rules: the pick is observed through the file staging asks the
downloader for.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Turns the previous commit's pin green. Two halves, because the file a caller
*chooses* and the file that *runs* are routinely different:

  - `ClientsideVerifier.verdictFor` samples the combination staging would pick
    (`BootCandidateSelector.pickBootableCandidate`) instead of
    `loaderFiles.firstOrNull()`, which is the platform's newest *upload* -- a
    different file for any project that re-published an old build.
  - `BootOutcome.bootedFile`, stamped from the staged pack beside its sibling
    `bootedLoader` in the one place that knows what was booted, and preferred
    over the metadata pick. Only the boot can answer this: staging re-selects
    on a loader or Minecraft range the jar declares, and the crash re-checks
    boot other builds entirely.

Also fixes a silent mis-scan found on the way: `scanSample` chose its Minecraft
version with `minecraftVersions.maxOrNull()` -- a *lexicographic* maximum, so a
file tagged `1.9` and `1.20.1` was scanned as `1.9` and got `scannerFor`'s
answer for the wrong era. The version now comes from the same pick, ordered by
`BootCandidateSelector.minecraftComparator`.

Behaviour change for an embedder: `LoaderVerdict.sampleFile` (the grinder's
`Filename` column, and `/verdicts.json`'s `fileName`) can now differ from the
file it named before -- it names the staged build rather than the newest-
uploaded one, which is the point.

Suite: 570 tests, 0 failed, 0 skipped -- 568 pre-existing assertions unchanged,
plus this pin's two. Grinder and app suites green. No new compiler warnings in
-clientside.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Sideness is a property of a build, and builds differ far more across Minecraft
eras than across loaders of one era. Grinding once per loader therefore spent
most of its boots re-asking one era's question and never asked the older eras
at all: measured 2026-09-11 over the 200 most-downloaded Modrinth mods, 3.06
boots per project covering a mean of 1.6 distinct lines, and
CurseForge/aether's 1.12.2 build -- a wholly separate codebase -- was never
booted under any loader. This is the selection half of moving the axis.

Two halves rather than one list, because each covers the other's blind spot: a
bare count never reaches 1.12.2 for a project publishing for sixteen lines (JEI
does), and a bare list goes stale in silence, since a new Minecraft release is
simply never ground until somebody edits an environment variable.

Measured cost of the shipped defaults (newest 2 + 1.21,1.20,1.12) on the same
200 projects: 3.83 boots/project against today's 3.06, i.e. 1.25x. "Every line"
would be 7.38, or 2.41x, which would break the sizing rule that
SPC_GRINDER_REVERIFY_TTL_DAYS must outlast a full sweep.

The boundary for a red pin does not exist -- nothing to compile a guard
against before the unit does -- so per this repo's convention the mutations
that reproduce the red are quoted instead, both run and observed:

  - `.sortedWith { l, r -> minecraftComparator.compare(r, l) }`
    -> `.sortedDescending()`  (a string compare)
    newestIsOrderedNumerically: expected <[1.20]> but was <[1.9]>
  - `newestCount.coerceAtLeast(1)` -> `newestCount`
    aPolicyThatWouldSelectNothingStillKeepsTheNewestLine:
    expected <[1.21]> but was <[]>

That floor is not defensive tidiness. A candidate that records no verdict is
indistinguishable from one the engine failed on: nothing is stored, so the
freshness check keeps answering "never seen" and the project is re-selected
every sweep forever -- the same shape as SPC_GRINDER_WORKERS=0, a knob that
parsed fine and was unusable.

Nothing calls this yet.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The other half of the selection change. Each line MinecraftLinePolicy selects
gets exactly one target, under the first loader of LOADER_PRIORITY
(NeoForge, Forge, Fabric, Quilt, LegacyFabric) that line has a bootable build
for. A line no loader can boot is dropped rather than reported -- nothing ran,
so a verdict about it would be a verdict about our own selection.

Two compositions needed care, and both are pinned:

  - **A file's versions are narrowed to the line, not just its files.** One
    published file is routinely tagged across lines, and pickBootableCandidate
    takes the newest version it is *shown*. Handing it the whole set lets a
    1.20 line boot at 1.21, which is the one thing a per-line axis exists to
    stop.
  - **A stated loader has to win across loaders, not only within one.** The
    untagged-file fallback fires inside pickBootableCandidate and an untagged
    file matches every loader, so a single pass down the priority order hands
    one to NeoForge while Forge has a file its author actually tagged -- a jar
    staged for a loader that will ignore it, which can boot cleanly and publish
    a false CLEAR. Hence `untaggedFallback` on pickBootableCandidate (defaulting
    to today's behaviour, so no existing caller changes) and two passes.

`everySupportedModloaderHasAPriority` asserts the order against
SupportedModloaders.names rather than a copy: a loader SPC supports but the
order omits would be silently never ground, the same shape as a verdict missing
from the grinder's rank.

No red pin was possible -- nothing to compile a guard against before the unit
exists -- so the mutations were run and observed instead:

  - two passes -> one pass with untaggedFallback = true
    aTaggedFileBeatsAnUntaggedOneEvenForALowerPriorityLoader:
    expected <(Forge, tagged-forge.jar)> but was <(LegacyFabric, untagged.jar)>
  - filesWithin narrowing -> a plain `any { line }` filter
    aFileTaggedAcrossLinesBootsAtTheLinesOwnVersion:
    ... but was <[1.21 Forge mod-wide.jar @ 1.21.1,
                 1.20 Forge mod-wide.jar @ 1.21.1]>
  - LOADER_PRIORITY reversed
    aetherIsGroundOncePerMinecraftLineUnderOneLoaderEach:
    ... but was <[1.21 Fabric ..., 1.20 Fabric ..., 1.12 Forge ...]>

The fixture is CurseForge/aether's real shape, read from the live API on
2026-09-11 -- including `aether-1.20.1-1.5.2-neoforge.jar` being tagged
['NeoForge', '1.20.1', 'Forge'], one file for two loaders. Under the loader axis
that project costs three boots of which two land on 1.21.1; under this one it
costs three that ask three different questions.

Suite: 591 tests, 0 failed, 0 skipped. Nothing calls this yet.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Strangler-Fig step: `verify(project, target, …)` and
`prepareBootPack(project, target, …)` sit beside the loader entry points, which
are untouched and still select for themselves. Nothing calls the new ones yet.

Three parts:

  - `BootOutcome.minecraftVersion`, stamped from the staged pack beside
    `bootedLoader` and `bootedFile`. No layer carried the booted Minecraft
    version at all before this -- the only place it reached disk was
    `BootLogStore.attemptKey` -- so a row could not say which era its evidence
    came from.
  - `prepareBootPack` splits into select-then-stage and stage-a-chosen-target
    over a shared `prepareChosen`, so a caller supplying its own combination
    still gets the loader- and Minecraft-contradiction retries.
  - the newest-build crash re-check takes a `restageOnLoaderVersion` closure
    rather than calling `prepareBootPack(project, loader, …)` itself. For a
    target that difference is the whole point: re-selecting would answer a crash
    on one Minecraft line with a boot on another, which is a different mod's
    worth of code.

Selection is deliberately **not** repeated inside `prepareBootPack(project,
target)`: `pickGrindTargets` is handed `bootableCombination` to choose with, so
a target already satisfies that gate, and asking twice would be a second silent
predicate free to disagree with the first. An unbootable combination still
refuses honestly one step later, naming its own version -- pinned.

`bootableCombination()` becomes public for the same reason: the caller doing the
selecting needs the gate the boot will apply, and a second copy of it is how the
metadata scanners drifted.

Mutation, run and observed (the boundary for a red pin does not exist -- the
entry point is what is being added):

  `prepareBootPack(project, target)` -> `prepareBootPack(project, target.loader)`
  aTargetIsBootedAsChosenRatherThanReSelected:
  expected: <[themod-1.1.jar]> but was: <[themod-26.2.jar]>

`theLoaderEntryPointStillSelectsTheNewestItself` asserts the old entry point in
the same fixture, which is what makes that guard mean something: the two
genuinely diverge there, so honouring the target is a decision and not an
accident of the fixture.

Suite: 594 tests, 0 failed, 0 skipped.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The axis moves off the modloader. `ClientsideVerifier.report` asks
`pickGrindTargets` for one target per Minecraft line rather than mapping over
`project.loaders`, and `LoaderVerdict`/`GrindVerdict` carry the line and the
exact version the pack was staged at.

What a reader sees, on the reported project: CurseForge/aether went from
Fabric + Forge + NeoForge -- two of which were about Minecraft 1.21.1, while its
1.12.2 build was never booted at all -- to 1.21/NeoForge, 1.20/NeoForge and
1.12/Forge. Three boots that ask three questions instead of three that ask one
and a half.

**Why this could not be split by module.** The scratch directory is *named* in
-clientside and *addressed* from -grinder, and it had to gain the Minecraft line
in the same change:

    AttemptDirectory.nameFor(platform, slug, loader)
    -> nameFor(platform, slug, loader, minecraftLine)

One loader now owns several of a project's rows -- NeoForge on 1.21 and on 1.20 --
so the old name would have the second target wipe the first's pack and console
mid-run. That is the `creativecore` failure exactly: two runs sharing a
directory produced SURVIVED and CRASHED for the identical build. `ownerOf` cuts
`SUFFIX_PARTS` trailing segments to match, and a half-applied rename would
silently re-scope the reaper.

Three behaviour changes worth stating plainly:

  - **`loaderDisprovingTheCrash` asks for another *row*, not another loader.**
    Two rows of one project now routinely share a loader and differ by era, and
    a clean 1.20 boot disproves a 1.21 crash for exactly the reason a clean
    NeoForge boot disproved a Forge crash -- they publish the same entry, which
    is `startsWith`-matched and would strip the build proven to boot.
  - **`suggestedEntry` is deliberately unchanged**: still the stem over the
    loader's whole history, because that is what `/as-properties` publishes.
    Narrowing it to a line would publish a pattern missing the builds it was
    never shown, and it is also what lets two lines of one loader disprove each
    other.
  - **`BootLogStore` addresses a tuple by line too**, so `pruneExcept` no longer
    deletes another line's kept consoles. Every log already on disk carries the
    old three-part owner and is therefore unreachable from a row; the budget is
    what reclaims it. `adoptLegacy`'s pre-per-attempt consoles record no
    Minecraft version anywhere, so they are adopted, readable and listed, and
    attributable to no line -- pinned as such, so nobody later "fixes" it by
    guessing one.

Mutations, run and observed:

  - targets `.distinctBy { it.loader }` (i.e. back to one row per loader)
    oneVerdictPerMinecraftLineNotOnePerLoader:
    expected <[(1.21, NeoForge), (1.20, NeoForge), (1.12, Forge)]>
    but was  <[(1.21, NeoForge), (1.12, Forge)]>
  - `other !== verdict` -> `other.loader != verdict.loader`
    aCleanBootOnAnotherMinecraftLineOfTheSameLoaderDisprovesTheCrash: ... was <null>
  - `ownerOf` cutting one part instead of SUFFIX_PARTS
    anAttemptDirectoryNamesTheCandidateThatOwnsIt:
    expected <Modrinth-creativecore> but was <Modrinth-creativecore-Fabric>

Test changes: the ~20 staging tests that build a mods directory through
`nameFor` are reference-only -- one argument added, no expectation touched.
Four are not, and each is the deliberate behaviour change rather than an
accident: `SampledFileMatchesTheBootedFileTest` now asserts a row *per line*
instead of a single row, the two log-column fixtures needed a row identity to
look their logs up by, and the `adoptLegacy` guard swapped one reachability
claim for the honest one above.

Suites: clientside 601 / 0 failed, grinder 516 / 0 failed / 29 skipped
(gated ITs), app 149 / 0 failed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Red, and the first failure is a defect the previous commit introduced: with the
axis moved to the Minecraft version-line but `verdictKey` still built from the
loader, two rows of one project that share a loader collide and one silently
overwrites the other. CurseForge/aether is NeoForge on both 1.21 and 1.20, so
the store would report one era's evidence as the whole project's.

Observed:

  twoMinecraftLinesOfOneLoaderAreTwoRows
    expected: <[1.20, 1.21]> but was: <[1.20]>
  aLineThatChangesLoaderKeepsOneRow
    expected: <[(NeoForge, after)]> but was: <[(Forge, before), (NeoForge, after)]>
  aLineRowSupersedesThatProjectsLegacyLoaderRows
    expected: <[(aether, 1.21), (jei, null)]>
    but was:  <[(aether, null), (aether, 1.21), (aether, null), (jei, null)]>
  theJsonStoreKeysOnTheLineToo          expected <[1.20, 1.21]> but was <[1.20]>
  theJsonStoreSupersedesLegacyRowsAcrossAReopen
    expected <[1.21]> but was <[1.21, legacy]>

Two of the seven pass already, and say why rather than pretending otherwise:
`reGrindingOneLineReplacesOnlyThatLine` and `anUntouchedProjectKeepsItsLegacyRows`
use fixtures whose loaders differ too, so the loader key happens to give the
same answer. They are kept because they pin the contract, not because they
currently discriminate.

The migration half is pinned as deliberately as the key: the deployed store
holds tens of thousands of loader-keyed rows, and they have to go **per project
as it is re-ground** -- never on a schedule, and never before a replacement
exists. `anUntouchedProjectKeepsItsLegacyRows` is the counterweight that stops
the sweep growing to cover projects nothing has re-ground.

Both stores are asserted, because they once drifted apart over a key scheme
before -- the NUL-separator fix -- and the JSON one is asserted across a reopen,
which is where the deployed store actually lives.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Turns the previous commit's pin green, and closes the collision the axis change
opened: `CurseForge/aether` is NeoForge on both 1.21 and 1.20, and with the
loader in the key the second row overwrote the first.

    verdictKey(platform, slug, loader, projectId)
    -> verdictKey(platform, slug, minecraftLine, projectId)

`loader` stays a recorded field and a report column -- it is what produced the
evidence -- it just stops being the row's identity. Keeping it is also what
leaves `/verdicts.json`, `/export.csv` and `GrinderAuditIT` (which hard-fails on
a missing `Loader` header) working unchanged.

**`supersededLegacyKey` could not be extended, and the reason generalises.** It
computes the superseded key from fields the *new* verdict still carries, which
works for the one-to-one `slug:` -> `id:` hop and cannot work here: three loader
rows collapse into one line row, and the line row can name only the loader it
happened to pick. `supersededLoaderKeys` removes by **prefix** instead -- every
key of that project whose last part is not `mc:`-marked -- which is why the line
is spelled into the key rather than merely concatenated.

The migration is per project, as it is re-ground, and deliberately nothing else:
a sweep at load time or on a timer would discard evidence before a replacement
exists, and the deployed store holds tens of thousands of rows. A legacy verdict
recording itself supersedes nothing, or a build predating the change would
delete its own project's neighbours.

Mutations, run and observed:

  - `+ "mc:" + minecraftLine` -> `+ minecraftLine`, and the supersession's null
    guard inverted:
      anUntouchedProjectKeepsItsLegacyRows   expected <2> but was <1>
      aLineRowSupersedesThatProjectsLegacyLoaderRows
        ... but was <[(aether, null), (aether, 1.21), (jei, null)]>
      theJsonStoreSupersedesLegacyRowsAcrossAReopen
        expected <[1.21]> but was <[1.21, legacy]>

Two existing tests asserted the old axis and are **restated**, not deleted --
the stop-and-flag signal, firing on the change it exists for:
`distinctLoadersOfOneProjectCoexist` becomes `twoLoadersOfOneLineAreOneRow` (a
line is ground under exactly one loader, so two such verdicts describe one era
twice), and `recordsOneVerdictPerLoaderFromTheReport` becomes
`recordsOneVerdictPerMinecraftLineFromTheReport`.

The on-disk row order gains the line so a diff of the store stays readable.

Grinder suite: 516 tests, 0 failed, 29 skipped.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A row's identity became the Minecraft version-line, and until now nothing on
`/`, `/export.csv` or `/verdicts.json` said which era a verdict was about --
`jei` simply appeared five times with no way to tell the 1.12 finding from the
26.2 one.

Two `VerdictField` entries, so the header, the CSV cell, the query key, the
filter kind and the sort key all come from one declaration as every other column
does: `Minecraft` (the line, a CHOICE, because "what does this mod do on 1.12?"
is a small closed set) and `MinecraftVersion` (the exact build, beside it for the
same reason `Filename` sits beside `Name-pattern` -- one is the row's identity,
the other is what reproduces the boot).

The sort key is **numeric**, not the cell text. A line sorted as text puts `1.9`
above `1.20`, which is the mistake `BootCandidateSelector.minecraftComparator`
exists to prevent one layer down, and a table cannot report it -- it just looks
like an odd order. The default ordering's tie-break is now newest-era-first
inside a project, which is both the order the grind produces and the one a
reader wants; the loader stays the last tie-break, because a legacy row carries
no line and two of them would otherwise be ordered arbitrarily.

Mutations, run and observed:

  - numeric sortKey -> the bare cell text
    theLineSortsNumericallyRatherThanAlphabetically:
    expected <[1.9, 1.12, 1.20, 26.2]> but was <[1.12, 1.20, 1.9, 26.2]>
  - the era tie-break removed
    aProjectsRowsLeadWithItsNewestEra:
    expected <[26.2, 1.20, 1.12]> but was <[1.12, 26.2, 1.20]>

`Loader` stays exactly where it was, which is what keeps `GrinderAuditIT` -- it
hard-fails on a missing `Loader` header -- and the plugin's feed reading
unchanged. Four fixtures move because a column was added: two hard-coded CSV
header strings, the renderer's ordered sentinel list, and the server test's
header prefix.

A legacy row renders a blank rather than a guessed era: `1.20` would claim
something nobody recorded and `UNKNOWN` reads as though we looked.

Grinder suite: 522 tests, 0 failed, 29 skipped.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The safeguard the axis change owed. A project used to be ground under every
loader it publishes for, so a wrong crash routinely met a clean boot from a
sibling loader *in the same run* and `loaderDisprovingTheCrash` threw it out for
free -- that is the `iron-chests` story, Forge CRASHED beside NeoForge SURVIVED,
same entry. One loader per Minecraft line means nobody boots that sibling unless
something asks. Two halves now do:

  - **`shouldRecheckAgainstOtherVersions` also arms on a *decisive* crash**, not
    only on one the metadata contradicts. A decisive rung reaches CONFIRMED,
    which strips the mod from every server pack built against the fallback list,
    and that is worth one boot whatever the metadata says. In practice this arm
    reaches `OPERATOR_RULE` alone -- the other decisive rungs all prove
    client-only and are excluded above -- which is exactly right: a hand-written
    rule is the one decisive signal nothing else cross-checks. It matters
    especially for CurseForge, which publishes no sideness at all, so the
    metadata gate almost never opened there.
  - **`pickRecheckCandidates` spends its first attempt on the crashing era's
    other loader.** Since every *other* Minecraft line is now a first-class
    verdict the report reconciles against for free, spending the budget there
    re-buys evidence the run produces anyway. The diverse ladder still runs for
    the rest of the budget, so a project with one loader and one line samples
    exactly as deeply as before.

That is also a straight improvement to the case the old ordering was written
for. `creativecore` crashed on Fabric / Minecraft 26.2 while NeoForge booted a
server; the diverse sample reached NeoForge on 1.21.1, two eras away, and the
first pick is now NeoForge / 26.2 -- the very boot that contradicted the crash,
in one attempt instead of two spent on neighbouring Fabric versions.

Mutations, run and observed:

  - the `decisive` arm removed
    aCrashThatIsAboutToBePublishedIsReCheckedEvenIfTheMetadataAgrees:
    expected <true> but was <false>
  - the sibling-loader pick removed
    aCrashIsReCheckedOnItsOwnErasOtherLoaderFirst:
    ... but was <[(CreativeCore_FABRIC_..._mc26.1.2.jar, Fabric, 26.1.2),
                  (CreativeCore_NEOFORGE_..._mc1.21.1.jar, NeoForge, 1.21.1)]>

`aCrashIsReCheckedOnAnotherLoaderRatherThanTwiceOnItsOwn` is renamed and its
expectation restated -- the stop-and-flag signal firing on the change it exists
for, and in the direction its own narrative argues for.
`aCrashThatCannotBePublishedIsStillNotReChecked` is the counterweight: the bare
exit code means only "nothing recognised why", reaches INCONCLUSIVE, strips
nothing, and still buys no boots.

Clientside suite: 610 tests, 0 failed, 0 skipped.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Which Minecraft version-lines a project is ground on becomes operator
configuration rather than a constant, and it is the biggest lever the daemon has
on what a sweep costs -- it multiplies the boots per project.

    SPC_GRINDER_MINECRAFT_LINES_NEWEST   2                 the project's own newest N
    SPC_GRINDER_MINECRAFT_LINE_ANCHORS   1.21,1.20,1.12    older eras, when published

Two knobs rather than one because each covers the other's blind spot: a bare
count never reaches 1.12.2 for a project publishing for sixteen lines (JEI does),
and a bare list goes stale in silence -- a new Minecraft release would simply
never be ground until somebody edited an environment variable.

Measured over the 200 most-downloaded Modrinth mods, 2026-09-11:

    newest 2, no anchors                      2.00 boots/project   0.65x
    newest 2 + 1.21,1.20,1.12  (the default)  3.83                 1.25x
    newest 2 + 1.21,1.20,1.16,1.12            4.35                 1.42x
    every line the project publishes          7.38                 2.41x

against the old per-loader axis's 3.06. That table is in README §5 beside the
knobs, because the number an operator needs is the one that decides whether
SPC_GRINDER_REVERIFY_TTL_DAYS still outlasts a sweep.

An empty anchor list is honoured as "only the newest N" rather than coerced to
the default: it is a legitimate choice, unlike an unparseable number. The
newest-count floor of one stays in `MinecraftLinePolicy`, not here, so every
caller gets it.

The three documentation guards did their job on the first run -- README,
systemd unit, and `everyVariableReadIsDeclaredAsAKnob`, the last because the
reader was wrapped across lines and its regex alphabet matches `intIn("NAME"`
on one. Worth recording: the guard that looks like bookkeeping is the one that
catches a knob nobody can find.

Grinder suite: 522 tests, 0 failed, 29 skipped.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The daemon's rows became one per Minecraft version-line, and the tab had no
column for it — so a project appeared several times with `Loader` as the only
difference, which no longer separates them: one loader routinely holds more than
one of a project's rows (`aether` is NeoForge on both 1.21 and 1.20).

`minecraftLine` is read off the feed as an optional field, exactly as `declared`
and `jarScan` are, so a plugin talking to a daemon older than the axis renders a
blank rather than inventing an era. It sits immediately before `Loader`: the row
identity, then the loader that produced its evidence.

`DECLARED_COLUMN` and `JAR_SIDENESS_COLUMN` are unaffected -- the new column goes
in after both -- and the tests address columns by name rather than by index, so
nothing else had to move.

Mutation, run and observed:

  `verdict.minecraftLine.orEmpty()` -> `""`
  oneProjectsRowsAreToldApartByTheirMinecraftLine:
  expected <[1.21, 1.20]> but was <[, ]>

Plugin suite: 76 tests, 0 failed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Every place that described a verdict as per-modloader now describes it as per
Minecraft version-line, plus the landmines the change created and the narrative
behind it.

Corrected rather than merely appended to, because a stale claim is worse than
none:

  - the store key sentence (README §6, grinder CLAUDE.md) said platform + slug +
    loader
  - the staging paths said `<slug>-<Loader>` -- which was already wrong before
    this, the real name having carried the platform since 2026-08-23
  - "loaders are assessed in sorted order (Fabric, Forge, NeoForge, Quilt)"
  - the `(platform, slug, loader)` scratch-ownership landmine, and "cut only the
    loader suffix when parsing"
  - `loaderDisprovingTheCrash` comparing loaders rather than rows
  - the plugin tab's column list

New: README §5 *What gets ground* (the operator-facing half, with the measured
cost table), an axis section in both module `CLAUDE.md` files, and four lessons
in the root file.

`claude-docs/API-BEHAVIOUR-CHANGES.md` is deliberately untouched: it records
changes to the **published** `-api`, and every module this touched is
unpublished. The wire contract that does have an outside consumer -- the grinder
plugin reading `/verdicts.json` -- is documented where that feed is, in
`grinder/report/CLAUDE.md` and the plugin's own context file.

The log entry also records a defect in one of this branch's own commit
messages: `fix(clientside): re-check a publishable crash…` claims 610 clientside
tests where the measured figure is 603. Written from recall instead of from
`build/test-results/test/*.xml` -- the exact failure "cite names, not snapshots"
exists to prevent, in the one place that convention allows a number.

Counts in the root table re-derived from a full run rather than carried forward:
api 421 (1 skip), clientside 603, app 149, plugin-grinder 75, grinder 529 (29
skip).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The consequence the axis change left behind, closed rather than documented away.

The artifact owner gained the Minecraft version-line, so every console, server
log and crash report already on disk is filed under a three-part owner that
`namesFor` -- which rebuilds a four-part prefix -- can never find. Those are the
consoles behind verdicts that are **still published**, and a CONFIRMED exclusion
has to stay auditable; left alone they would be reclaimed by
SPC_GRINDER_BOOT_LOG_BUDGET_MIB while the verdicts they evidence kept serving.

**The line is not guessed.** The attempt segment beside the owner already
records what was booted -- `<loader>_<loaderVersion>_mc<version>` -- so the part
the owner is missing is sitting in the same file name. `migrateOwnerNames()`
reads it back and renames, at startup beside `adoptLegacy`.

Conservative in three places, each pinned:

  - an owner whose last part already looks like a version-line is left alone, so
    the pass is idempotent;
  - an attempt segment carrying no `_mc` -- `LEGACY_ATTEMPT`, from before
    per-attempt naming -- records no version at all and is left alone rather than
    filed under a guessed era;
  - a failed rename, or one whose target exists, is skipped with a warning: this
    runs at startup and must never stop a daemon that has verdicts to serve.

Mutations, run and observed:

  - the already-migrated check removed
    aSecondMigrationMovesNothing: expected <0> but was <1>
  - an absent version defaulted to "1.20"
    anArtifactRecordingNoMinecraftVersionIsNotMoved: expected <0> but was <1>

`BootCandidateSelector.minecraftLine` becomes public for this. A second copy of
that rule in -grinder is exactly the duplication this repository has paid for
three times; the alternative was re-deriving a version-line from a string in
another module.

The migration fixture is `iron-chests`, deliberately: its slug contains the `-`
that makes an owner unsplittable, which is why the rename appends rather than
rebuilding the name from parts.

Grinder 532 / 0 failed / 29 skipped, clientside 603 / 0 failed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`develop`'s unmodified test tree, run against this branch's production code in a
detached worktree, as the conventions require for a change of this size.

Two signature changes and nothing else fails to compile, across 20 files:
`AttemptDirectory.nameFor` and `BootLogStore.namesFor`/`pruneExcept` each gained
`minecraftLine`. Adapted by adding that one argument and editing no assertion,
develop's guards then ran: clientside 568 with 1 failure, grinder 516 with 7
(29 gated ITs skipped). All eight are the deliberate behaviour change, named
individually in the table, and each is restated on the branch rather than
deleted.

The half worth reading is what did **not** fail:
`recordsOneVerdictPerLoaderFromTheReport` and `distinctLoadersOfOneProjectCoexist`
both pass, because develop's fixtures build verdicts carrying no Minecraft line
-- which is precisely the shape a row written by an older build has, and those
still key on the loader. The legacy path is exercised by 1,084 guards that know
nothing about it.

Also records what is still outstanding and needs a host this session did not
have: the end-to-end aether run against Docker with a CurseForge key.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Reference-only, once the behaviour was settled:

    LoaderVerdict             -> GrindTargetVerdict
    ClientsideReport.perLoader -> perTarget
    loaderDisprovingTheCrash  -> targetDisprovingTheCrash
    supersededByLoader        -> supersededByTarget
    loaderVerdict(...)        -> targetVerdict(...)   (test fixture)

A collection called `perLoader` holding one entry per Minecraft era is the kind
of stale name this repository treats as a defect, and the guard it fronts really
does now look for another *row* (`other !== verdict`) rather than another loader.

`refactor:` is honest here under the conventions' own carve-out: every hunk in
the test tree is a receiver or a type name, and no assertion, argument or
expected value changed -- verified by filtering the diff for anything that is not
one of the five renames, which comes back empty.

`GrindVerdict.loader` and `GrindTargetVerdict.loader` are untouched: the loader
is still recorded, still a report column, and still what produced the evidence.
It just stopped being the row's identity.

`claude-docs/ANALYSIS-AUDIT.md` and `REFACTOR-AUDIT.md` are deliberately left
spelling the old names, as are every `REFACTOR-LOG.md` entry before today's:
they record what was true when they were written, and rewriting a historical log
to match today's symbols is how a record stops being one. Today's entry says so.

Suites green: clientside, grinder and app.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
First of the two structural dependency-failure fixes, from reading all 40
DEPENDENCY_FAILURE consoles on the public grinder (2026-09-11). Five of them are
this: **a jar-in-jar library is on the classpath exactly like a staged one, so
its demands bind exactly like a staged one's** -- and nothing read them.
`BundledJars` reported only what a nested jar *provides*.

Two live failures, each verified by opening the published jar:

  - `Modrinth/highlight` declares `depends: { "resourcefullib": "*" }` and ships
    META-INF/jars/resourcefullib-fabric-26.2-5.0.3.jar, so the requirement was
    rightly dropped -- the library is already inside. That bundled jar then
    declares `depends: { "fabric-api": "*" }`, which nothing read, so Fabric API
    was never staged. The boot died with "Resourceful Lib requires any version of
    fabric-api, which is missing" and the INCONCLUSIVE was charged to `highlight`,
    whose stagedDependencies was empty.
  - `quilted-fabric-api-11.0.0-alpha.3+0.102.0-1.21.jar` bundles
    `qsl_base-10.0.0-alpha.1+1.21.jar`, which pins `minecraft [1.21, 1.21]` --
    exactly, not a line. Staged into a Minecraft 1.21.1 pack it refused the whole
    pack; QFAPI's own top-level descriptor says nothing that would predict it.
    Four rows: notenoughrecipebook and shatterbyte-lib, on both platforms.

`BundledJars.requirementsIn` and `minecraftDemandsIn` are separate because the
consequence is: an unmet mod dependency is *staged*, while a bundled jar built
for another Minecraft can only be answered by dropping the jar that carries it.
`minecraft` is therefore excluded from the first and is the whole of the second.

Both keep the class's existing restraint. Only jars the descriptor *declares*
count -- a stray file under META-INF/jars/ is not on the classpath. A range that
is not a plain string (Quilt permits an object, Fabric an array of alternatives)
yields no opinion rather than a guess. An unreadable jar demands nothing. And
the loader's own ids are left in, because `stageableRequirements` is the one
place that decides what the environment provides.

Pinned at both levels, because a correct unit no caller reaches is this module's
most-repeated failure. Mutations, run and observed:

  - the staging wiring removed
    aLibraryDemandedOnlyByABundledJarIsStillStaged:
    expected <[hightlight-26.2-4.2.0.jar, fabric-api-0.160.0.jar]>
    but was  <[hightlight-26.2-4.2.0.jar]>
  - the nested Minecraft pin removed
    aDependencyBundlingAJarThatExcludesThePacksMinecraftIsDemoted:
    expected <[some-lib-1.0.0.jar, some-mod-1.0.0.jar]>
    but was  <[some-lib-2.0.0.jar, some-mod-1.0.0.jar]>

Clientside suite: 612 tests, 0 failed, 0 skipped.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Second structural dependency-failure fix, and the biggest single group: **12 of
the public grinder's 40 DEPENDENCY_FAILURE rows** are `fabric-language-kotlin`
demanding `fabricloader [0.19.5, ∞)` against the `0.19.3` quilt-loader 0.30.1
provides. Read from the published jars:

    quilt-loader 0.30.1        provides fabricloader 0.19.3
    quilt-loader 0.31.0-beta.4 provides fabricloader 0.19.5

The demand was invisible at three layers, and all three had to move:

  1. **The scanner strips it.** `FabricScanner.dependencyExclusions` drops
     `(fabricloader|java|minecraft)` and Quilt's does the same -- correctly, since
     those are the platform rather than mods to stage, and reporting them would
     have `ModListCompiler` try to rescue a loader into a pack. So
     `BundledJars.demandsOn` reads them back off the staged jar, narrowed to the
     ids the loader could actually describe: it can only add a comparison that can
     be made, never a duplicate of what the scanner already reports.
  2. **Nothing knew what a loader provides.** New `loaderProvides` seam on
     BootVerifier, defaulting to knowing nothing -- which is the old behaviour, and
     `aLoaderNothingCanDescribeDemotesNothing` pins that a gap in our knowledge
     never demotes anything. The grinder implements it by reading the cached
     install layer's own loader jar, because a table of loader->provides pairs
     would be a snapshot going stale with every release.
  3. **DependencyBacktrack skipped it.** A requirement naming something not staged
     is skipped by design; with the pair in hand `fabricloader` is present, the
     conflict is real, and the demanding jar is demoted to a build the installed
     loader can satisfy.

Also, the half that made this hard to see at all: **every one of 16 of 16 Quilt
boots printed `Quilt Loader 0.30.1` while its verdict reported
`Quilt 0.31.0-beta.4`** -- the build staging chose. `BootLoaderVersion` now reads
the build the console announces and states the disagreement in the detail, so a
row stops claiming a build it never ran. Every pattern in it is verbatim from a
kept console. The newest-build re-check is deliberately **not** armed off it:
if the installer keeps producing 0.30.1 that would cost a boot per row and fix
nothing, and why 0.30.1 is installed for a tuple labelled 0.31.0-beta.4 needs the
daemon's install cache to answer.

Mutations, run and observed:

  - the loader's provides removed from the judge's staged set
    aDependencyDemandingMoreThanTheLoaderProvidesIsDroppedToAnOlderBuild:
    expected <[YetAnotherConfigLib-3.4.2.jar, Zoomify-2.13.3.jar]>
    but was  <[Zoomify-2.13.3.jar, yet_another_config_lib_v3-3.6.6.jar]>

Suites: clientside 621 / 0 failed, grinder 532 / 0 failed / 29 skipped.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`pickUntagged`'s safety argument is that untagged CurseForge files are pre-1.13,
from before the platform had a modloader facet, so "only Forge is reachable and
untagged *means* Forge". Measured against the live API on 2026-09-11, that is
false: `TerraBlender (Forge)` publishes

    TerraBlender-forge-26.2-26.2.0.0.2.jar   gameVersions=['26.2']

untagged, for Minecraft 26.2, in 2026. So it matched a **Fabric** boot and a
**NeoForge** boot alike, `biomes-o-plenty` was staged the Forge build of its own
dependency on both, neither loader could see it, and `terrablender` came out
`[MISSING]` in two published rows.

How it got there is worth recording, because the platform metadata was fine:
only BoP's newest 4 Fabric and 5 NeoForge files declare a required TerraBlender
ref at all -- 26 NeoForge and 13 Fabric files declare none. The booted (older)
file therefore had nothing on the platform route, its manifest id `terrablender`
fell through to the slug guess, and slug `terrablender` is the **Forge** project
(563928), whose files are untagged.

The fallback now applies only below Minecraft 1.13. Above it an untagged file is
genuinely unknown and a refusal naming the real gap beats a jar the loader will
ignore; below it the fallback stays, which is what keeps `mtlib` -- all 15 of its
files untagged, all 1.12.2 -- gradeable at all.

**The candidate's own fallback is deliberately untouched.** `pickBootableCandidate`
keeps it at every version, because `refuseForSelfDeclaration` reads the
downloaded jar's descriptor before the boot and refuses one carrying another
loader's. A dependency gets no such guard, which is the whole asymmetry.

Mutation, run and observed:

  the version gate removed
  anUntaggedDependencyIsNotPickedWhereThePlatformTagsLoaders:
  expected <null> but was <ModFile(fileName=TerraBlender-forge-26.2-26.2.0.0.2.jar, …)>

Clientside suite: 623 tests, 0 failed, 0 skipped.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Two of the smaller dependency-failure fixes from the public grinder's 40 rows.

**The range lives in the jar, not in the ref.** `ModFile.requiredDependencies`
carries opaque platform ids and no version, so the platform route called
`pickDependencyFile` without a constraint and took the newest build for the
Minecraft version -- even where the candidate's own descriptor had demanded a
specific one. Measured 2026-09-11: `cobblemon-additions` demands
`cobblemon >=1.7.1` and was staged `Cobblemon-fabric-1.6.1+1.21.1`;
`create-enchantment-industry` pins `create_dragons_plus 1.11.4-p1` and was staged
`1.11.8`. `PlatformDependencyDemand.demandedConstraint` finds it through the same
fuzzy id-to-slug match `isDemanded` already makes -- one matcher, not two -- and
it stays a **preference** downstream, since `pickDependencyFile` narrows by the
constraint and then falls back to the whole set.

**`ClassMetadataNotFoundException` moves from `dependency-failure` to
`mixin-apply-failure`.** It is a mixin subsystem exception whatever it was
reaching for, and in that sample it caught two rows that are nothing of the kind:
`ars-nouveau` reaching `net.minecraft.core.BlockSourceImpl` (a class its
Minecraft no longer has) and `yungs-better-caves` reaching MixinExtras'
`Operation`. Both stay INCONCLUSIVE -- the rungs are neighbours -- so nothing
published changes; what changes is that the `Decision` column, which is how an
operator filters, stops calling a mixin failure a missing dependency. The
`MixinTweaker` miss deliberately stays a dependency failure: a 1.12.2 coremod
really is an absent dependency, and `theMissingMixinTweakerStaysADependencyFailure`
is the counterweight that keeps the move from widening.

Clientside suite: 628 tests, 0 failed, 0 skipped.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Seven of the public grinder's 40 DEPENDENCY_FAILURE rows name a library that
exists on both platforms under a slug the mod id does not spell, so the
optimistic slug guess found nothing, the boot went ahead without the library,
and the loader refused the pack:

    obscure_api      aquamirae                    -> obscure-api
    farmersdelight   nethers-delight              -> farmers-delight   (CF 398521)
    refinedstorage   refined-storage-addons x2    -> refined-storage   (CF 243076)
    kotlinforforge   slice-and-dice, via kubejs   -> kotlin-for-forge  (CF 351264)
    rhino            create-enchantment-industry  -> rhino             (CF 416294)
    wover            betternether                 -> worldweaver       (CF 1037172)

**A search was tested and rejected before adding these**, which is the part worth
keeping: CurseForge answers `farmersdelight` with "Dirty Bowls Delight",
`refinedstorage` with "RSExtendedCrafting" and `rhino` with "TS Modify", while
Modrinth answers `kotlinforforge` and `obscure_api` with nothing at all. A text
search would have staged somebody else's mod into the pack -- the exact trap
`modIdForSlug`'s exact-match rule exists to avoid. The table is the right
instrument here precisely because it cannot guess.

Verified, not assumed, as this table's own rule demands. Every Modrinth ref was
checked by downloading that project's newest jar and reading the id out of its
descriptor (`obscure-api` declares `obscure_api`, `worldweaver` declares `wover`,
and so on); every CurseForge id by its published file names carrying the id --
`kotlinforforge-5.12.0-all.jar`, `refinedstorage-neoforge-2.0.9.jar`,
`worldweaver-26.101.2.jar`. That is the same standard the QSL entry above was
added under.

CurseForge is left unmapped for `obscure_api` alone: it is published there as
"Obscure API [Forge Edition]", which implies a sibling edition a single ref would
send every Fabric boot to. Same reason `tacz` carries no numeric id, and the
opposite of inventing one -- pinned, so the gap reads as deliberate.

Suites: clientside 630, grinder 532 (29 skipped), app 149, plugin-grinder 75 --
0 failed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Every row traced to its kept console and its cause verified against the live
platform APIs, grouped by cause rather than listed. 28 were ours and are fixed
across the six preceding commits; the rest are recorded with reasons so they are
not re-opened -- three upstream-unsatisfiable, two Sinytra Connector, four never
dependency failures at all, one closed by the per-line axis.

Four lessons that outlive the incident:

  - a demand can be invisible at several layers at once, and fixing one changes
    nothing (the fabricloader case needed three edits before one row moved);
  - a documented safety argument is a claim about the world, and the world
    changes -- "untagged means Forge" was true when written and is now false;
  - test the tempting fix before building it: a name search for the unresolved
    ids returns other people's mods, and that negative result is worth more than
    the table that replaced it;
  - what we asked for is not always what ran, and the report should say so --
    the same defect class as the aether row, one layer down.

Also records the one question the report cannot answer: why a tuple labelled
quilt-loader 0.31.0-beta.4 holds 0.30.1, which needs the daemon's install cache,
and why the newest-build re-check is deliberately left unarmed until it is.

Clientside count in the root table re-derived from a full run: 630.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Red. Measured across all 4475 rows of the public grinder on 2026-09-12: **27
rows across 16 projects** are published as clientside while their own boot
reached the ready line and their metadata claims server support -- `agricraft`,
`galosphere`, `zombie-awareness`, `immersive-lanterns`, `joy-of-painting` among
them. Those go to /as-properties, so each is stripped from every server pack
built against the list.

`CurseForge/agricraft` is the clearest, read from its kept console:

    [ERROR] [RuntimeDistCleaner/DISTXFORM]: Attempted to load class
        net/minecraft/client/gui/Gui for invalid dist DEDICATED_SERVER
    [ERROR] [FMLModContainer/LOADING]: Failed to register automatic
        subscribers. ModID: agricraft, class com.agri...

One `@SubscribeEvent` class in the NeoForge build touches a GUI class. Its
Fabric and Forge builds each boot a dedicated server to the ready line, the
platform declares SERVER and the jar scans SERVER_OR_BOTH -- and all three rows
publish. A crop-breeding mod.

The inference propagation rests on -- *a mod's features do not change with the
loader* -- is invalid exactly when the reaching is one build's bug, and a
sibling's clean boot **on a mod that claims the server** is what says so. That is
the same contradiction `shouldRecheckAgainstOtherVersions` already treats as
"one of these two signals must be wrong".

Observed:

  aCleanBootOnAModClaimingTheServerIsNotOverruled:
  expected: not equal but was: <CONFIRMED>

Two counterweights are committed green beside it, because the gate must be
narrow or it destroys the case propagation exists for: `sodium` declares
`server_side: unsupported` so the gate never opens for it, and a survival
*borrowed* from a third loader does not open it either -- the same
`bootedLoader == loader` landmine `targetDisprovingTheCrash` already guards.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Turns the previous commit's pin green, and fixes the audit that could not see
the problem.

**The publication half.** `propagateClientOnlyProof` no longer confers CONFIRMED
on a row whose own boot reached the ready line and whose mod is declared
`SERVER`. Measured on the live store 2026-09-12: **22 rows across 12 projects** --
agricraft, galosphere, modonomicon, zombie-awareness, immersive-lanterns,
joy-of-painting, toadlib and more -- were published as clientside on a sibling
build's crash while booting dedicated servers themselves.

The gate is narrow in three directions, and each matters:

  - the claim is `Declaration.SERVER` (platform **and** jar agreeing), never
    `declaresServerSupport`, which accepts `JarScan.SERVER_OR_BOTH` -- and
    SERVER_OR_BOTH is also what a scan that read *nothing* returns. The weak
    reading matches 27 rows, this one 22, and the five it drops are
    CONTRADICTORY, where by this module's own rule neither source is evidence;
  - `sodium` declares `client_side: required`, so the gate never opens for it and
    its Fabric entry is still excluded -- the case propagation was written for;
  - the survival must be the row's **own**, the `bootedLoader == loader` landmine
    `targetDisprovingTheCrash` already guards.

The cost is accepted and is the cheaper direction: a mod whose metadata wrongly
claims the server and boots cleanly stops inheriting -- `controlify` is one -- so
it ships unused into a server pack. A false positive strips a working mod out of
every pack built against the list.

**The audit half, which is why this went unnoticed.** An inherited proof lived
only in the detail's prose, so a row's `decidedBy` stayed its own boot rung and
`GrinderAuditIT` -- which re-derives evidence from the kept consoles -- read
**86 of 140** published rows as resting on none. The guard built to catch wrong
publications was failing wholesale on a design working as intended, and an audit
that cries wolf gets ignored. `inheritedProofFrom`/`inheritedProofRule` are now
fields, carried to `GrindVerdict` and shown as the `Inherited proof` column, and
the audit grades such a row at the sibling that owns the evidence instead.

The audit had a second break this branch introduced: it built the old
three-part `platform-name-loader` tuple, which matches no console now that the
owner carries the Minecraft line -- so it would have *assume-skipped* with "no
kept console belongs to a published CONFIRMED". A green run that graded nothing
is the worst outcome an audit has.

Mutation, run and observed:

  the gate removed
  aCleanBootOnAModClaimingTheServerIsNotOverruled:
  expected: not equal but was: <CONFIRMED>

Suites: clientside 633 / 0 failed, grinder 532 / 0 failed / 29 skipped.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`com.mojang.blaze3d` was not in the client-only marker, which matched only
`net.minecraft.client`. Measured on the public grinder 2026-09-12:
`Modrinth/vulkanmod` -- a Vulkan *renderer*, metadata CONTRADICTORY -- crashed with

    Caused by: java.lang.NoClassDefFoundError: com/mojang/blaze3d/systems/RenderSystem

and was filed INCONCLUSIVE off the bare exit code, publishing nothing. A lost
true positive, in the one direction this engine cannot afford: finding exactly
that contradiction is what the container is paid for.

Safe to trust over the exit code for the same reason its neighbours are -- a
dedicated server ships no rendering layer, so no environment failure can
fabricate it -- and it belongs in `client-only-class` rather than beside it
because it proves the same thing about the *mod*, not about one build.

One row in the current store, and the marker is what generalises: this is the
first of the three decisive markers to be extended since they were written, and
it was found by reading the 66 EXIT_CODE consoles rather than by guessing at
patterns.

Mutation, run and observed: with the alternation removed,
`reachingMojangsRenderingLayerIsClientOnlyEvidence` fails on both spellings.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`UnmetReason.DROPPED_BY_BACKTRACK` mapped to `PreventionCause.HOST`, so a
dependency whose every candidate build was demoted published `ERROR` -- whose
contract is "an operator's problem, never evidence about the mod". Measured on
the public grinder 2026-09-12: **6 of its 7 ERROR rows** were exactly this,
telling an operator their host was broken over `bellsandwhistles` needing a
`create-fabric` build whose every candidate conflicts.

The old reasoning -- "staging dropped those builds itself" -- describes the
*mechanism*. `preventionCause` is about the *blame*, and staging only ever drops
a build because something upstream **declared** an incompatibility: a version
range one jar states about another, or a Minecraft range a jar states about
itself. Neither is a host failure and no operator can act on either; the host
worked perfectly. Running out of backtracks is not this case at all --
`dependencyToDemote` then logs and boots anyway rather than refusing.

This is the residue the module's own CLAUDE.md already recorded as open, which
said it needed "a second exclusion channel" to tell "we dropped it" from "we
dropped it because upstream's builds do not fit". It does not: both things that
reach this reason are upstream declarations, so there is nothing to tell apart
and the channel would have been 12 signatures of plumbing for the same answer.

The fold still protects the loud case, and that is now pinned with a reason that
genuinely is ours: `preventionCauseFor` takes the most actionable cause present,
so a backtrack drop beside a real `DOWNLOAD_FAILED` is still HOST and still
reaches the operator who can retry it.

`ourOwnFailureOutranksEveryOtherCause` asserted the old blame and is restated,
not deleted -- the stop-and-flag signal firing on the change it exists for.

Clientside 635 / 0 failed, grinder 532 / 0 failed / 29 skipped.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The whole store measured rather than sampled -- 4475 rows -- and the buckets
worth acting on read one by one. Records the four fixes and, more usefully, why
none of them was visible from the code alone.

  - **A guard that cannot fail is worse than no guard, and it fails silently in
    two ways**: by matching nothing (GrinderAuditIT's pre-axis three-part tuple,
    which assume-skips to green) and by matching everything (86 false alarms
    from inherited proofs). Whenever a naming scheme or a verdict path moves,
    ask what the audit now matches.
  - **Evidence must be a field.** The propagation was correct and its reasoning
    was recorded -- in prose, which is not queryable, so the one mechanism that
    checks publications could not see it.
  - **A predicate correct for one question can be wrong for another.**
    `declaresServerSupport` arms the crash re-check, where accepting an unread
    jar scan is conservative; as a gate on *publication* the same leniency opens
    on most of the catalogue.
  - **"Deliberately ours" can be a mis-blame rather than a decision.** The
    recorded residue said closing DROPPED_BY_BACKTRACK needed a second exclusion
    channel; it needed re-reading the sentence.
  - **Reading 66 consoles produced one rule, and disproved a hypothesis.** The
    EXIT_CODE bucket is mostly genuine runtime version mismatches, correctly
    INCONCLUSIVE; knowing that is worth as much as the marker it did yield.

Also closes the "known residue" note in the clientside module context, which is
no longer true, and re-derives the root table's clientside count from a full
run: 635.

Suites: api 421 (1 skip), clientside 635, grinder 532 (29 skip), app 149,
plugin-grinder 75 -- 1812 tests, 0 failed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
merge: grind per Minecraft version-line, and the report defects that exposed
Some checks failed
Continuous / Build JAR (push) Has been cancelled
Continuous / Build AppImage (x86_64) (push) Has been cancelled
Continuous / Build AppImage (aarch64) (push) Has been cancelled
Continuous / Build Install4J Media (push) Has been cancelled
Continuous / Continuous Pre-Release (push) Has been cancelled
Docker Test / build image (push) Has been cancelled
Documentation / Writerside webhelp (push) Has been cancelled
Documentation / Help image (push) Has been cancelled
Qodana / scan (push) Has been cancelled
Qodana / notify (push) Has been cancelled
Test / build (push) Has been cancelled
b88ca73678
Two pieces of work, both driven by the live grinder rather than by reading code.

**The axis moves from the modloader to the Minecraft version-line.** A project is
ground once per line under a single loader (NeoForge > Forge > Fabric > Quilt >
LegacyFabric), because sideness is a property of a *build* and builds differ far
more across Minecraft eras than across loaders of one era. Measured over the 200
most-downloaded Modrinth mods: the old axis spent 3.06 boots per project covering
a mean of 1.6 distinct lines, and CurseForge/aether's 1.12.2 build -- a wholly
separate codebase -- was never booted under any loader. Defaults cost 1.25x the
old boot count; the knobs and that table are in README §5.

The reported aether row turned out not to be a dependency-resolution bug at all:
`Filename` named `aether-1.12.2-v1.5.4.1.jar` while the evidence belonged to the
1.20.1 jar staging had selected. Two selections for one row, fixed first and on
its own.

**Then the public grinder's own report, read row by row.** All 40
DEPENDENCY_FAILURE consoles traced and every cause verified against the live
platform APIs: 28 were ours, across six fixes -- jar-in-jar demands, a demand the
loader itself must satisfy, six verified mod-id aliases, the untagged-file
assumption, the declared version range, and two mis-filed rungs. Then the whole
store, 4475 rows, which surfaced the finding that matters most: **22 rows across
12 projects were published as clientside while booting dedicated servers of their
own** -- agricraft, galosphere, zombie-awareness among them -- and the audit built
to catch exactly that was reporting 86 false alarms while also, after the axis
change, matching no console at all.

Every behaviour change is pinned and mutation-verified; the two module CLAUDE.md
files carry the landmines and `claude-docs/REFACTOR-LOG.md` the narrative,
including what was tested and *disproved* (a name-search fallback for unresolved
mod ids returns other people's mods; the EXIT_CODE bucket does not hide
wrong-Minecraft staging).

`develop`'s unmodified test tree was run against this branch's production code:
two signature changes, nothing else failing to compile, and 8 assertion failures
out of 1,084 -- each the deliberate change, each restated rather than deleted.

Suites: api 421 (1 skip), app 149, clientside 635, grinder 532 (29 skip),
plugin-example 3, plugin-grinder 75 -- 1815 tests, 0 failed.

Still open, and it needs the host: why a tuple labelled quilt-loader
0.31.0-beta.4 holds 0.30.1.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Red: `isConnectorPlaceholder` reads `META-INF/mods.toml` and nothing else, so
the identical marker in `META-INF/neoforge.mods.toml` says nothing.

The fixture is `continuity-3.0.0+1.21.neoforge.jar`'s shape, read from the live
file on 2026-09-12: the same `[properties] "connector:placeholder" = true`, the
same `fabric.mod.json` beside it declaring `"environment": "client"`, at the
path NeoForge moved its descriptor to on Minecraft 1.20.5.

Measured the same day on the public grinder, that one path cost the project its
1.21 verdict: the NeoForge row read `SERVER_OR_BOTH` off the stub -- whose
`[[dependencies]]` entries carry no `side`, which `ForgeTomlScanner` reads as
*assume SERVER* -- and published `CONTRADICTORY` against a platform declaring
`client_side=REQUIRED`, while the same project's Forge row on the 1.20 line read
`CLIENT` off the identical `fabric.mod.json`.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
NeoForge moved its descriptor to `META-INF/neoforge.mods.toml` on Minecraft
1.20.5, and a Connector placeholder built for that era stamps its marker there.
`isConnectorPlaceholder` read `META-INF/mods.toml` and nothing else, so it
answered "not a placeholder" for every wrapped jar newer than the rename.

Searching both, and only these two, because the marker lives in a TOML
`[properties]` table and no other descriptor has one. Deliberately version-blind:
the marker means the same thing wherever it appears, and `descriptorsFor` would
need a Minecraft version this question does not have, then answer with a subset
of what it must search. Each descriptor parses inside its own `runCatching`, so
an unparseable first one cannot mask a marker in the second.

Verified against the live files rather than the fixture alone:
`continuity-3.0.0+1.21.neoforge.jar` now reads `true` and
`continuity-3.0.0+1.20.1.forge.jar` still does, while `sodium-neoforge-0.9.2`,
`entityculling-neoforge-1.10.5`, `moreculling-neoforge-1.0.10`,
`MouseTweaks-neoforge-2.31`, `aether-1.20.1-neoforge` and continuity's own two
native Fabric jars all still read `false`.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Red for the right reason, twice: `declaredLoaders` answers
`[Fabric, Forge, NeoForge]` for a placeholder because the stub counts as a
declaration, and `contradictingLoaders` therefore returns `[]` and lets the shim
keep the boot.

Under the per-line axis that costs the whole Minecraft line. Measured on the
public grinder 2026-09-12: `Modrinth/continuity`'s 1.20 row booted
`continuity-3.0.0+1.20.1.forge.jar` and died on the stub's own version-less
dependency entries -- `Expected range: '', Actual version: '1.0.0-beta.49+1.20.1'`,
Forge reading an absent `versionRange` as a range matching nothing, so both
dependencies were staged, both were loaded, and both were refused -- while
`continuity-3.0.0+1.20.1.jar`, the release a Fabric user installs for the same
mod and the same Minecraft version, was never booted at all.

`MetadataScanner` has redirected the scan to Fabric since 2026-09-06. These pin
the boot making the same call.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A placeholder's TOML is a stub that exists to get the file past Forge's mod
discovery until Connector takes over, so it declares nothing about the loader
that reads it. `declaredLoaders` now discounts the stubbed path, which makes
`contradictingLoaders` refuse the Forge/NeoForge boot and name Fabric -- and
`reselectOnLoaderContradiction` then re-stages the same file under Fabric, the
loader its `fabric.mod.json` names. No new machinery and no extra container: the
refusal happens before one is spent.

Only the stub's own path is discounted, so a placeholder carrying a real second
TOML would still name that loader, and the marker read moved behind
`carriesPlaceholderMarker` so `declaredLoaders` asks it without a second open of
the archive.

This is the boot making the call `MetadataScanner.descriptorLoaderOf` has made
for the scan since 2026-09-06, so the two stop disagreeing about one fact. It
reverses the B36 note that a Connector setup is still worth verifying: that was
written when the Forge row was the project's only Forge evidence, and the
per-line axis made the shim cost the whole Minecraft line instead.

Verified against the live files: both `continuity-3.0.0+1.20.1.forge.jar` and
`continuity-3.0.0+1.21.neoforge.jar` now declare `[Fabric]` and are refused for
Forge and NeoForge alike while accepted for Fabric, and `sodium-neoforge-0.9.2`,
`entityculling-neoforge-1.10.5`, `moreculling-neoforge-1.0.10`,
`MouseTweaks-neoforge-2.31` and `aether-1.20.1-neoforge` all answer exactly as
they did before.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`Iceberg-1.20.1-forge-1.1.25.jar` declares `[[dependencies.iceberg]]
modId="forge" versionRange="[47.2,)"`, and Modrinth ticks it `forge, neoforge`,
so LOADER_PRIORITY took NeoForge for the 1.20 line. NeoForge's 1.20.1 fork froze
at 47.1.106 and registers under the mod id `forge`, so the console read "Mod
iceberg requires forge 47.2 or above" and the line published INCONCLUSIVE --
while Forge 1.20.1 is at 47.4.23 and satisfies it outright. The descriptor gate
could not see it: `mods.toml` names Forge *and* NeoForge on 1.20.1, so the
requested loader was declared and nothing reopened the choice.

`contradictingLoaders` now also asks whether each declared loader's newest build
can satisfy what the jar demands of it, and names the reachable ones so
`reselectOnLoaderContradiction` re-stages under Forge. `latestVersion`, not
`preferredVersion`: the question is whether the ecosystem contains a build the
jar accepts, and a cache preference for an older build must never condemn a
loader.

Fails toward accept throughout, and never fires when nothing is reachable --
there would be nothing to re-select to, and throwing the candidate away is the
expensive outcome, not the safe one.

The range is read here rather than through `ForgeTomlScanner`, which consumes
the platform entry for sideness and discards its `versionRange`; that also keeps
a `-clientside` gate from putting a requirement on the published `-api`.

**No red pin was possible** -- the guard needs a `contradictingLoaders` overload
that did not exist, so it could not compile against the unfixed code. The
mutation that reproduces the red is, in `contradictingLoaders`:

    - if (loader in declared && loader !in reachable && reachable.isNotEmpty()) {
    + if (false && loader in declared && loader !in reachable && reachable.isNotEmpty()) {

which fails `aLoaderThatCannotReachTheDemandedBuildNamesTheOneThatCan` with
`expected: <[Forge]> but was: <[]>` and leaves the two accepting guards green.

One signature change, enumerated rather than worked around:
`BootVerifier.refuseForSelfDeclaration` gained a defaulted `loaderVersionFor`,
which moved the trailing-lambda position, so `LoaderReselectionTest`'s one call
names `minecraftConstraint` explicitly. No assertion, argument or expected value
changed.

Verified against the live files: `Iceberg-1.20.1-forge-1.1.25.jar` refuses
NeoForge with "declares NeoForge '[47.2,)', but the newest build for Minecraft
1.20.1 is 47.1.106" and accepts Forge, while `aether-1.20.1-neoforge.jar` --
which demands `[47.1.0,)`, a range 47.1.106 does satisfy -- is refused by
neither.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The Connector-placeholder landmine said `isConnectorPlaceholder` reads
`META-INF/mods.toml`, which is what let the NeoForge-era marker go unread for
six days, and it still recorded the 2026-09-06 call that the shim's boot is
attempted anyway. Both are now current, with the FML lines that show both
dependencies were present and loaded and the stub's empty ranges refused them.

New entry for the loader-version gate, with the iceberg measurement, the
`latestVersion`-not-`preferredVersion` reasoning, the never-fires-when-nothing-
is-reachable rule and `aether-1.20.1-neoforge.jar` as the near-miss control.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
B36 asked for Sinytra Connector as a boot strategy. The opposite landed: a
placeholder is now refused for the loader its stub names and verified under
Fabric, so there is no Connector boot left to make work. Deleted per this file's
own rule, with the drop recorded in REFACTOR-LOG.md.

Also fixes two counts this file stated about itself and that its own "cite names,
not snapshots" lesson warns against: it claimed to be empty while holding two
items, and named B34 as the highest ID issued. Both are now derived from the one
command that cannot go stale.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
One DEPENDENCY_FAILURE row on the public grinder, and what reading its console
turned up: a Connector placeholder booted under the loader its stub names, the
same placeholder redirect blind to NeoForge's renamed descriptor six days after
it was written, and a jar demanding a loader build that loader never shipped.

Records the B36 reversal and its reason, and the 45-row CONTRADICTORY bucket
that was checked against four live jars and is *not* ours -- so it is not
re-opened as a bug later.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Counts re-derived from build/test-results after a full run rather than adjusted:
clientside 635 → 641 (six new guards), grinder 529 → 532.

The three lessons are the ones that outlive their incident: a fix keyed on a file
name covers only that spelling, a recorded decision holds only under its premises,
and a choice made on metadata needs a way for the artifact to re-open it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The predicate guards in `JarSelfDeclarationTest` prove what `contradictingLoaders`
answers; these prove the answer is acted on. Driven through the real
`prepareBootPack` over jars written to disk, and observed through the
loader-version policy, which `stageBootPack` asks once per attempt -- so the
staging sequence *is* the assertion.

A Connector placeholder stages `[Forge, Fabric]`; a `mods.toml` demanding
`forge [47.2,)` requested as NeoForge stages `[NeoForge, Forge]`.

Both have teeth, checked by mutation: disabling the stub discount
(`stubbedDescriptors = emptyList()`) and the reachability arm (`if (false && ...)`)
turns exactly these two red and nothing else.

**The second guard first passed for the wrong reason** and the fixture was fixed,
not the assertion: it used `sharedTomlRelease`, which is 1.20.4, where NeoForge
registers as `neoforge` and a `forge` dependency entry is correctly not a
statement about it. The case is the parity release alone, so the fixture now
derives it from `LoaderCompatibility.alsoRuns` rather than writing "1.20.1" a
second time.

`RecordingPolicy` gained a defaulted `builds` map so a loader's published build
number can be stated; every existing use is unchanged.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`sharedTomlRelease` was already unreferenced before this branch -- one
declaration, no uses -- while its doc claimed to be "where the version retry
exists to reach". A fixture that describes a purpose it no longer serves is the
stale-comment problem with a compiler-shaped disguise, and this file now has a
`parityRelease` next to it that a reader could easily mistake it for.

Behaviour-preserving in the strict sense: no assertion, argument or expected
value changed, and the suite is green at 641.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
641 → 643. The number was correct when written and stale two commits later,
which is the snapshot trap this file's own "cite names, not snapshots" rule
warns about -- re-derived from build/test-results, not adjusted by hand.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
717 live rows: all 26 published CONFIRMED belong to 7 projects, and every one of
the 8 resting on its own evidence was booted under NeoForge. No Fabric or Quilt
boot has ever produced its own clientside proof -- Fabric honours
`environment: "client"` and leaves the mod inert, so the server reaches the ready
line instead of dying.

It is a controlled comparison rather than a sampling artefact: iris, sodium,
sodium-extra and reeses-sodium-options each have a Fabric row that SURVIVED and a
NeoForge row of the same era that died decisively.

Which makes the priority order a statement about evidence, not popularity, and
gives the two redirects landed today a bar to clear -- both do: the Forge shim
died on its own stub before loading anything, and the NeoForge shim survived.

Also records the limit this exposes: 90 of 114 `declared=CLIENT` rows are CLEAR
because a well-behaved Fabric-only mod cannot be proven by boot. Not a defect,
but the thing to re-open if the fallback list is judged too short.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
merge: re-open the loader a line is ground under, once the jar can be read
Some checks failed
Continuous / Build JAR (push) Has been cancelled
Continuous / Build AppImage (x86_64) (push) Has been cancelled
Continuous / Build AppImage (aarch64) (push) Has been cancelled
Continuous / Build Install4J Media (push) Has been cancelled
Continuous / Continuous Pre-Release (push) Has been cancelled
Docker Test / build image (push) Has been cancelled
Documentation / Writerside webhelp (push) Has been cancelled
Documentation / Help image (push) Has been cancelled
Qodana / scan (push) Has been cancelled
Qodana / notify (push) Has been cancelled
Test / build (push) Has been cancelled
b4046da78c
A field report on the public grinder's DEPENDENCY_FAILURE filter turned out to
be a class rather than a case: the loader for a Minecraft line is chosen from
platform metadata, and only one narrow disagreement -- *the jar carries a
different descriptor* -- could re-open that choice after staging. Two defects
fell out of it, both losing a whole line under the per-line axis.

**A Sinytra Connector placeholder is now booted as the Fabric mod it wraps.**
`declaredLoaders` discounts the stub, so `contradictingLoaders` refuses the
Forge/NeoForge boot and the existing `reselectOnLoaderContradiction` re-stages
the same file under Fabric -- the call `MetadataScanner` has made for the *scan*
since 2026-09-06, so the two stop disagreeing about one fact. No new machinery
and no extra container: the refusal lands before one is spent. Half of this was
a repeat -- the 2026-09-06 marker read `META-INF/mods.toml` only, and NeoForge
moved its descriptor on Minecraft 1.20.5, so the fix was blind to the second of
the two shims the same project publishes.

Measured live 2026-09-12: `Modrinth/continuity`'s 1.20 row booted the shim and
died on its own version-less dependency entries -- `Expected range: '', Actual
version: '1.0.0-beta.49+1.20.1'`, with **both dependencies present and loaded**
-- while `continuity-3.0.0+1.20.1.jar`, same mod and same Minecraft version, was
never booted at all.

**And a loader that cannot reach the build a jar demands hands over to one that
can.** `demandedLoaderVersion` reads the range the jar puts on its platform
dependency entry; any declared loader whose newest build cannot satisfy it is
dropped and the reachable ones named. `Iceberg-1.20.1-forge-1.1.25.jar` demands
`forge [47.2,)` and is ticked `forge, neoforge`, so priority took NeoForge --
whose 1.20.1 fork froze at 47.1.106 and registers as `forge`. Verified against
the live jar and SPC's own metadata: it now stages `[NeoForge, Forge]` under
every platform tagging shape, and `[NeoForge]` alone when the new arm is
disabled.

This reverses B36, which recorded that a Connector boot is worth attempting
anyway. The premise changed, not the reasoning: that call was made when the Forge
row was the project's only Forge evidence, and the per-line axis made the shim
cost one of one boot instead of one of three. Griefed made the reversal
explicitly once the measurement was in front of them. B36 is deleted from the
backlog and recorded in REFACTOR-LOG.md.

Checked and deliberately NOT changed: the 45 `declared=CONTRADICTORY` rows look
like the same root and are not. Read from four live jars, `sodium-neoforge`,
`entityculling-neoforge` and `moreculling-neoforge` all declare `side="BOTH"`
and `MouseTweaks-neoforge` declares no dependency block at all. The scanner reads
them correctly; the platform and the jar genuinely disagree.

Also recorded, measured over 717 live rows: every one of the 8 `CONFIRMED` rows
resting on its own evidence was booted under NeoForge, and no Fabric or Quilt
boot has ever produced its own clientside proof -- Fabric honours
`environment: "client"` and leaves the mod inert. That makes `LOADER_PRIORITY` a
statement about evidence rather than popularity, and gives both redirects a bar
to clear. Both clear it: the Forge shim died before loading anything and the
NeoForge shim survived.

Suite measured on the merge parent: 1823 tests, 0 failures, 30 skipped
(clientside 635 -> 643). Equivalence against develop's unmodified clientside test
tree: 635 pre-existing guards, 0 failures, with one enumerated signature change
(`refuseForSelfDeclaration` gained a defaulted `loaderVersionFor`, moving the
trailing-lambda position) adapted by naming one argument and changing no
assertion.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`serverpackcreator.dokka-conventions` sets `dokkaSourceSets.includes` to
`projectDir.resolve("module.md")`, a File which Dokka opens unconditionally. This
module applied the convention without the file, so every Dokka task in it failed:

  > /…/serverpackcreator-plugin-grinder/module.md (No such file or directory)

Nothing in the normal loop notices, which is why it shipped: only `-api` has
`build { finalizedBy(dokkaGeneratePublicationJavadoc) }`, so `./gradlew build`
exercises no other module's Dokka. It surfaced in the release pipeline's
`Publish Maven` job, which runs `dokkaJavadocJar` with no project path and
therefore in every project — Forgejo run 472, tag 9.0.0-alpha.8. The Maven
publish never ran, and `mirror` and `news` were skipped behind it.

Measured, this module only:
  before  :serverpackcreator-plugin-grinder:dokkaGeneratePublicationJavadoc FAILED
          :serverpackcreator-plugin-grinder:dokkaGeneratePublicationHtml    FAILED
  after   both BUILD SUCCESSFUL; javadoc jar 179 entries / 96 HTML pages

The `# Package` sections cover the three packages the module actually has, and
the prose is derived from this module's own CLAUDE.md rather than restated from
the signatures.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`dokkaJavadocJar` packs BOTH publications' output directories, and both write an
`index.html`. With Gradle's default duplicates strategy that is a hard failure —
"Entry index.html is a duplicate but no duplicate handling strategy has been
set" — the moment both directories are populated.

CI has never hit it because the task depends on the Javadoc publication alone and
`build/dokka` is empty in a fresh checkout; the release generates the HTML in the
`assets` job, on a different runner. Locally it is a live failure: any earlier
`dokkaGenerateHtml` leaves the directory behind, and every subsequent
`dokkaJavadocJar` in that module dies.

EXCLUDE, not INCLUDE: the Javadoc tree is added first, so its `index.html` wins
and the jar keeps the entry point a `-javadoc.jar` is expected to have, rather
than carrying two entries under one name. `sourcesJar` in publishing-conventions
uses INCLUDE for its own collision, where either copy is equivalent; here they
are not.

Measured (build/dokka populated in every module):
  before  :serverpackcreator-plugin-grinder:dokkaJavadocJar FAILED, duplicate index.html
  after   ./gradlew dokkaJavadocJar BUILD SUCCESSFUL in 33s, all six modules
          api 473 entries / 362 HTML — unchanged in content: -api has no
          colliding name, so EXCLUDE is a no-op for the one published jar.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The convention names `module.md` as a File in `dokkaSourceSets.includes`, and
Dokka opens it unconditionally — so applying this plugin without the file makes
every Dokka task in that module fail. The failure is far from the mistake: no
module except `-api` runs a Dokka task during `./gradlew build`, so the gap sits
undetected until something fans a Dokka task out over every project, which in
this repo is the release pipeline and nothing else. That is exactly how
`serverpackcreator-plugin-grinder` reached a release (Forgejo run 472, tag
9.0.0-alpha.8) and took the Maven publish down with it.

A configuration-time `require` costs one file-existence check per project and
moves the failure to the next `./gradlew` anybody runs, naming the module and the
path. Cheaper than the alternative of running every module's Dokka in `build`,
which would put ~30s of documentation generation in the local loop to guard a
missing file.

Measured, with module.md moved aside:
  before (parent commit)  ./gradlew help BUILD SUCCESSFUL — nothing notices
  after                   ./gradlew help BUILD FAILED in 16s, "…:serverpackcreator-plugin-grinder
                          applies serverpackcreator.dokka-conventions but has no module.md…"
  restored                ./gradlew help BUILD SUCCESSFUL in 6s

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`Publish Maven` ran `./gradlew dokkaJavadocJar` with no project path, which means
"in all seven projects". Only `-api` is published, so the documentation of six
modules that go nowhere gated the one publish that matters — and on 9.0.0-alpha.8
(run 472) that is what happened: `serverpackcreator-plugin-grinder` had no
module.md, its Dokka task failed, and the build stopped in the job's first
gradle invocation.

The job is `:serverpackcreator-api:dokkaJavadocJar` now. `-api`'s own
`signMavenJavaPublication` already `dependsOn(dokkaJavadocJar)`, so the task is
explicit rather than load-bearing — but naming the project is what keeps an
unpublished module out of the release. Every other Dokka call in this directory
was already scoped (`:serverpackcreator-api:dokkaGenerateHtml` in `assets`);
this was the outlier.

Read from the failed job's log rather than inferred: it contains no
`publishMavenJavaPublicationTo*`, `publishToSonatype` or
`closeAndReleaseSonatypeStagingRepository` line, so nothing reached Sonatype,
GitHub Packages, GitLab or the Forgejo registry and re-running `maven` for
9.0.0-alpha.8 is safe. `mirror` and `news` both `needs: maven` and were skipped
behind it.

Recorded in .claude/rules/ci-workflows.md, including the caveat that a failed
`maven` job is not re-runnable in general.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`8c18001f1 feat(clientside)!: delete Confidence and aggregateFor` removed the
type; four places kept describing it, and Dokka had been saying so — eight
"Couldn't resolve link" warnings that nothing read, because `failOnWarning` is
false.

What a reader was being told, and is not any more:

- `serverpackcreator-clientside/module.md` — "reports how confident it is
  ([Confidence])", a metadata signal that "tops out at MEDIUM", a crash meaning
  "HIGH" and a clean boot proving "nothing". Every one of those is now wrong: a
  clean boot that matched nothing is CLEAR, *proven server-safe*, and it is the
  most expensive signal the engine produces. Rewritten against `Verdict` and its
  six states, and the pipeline diagram's "aggregate per-loader Confidence"
  against the version-line axis it has had since 2026-09-11.
- `GrindTargetVerdict` — an `@param confidence Aggregate confidence for this
  loader.` for a constructor parameter that is not in the list, and a summary
  naming "[confidence]" where the property is `verdict`. Also "per-loader",
  same axis change.
- `GrindVerdict` — "[confidence] the clientside engine's per-loader verdict",
  and `detail` documented as "evidence behind [confidence]".
- `VerdictField.sortKey` — "Overridden only by [CONFIDENCE]", illustrated with
  `INCONCLUSIVE` outranking "MEDIUM and LOW". There is no CONFIDENCE column; the
  override is on VERDICT, it is no longer the only one (MINECRAFT has one too),
  and the live failure it prevents is `CLEAR` sorting ahead of `CONFIRMED`.

No code changed. Measured: "Couldn't resolve link" warnings 27 before / 8 fewer
after this commit, 0 after the next.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The remaining 19 "Couldn't resolve link" warnings were not dead references —
every target exists. They fail for three different reasons, and only one of them
is a typo:

- **Cross-package, written bare.** `GuiProps` (…app.gui) links six icon
  properties to `ConvenientJTable` (…app.gui.components), and `ServerPack`
  (…web.serverpack) links to `ModPack` (…web.modpack). Given the label form,
  `[Name][fully.qualified.Name]`, so the rendered text is unchanged.
- **Wrong owner.** `[BootDecision.decidedBy]` — `decidedBy` is a property *of*
  `BootVerifier.BootOutcome` whose *type* is `BootDecision`, not a member of it.
  The sentence is about the standard the property enforces, so it now points at
  the property.
- **Outside the documented set.** `refuseForMissingDependencies` is `internal`
  and `documentedVisibilities` is Public/Protected/Package;
  `ReadmeConfigurationTest` is a test class and Dokka reads main sources only.
  Neither can ever resolve, so both are backticked prose — which is how
  `DependencyBacktrack` already refers to the first of them.

Measured: 27 unresolved links before the previous commit, 0 after this one
(`./gradlew dokkaGeneratePublicationJavadoc --rerun`, all six modules).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The module was modelled on `serverpackcreator-plugin-example`, which documents
every one of these — but with the restatement this project's conventions warn
against ("Get the title of this tab. @return The title of this tab."). These say
what is specific to this plugin instead:

- `GrinderTabExtension` / `GrinderPreGenExtension` — the eight and five metadata
  members SPC shows the user and writes to `plugins.log`. The class doc now says
  that collectively, and `extensionId` carries the one fact that is not a label:
  SPC keys per-extension configuration on it, so it is an identity and must stay
  stable across releases. `getTab` notes that the `pluginConfig` it is handed is
  the *same* instance the pre-gen extension receives, which is the mechanism the
  whole feature rests on.
- `VerdictTableModel`'s four `AbstractTableModel` overrides — `getValueAt` gets
  the two facts a reader needs (it runs per visible cell per repaint, and an
  absent reading renders empty because a cell reading "null" looks like a value);
  the other three say that `COLUMNS` is the single edit a new column needs.
- Both `companion object`s, which held documented constants behind an
  undocumented container.

Measured, this module: `Undocumented: 19` before, `Undocumented: 0` after
(`:serverpackcreator-plugin-grinder:dokkaGeneratePublicationJavadoc --rerun`),
and no new unresolved links.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Seven are `companion object`s full of documented constants behind an
undocumented container; each now says what the companion holds and why it is one
(statics a guard needs without an instance, the neutral value, the parse entry
point), following `DeclaredSupport.Companion`, which already did.

The other two are `KnownModIds` and its `mappingFor`, and the cause there is
worth recording: **its doc block was orphaned, not missing.** `ModIdRegistry.kt`
carried two KDoc blocks back to back — "Bridges the two vocabularies a dependency
is spelled in…", plainly about `KnownModIds`, immediately followed by the one
about `ModIdMapping`. Kotlin attaches only the *last* preceding block, so
`ModIdMapping` got its own doc and the first block documented nothing, while the
object it was written for sat bare 25 lines further down. Somebody inserted
`ModIdMapping` between a comment and its declaration and nothing said so. The
block is moved back onto `KnownModIds` verbatim; `mappingFor` is new prose,
naming the three outcomes so a caller knows how much to trust the answer.

Measured across all six Dokka modules (`dokkaGeneratePublicationJavadoc --rerun`):

  before this branch   55 warnings — Undocumented 28, unresolved links 27
  after                 0

Suites green: clientside, grinder, plugin-grinder.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
merge: repair the release's Publish Maven job, and the Dokka silence behind it
Some checks failed
Test / build (push) Has been cancelled
Qodana / scan (push) Has been cancelled
Qodana / notify (push) Has been cancelled
Documentation / Writerside webhelp (push) Has been cancelled
Documentation / Help image (push) Has been cancelled
Docker Test / build image (push) Has been cancelled
Continuous / Build JAR (push) Has been cancelled
Continuous / Build AppImage (x86_64) (push) Has been cancelled
Continuous / Build AppImage (aarch64) (push) Has been cancelled
Continuous / Build Install4J Media (push) Has been cancelled
Continuous / Continuous Pre-Release (push) Has been cancelled
d5e5e8b074
`Publish Maven` failed on 9.0.0-alpha.8 (Forgejo run 472). The step ran
`./gradlew dokkaJavadocJar` with **no project path** — which means "in all seven
projects" — and `serverpackcreator-plugin-grinder` had no `module.md`, the file
`serverpackcreator.dokka-conventions` names in `dokkaSourceSets.includes` and
Dokka opens unconditionally. Its Javadoc task died, the build stopped in the
job's first gradle invocation, and `mirror` and `news` were skipped behind it:
one unpublished module's documentation cost a release its GitHub mirror and its
Discord announcement.

Read from the log rather than inferred: it contains no
`publishMavenJavaPublicationTo*`, `publishToSonatype` or
`closeAndReleaseSonatypeStagingRepository` line at all, so nothing reached
Sonatype, GitHub Packages, GitLab or the Forgejo registry. Griefed decided not to
recover that tag — it is an alpha, and the next release carries the fix.

**Three defects, not one.** The missing file is the one that fired; the other two
made it possible and would have outlived it:

- The release job fanned a task out over every project to get one published
  module's javadoc jar. It now names `:serverpackcreator-api:`, as every other
  Dokka call in `.forgejo/workflows` already did.
- `dokka-conventions` required a file it never checked for, and `./gradlew build`
  runs no module's Dokka except `-api`'s — so the gap was unreachable from the
  normal loop and only a release could find it. Applying the plugin without a
  `module.md` is now a configuration-time failure, naming the module and the path.
- `dokkaJavadocJar` packs both publications, both of which write `index.html`,
  with Gradle's default duplicates strategy. Never seen in CI, where `build/dokka`
  is empty; a live failure locally after any `dokkaGenerateHtml`.

**The silence the investigation surfaced.** `reportUndocumented` is true and
`failOnWarning` is false, so 55 warnings had been accumulating unread — 28
undocumented declarations and 27 unresolved KDoc links. The links were the
interesting half: four of them still described `Confidence`, deleted by
`8c18001f1 feat(clientside)!: delete Confidence and aggregateFor`, so
`serverpackcreator-clientside/module.md` was telling readers a metadata claim
"tops out at MEDIUM" and "a clean boot proves nothing" — where the code now
publishes `CLEAR`, *proven server-safe*, the most expensive signal the engine
produces. `GrindTargetVerdict` carried an `@param` for a parameter that is not in
its constructor. And `KnownModIds` was undocumented only because someone inserted
`ModIdMapping` between it and its doc block: Kotlin attaches the last preceding
comment, so the block documented nothing while its object sat bare 25 lines down.

Measured across all six Dokka modules (`dokkaGeneratePublicationJavadoc --rerun`):
55 warnings before, **0** after. `./gradlew build` green — api 421, clientside
643, grinder 532, app 149, plugin-grinder 75, plugin-example 3, frontend 32
(14 files), 0 compiler warnings.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Creating `alpha` or `beta` started `test`, `qodana`, `docker-test`, `docs` and
`release-generate` against a branch that is byte-for-byte `main` — which `main`
had already tested. The cause is not `create`: Forgejo's `pushUpdates` takes the
`IsNewRef()` branch and calls `notify_service.CreateRef` **and then**
`notify_service.PushCommits`, so a new ref fires `push` too, with the branch's
last ten commits in the payload though not one of them is new.

`release-generate` is the reason this is worth fixing rather than tolerating.
It is the workflow that runs semantic-release, i.e. the one that can **mint a
tag**, and it was doing so on a branch nobody had pushed work to yet — next to
the git-notes landmine already recorded in this repo's CI rules, where a remote
without `refs/notes/semantic-release` makes every `X-alpha.N` tag invisible and
restarts the counter at `.1`.

The guard keys on `github.event.before`, the all-zero object id on a new ref:

  if: ${{ github.event_name != 'push' || !(startsWith(github.event.before, '0000000000000000')
          && (github.ref_name == 'alpha' || github.ref_name == 'beta')) }}

Six copies, because there is no workflow-level `if:` and `qodana`'s `notify`
needs its own — `always()` runs it even when the job it `needs` was skipped.
`docs`' `help-image` needs no copy: it `needs: writerside` with no `if`, so it
skips on its own. `release-generate`'s existing `RELEASE:` clause is ANDed, not
replaced.

Scoped to `alpha`/`beta` deliberately. An unscoped zero-SHA guard is shorter and
would skip the first push of *every* branch, including a feature branch whose
first push carries real work.

**`github.event.created` is not an option here, and fails silently.** Forgejo's
PushPayload has no `created`/`deleted`/`forced` — `Ref, Before, After,
CompareURL, Commits, TotalCommits, HeadCommit, Repo, Pusher, Sender`. The GitHub
idiom `if: github.event.created == false` therefore evaluates true on every
event: the job always runs and the guard does nothing at all. `startsWith` is
used rather than `== '0000…'` for the same class of reason — an all-zero string
and an absent field both coerce to the number 0.

Measured, not reasoned: Forgejo's own act fork (code.forgejo.org/forgejo/act
v1.37.0, pkg/exprparser) evaluated all six guards over nine contexts.

  creation of alpha / beta ........................... false  (skip)
  creation of develop / claude-x ..................... true
  normal push to alpha / develop ..................... true
  workflow_dispatch / schedule / pull_request ........ true
  release-generate, RELEASE: commit on a normal push . false  (clause intact)
  github.event.created == false, all nine contexts ... true   (inert)

Not yet seen on a live branch creation — the next `alpha` cut is the check, and
those five workflows should report skipped.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Creating `alpha` or `beta` started `test`, `qodana`, `docker-test`, `docs` and
`release-generate` against a branch carrying `main`'s state, which `main` had
already tested. The trigger is not `create`: Forgejo's `pushUpdates` takes the
`IsNewRef()` branch and calls `notify_service.CreateRef` **and then**
`notify_service.PushCommits`, so a new ref fires `push` as well — with the
branch's last ten commits in the payload though not one of them is new.

`release-generate` is why this was worth fixing rather than tolerating: it runs
semantic-release, the one workflow that can **mint a tag**, and it was doing so
on a branch nobody had pushed work to yet. Next to the git-notes landmine this
repo already records — a remote without `refs/notes/semantic-release` makes every
`X-alpha.N` tag invisible and restarts the counter at `.1` — that is not a
theoretical concern.

The guard keys on `github.event.before`, the all-zero object id on a new ref, and
is scoped to `alpha`/`beta` so an ordinary feature branch still gets CI on its
first push. Six copies: there is no workflow-level `if:`, and `qodana`'s `notify`
needs its own because `always()` runs it even when the job it `needs` was skipped.

**`github.event.created` is not available here and fails silently.** Forgejo's
`PushPayload` has no `created`/`deleted`/`forced`, so GitHub's
`if: github.event.created == false` evaluates true on every event — the job
always runs and the guard does nothing at all. `startsWith` is used rather than
`== '0000…'` for the same class of reason: an all-zero string and an absent field
both coerce to the number 0, so the equality form misfires on every non-push
event.

Verified by evaluation rather than by reading, using Forgejo's own act fork
(`code.forgejo.org/forgejo/act` v1.37.0, `pkg/exprparser`) over all six guards and
nine contexts: creation of alpha/beta false; creation of any other branch, every
normal push, workflow_dispatch, schedule and pull_request true; the existing
`RELEASE:` clause still false for a release commit; and
`github.event.created == false` true in all nine, which is what proved it inert.

`create` does exist, contrary to the Actions reference which omits it —
`HookEventCreate` is matched in `modules/actions/workflows.go` — but it is an
opt-in trigger and no help in opting out. Recorded in
`.claude/rules/ci-workflows.md` beside the other two Forgejo-vs-GitHub
divergences.

Not yet exercised by a live branch creation: the next `alpha` cut is the check,
and those five workflows should report skipped.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`pickDependencyFile` compared `ModFile.version` directly, which leaves nowhere to
put the knowledge that a published version string is not always the mod's own.
`VersionOfFile.of` is that place; it returns the version verbatim, so this commit
changes no behaviour and exists only to give the next one a seam.

Landed separately because the guard for the next commit cannot compile without
it, and a guard that cannot compile is not a red pin.

Measured: 646 existing clientside guards green, unchanged.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Both are measured on the public grinder, from the consoles it publishes, and both
end the same way: the range narrows nothing, the newest build wins, the loader
refuses the pack, and the candidate wears an INCONCLUSIVE it did not earn.

1. **A Forge-style qualifier component makes the whole version unreadable.**
   `0.9.4c` is how CobblemonTrainers spells its version, and every dot-separated
   component had to be an integer or the version was treated as prose — which
   *accepts*. `Modrinth/rctmod` declares `cobblemontrainers [1.1.11,)` and was
   staged `CobblemonTrainers-forge-0.9.4c+1.20.1.jar`.

2. **A published version that leads with the MINECRAFT version is compared as
   though it were the mod's.** Create publishes both spellings inside one
   loader/Minecraft pair — `mc1.20.1-6.0.8` and `1.20.1-6.0.6` — so the set is
   judged inconsistently: the `mc`-prefixed one is unreadable and accepts, the
   bare one reads as `1.20.1`. Neither answer is about Create's version.
   `CurseForge/createaddition` declares `create [0.5.1.e,0.5.2)` and was staged
   `create-1.20.1-6.0.8.jar`, five major versions above its own upper bound.

Run red before committing: 649 tests, 3 failed —
`aQualifierComponentIsStillAVersion` on `0.9.4c should NOT satisfy '[1.1.11,)'`,
and both `VersionOfFileTest` cases on the unstripped string. The 646 pre-existing
guards are green, so the seam in the parent commit changed nothing.

`aLeadingNonNumericComponentIsStillProse` is added green on purpose: it pins the
half that must NOT move, since `Balm 26.2.0.7` reading as `0.2` is the refusal
the prose guard exists to prevent.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Turns the two pins in the parent commit green. Both made a declared range narrow
nothing, so the newest build won and the loader refused the pack.

**A qualifier component is a version component.** `readableVersion` required every
dot-separated component to be an integer, so `0.5.1.e` and `0.9.4c` were treated
as prose — which *accepts*, by the rule that a parser gap must never manufacture a
refusal. Now a component is readable when it is a number, a number with a
qualifier glued on (`4c`), or a bare qualifier (`e`) — but **only away from the
leading position**, which is untouched. That boundary is the prose guard: widening
it is how `Balm 26.2.0.7` comes to read as `0.2` and refuse almost every range.
A qualifier reads as `0`, so `0.5.1.e` and `0.5.1.f` compare equal; ordering Maven
qualifiers properly is a separate problem, and for a narrowing preference "both
are inside `[0.5.1.e,0.5.2)`" is the answer that matters.

**A published version that leads with the Minecraft version is not the mod's
version.** `VersionOfFile.of` strips a leading component that is literally one of
the file's own `minecraftVersions`, optionally spelled `mc<version>`, plus any
loader name between the two — `1.21.4-NeoForge-5.4.0` reads as `5.4.0`. The file's
own declared versions are the evidence, so nothing is guessed, and a Minecraft
version in the *suffix* is left alone because no range reads it and Fabric API
publishes `0.92.2+1.20.1` by the thousand. It never returns an empty string: a
file published under nothing but its Minecraft version has no mod version to read,
and `""` compares as `0.0.0`.

**An existing guard caught a crash this introduced, and its assertion did not
change.** `1..2` and `1.` split to an empty component, on which `first()` throws
and `all { isLetter() }` is vacuously true — the latter would have read `1..2` as
`[1, 0, 2]` and refused `>=99.0`, the exact inversion the file forbids. Empty is
explicitly unreadable now. Found by
`UnreadableStagedVersionTest.theEdgesOfReadabilityAllAccept`, which is byte for
byte what it was.

Measured: clientside 649 green (646 pre-existing + the 3 pins), api 421 green,
grinder 532 green.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Fabric refuses a mandatory dependency it will not load on a server in its own
words — *"requires … of sodium, which is disabled for this environment
(client/server only)"*. A mod that cannot load without something the server will
never have cannot run on a server, which is precisely what the fallback list is
for. Today that finding is thrown away: `Incompatible mods found` sits on the line
above and matches the `dependency-failure` rung first, so the boot is paid for and
published INCONCLUSIVE.

Measured on the public grinder 2026-09-13, from its own consoles: `Modrinth/voxy`
and `Modrinth/cull-less-leaves`, both Fabric 1.21, both naming `sodium`.

`BootDecision.CLIENT_ONLY_DEPENDENCY` is added in this commit because the guard
cannot compile without it, and nothing reads it yet — the ladder is not wired, so
behaviour is unchanged and the pin is red on the classification, not on a missing
symbol. It is `decisive` (the loader's own refusal of a jar it read is not
something a broken harness fabricates) but deliberately **not**
`provesClientOnly`: that flag clears every other build and loader of the project,
and this evidence is about one build's declared dependencies.

`BootDecisionTest.theDecisiveSetIsSmallAndExplicit` changes, and that is the guard
working as designed — it exists so an addition to the publishing set has to be
stated with a reason a harness cannot fake, rather than arriving quietly.

Run red before committing: 653 tests, 1 failed —
`aDependencyTheLoaderCallsClientOnlyIsACrash`, expecting CRASHED and getting
INCONCLUSIVE/DEPENDENCY_FAILURE. `anOrdinaryMissingDependencyIsUnchanged` is green
already and stays that way.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Turns the parent commit's pin green by wiring `client-only-dependency` into the
bundled rules and the classification ladder, immediately **above**
`dependency-failure`.

Position is the whole fix. Fabric prints `Incompatible mods found` on the line
before the one that names the client-only dependency, so the excuse matched first
and the boot's strongest finding was discarded — `Modrinth/voxy` and
`Modrinth/cull-less-leaves` were both published INCONCLUSIVE on 2026-09-13 for
exactly that reason, having each paid for a container. Pinned by
`theClientOnlyDependencyRungOutranksTheExcuseOnTheLineAbove`, because a later
re-order would silently restore the old behaviour.

Fabric's wording is generic — "disabled for this environment (client/server
only)" — and it is always a *server* that boots here, so a disabled mandatory
dependency is the client-only half of that phrase.

`DefaultBootRulesTest.onlyDecisiveClientEvidenceConfirmsFromAConsole` changes,
which is the second guard this rung had to answer to: one enumerates what may
publish, the other what may confirm from a console. Both exist so an addition is
argued rather than assumed, and both now name the same four.

Measured: clientside 654 green, grinder 532 green.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
malilib publishes `0.10.0-dev.23` and `0.10.0-dev.23.nomixin` for Forge 1.12.2,
and Modrinth returns the `nomixin` one first because it is newer *by date*.
`pickForLoader` takes the first match, so every mod depending on malilib was
staged the build that declares the Mixin tweaker without carrying Mixin —
`ClassNotFoundException: org.spongepowered.asm.launch.MixinTweaker`, and the
server never launched.

Measured on the public grinder 2026-09-13: `litematica`, `minihud`, `tweakeroo`
and `zume`, all Forge 1.12, all four staged
`malilib-forge-1.12.2-0.10.0-dev.23.nomixin.jar`, all four published
INCONCLUSIVE / DEPENDENCY_FAILURE. Confirmed against the live Modrinth API: the
plain `0.10.0-dev.23` build is published right beside it.

Run red before committing: exactly one of the four fails —
`aPlainBuildWinsOverItsOwnVariant`, `expected: <0.10.0-dev.23> but was:
<0.10.0-dev.23.nomixin>`. The other three are green already and pin the
boundaries this must not cross: a variant is still picked when it is all there
is, a newer version is not a variant of an older one, and build metadata
(`1.6.1+1.21.1`, which Fabric API publishes by the thousand) is not a variant
either — demoting that would invert the catalogue.

The first run of this guard failed for the wrong reason: the fixture spelled
`loaders = setOf("forge")` where the selector compares canonical names, so all
four returned `null` and the red said nothing about variants. It now builds the
set through `LoaderNames.canonicalLoaders`.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Turns the parent commit's pin green. `plainBuildsFirst` stably partitions the
candidate list so a file whose version is another file's version plus a trailing
separator and a **digitless** segment sorts behind the plain build of that same
version. Modrinth returns newest-by-date first, which is why
`0.10.0-dev.23.nomixin` beat `0.10.0-dev.23` and took four 1.12 mods with it.

The digitless rule is the whole discriminator. `1.6.1+1.21.1` hangs a segment off
`1.6.1` too, and that is build metadata Fabric API publishes by the thousand —
demoting it would invert the catalogue rather than fix anything.

A preference, never a filter: `plain + variants` keeps every variant reachable, so
a project that publishes only a variant still yields a dependency instead of a
refusal. Stable, so the platform's own ordering survives inside each half.

Measured: clientside 658 green.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`botanytrees` declares `botanypots`; Modrinth publishes the project as
`botany-pots` and answers 404 for the bare id, so the slug guess can only miss.
Verified against the live API 2026-09-13 in both directions.

Measured on the public grinder the same day: `CurseForge/botany-trees` on NeoForge
1.21 was published INCONCLUSIVE with `Mod ID: 'botanypots' … Actual version:
'[MISSING]'`.

Run red before committing: `expected: <Alias(ref=botany-pots)> but was:
<Guess(ref=botanypots)>` — the guess, which is exactly the miss.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Turns the parent commit's pin green. `botanypots` to `botany-pots`, Modrinth only:
the CurseForge numeric id could not be verified from here, and inventing one sends
every lookup to whatever project happens to hold it — the same call `tacz` and
`obscure_api` already make. A CurseForge boot is covered regardless, because an id
that maps nowhere locally is asked of the other platform.

Verified end to end against the live API: with the alias, the selector picks
`botanypots-neoforge-1.21.1-21.1.44.jar`, which is what `botanytrees` was asking
for when the grinder published it INCONCLUSIVE.

Measured: clientside 659 green.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Five defects behind the public grinder's `DEPENDENCY_FAILURE` rows, each pinned
red before its fix. All of them were measured from the daemon's own published
consoles rather than reasoned about: 4,410 verdicts pulled, 46 of them decided by
`DEPENDENCY_FAILURE`, every console downloaded and classified by what the loader
actually said.

**A declared range narrowed nothing, twice over.** `readableVersion` required
every dot-separated component to be an integer, so a Forge-style qualifier —
`0.9.4c`, `0.5.1.e` — made the whole version prose, and prose *accepts*. And a
version leading with the MINECRAFT version was compared as though it were the
mod's: Create publishes `mc1.20.1-6.0.8` and `1.20.1-6.0.6` within one
loader/Minecraft pair, so the set was judged inconsistently — one unreadable and
accepting, the other read as "version 1.20". `VersionOfFile` strips a leading
component that is literally one of the file's own `minecraftVersions`, so nothing
is guessed, and a Minecraft version in the *suffix* is left alone because Fabric
API publishes `0.92.2+1.20.1` by the thousand.

**A build variant is not a newer version.** malilib ships `0.10.0-dev.23` and
`0.10.0-dev.23.nomixin`; Modrinth returns the variant first because it is newer by
date, and `nomixin` declares the Mixin tweaker while carrying no Mixin. Four 1.12
mods — litematica, minihud, tweakeroo, zume — were each staged it and never
launched. A digitless trailing segment is the discriminator, which is what keeps
this off build metadata.

**A dependency the loader itself refuses as client-only is a finding, not an
excuse.** Fabric says *"which is disabled for this environment (client/server
only)"*, and `Incompatible mods found` sits on the line above — so the
dependency-failure rung matched first and the boot's strongest evidence was
discarded. `voxy` and `cull-less-leaves` each paid for a container to publish
INCONCLUSIVE. The new rung sits above that excuse, and two existing guards had to
be answered to put it in the publishing set.

**`botanypots` is `botany-pots`**, which no slug guess reaches.

Verified on a real local grinder, not only in the selector. `Modrinth/rctmod`,
Forge 1.20.1, in a container:

  public grinder  staged CobblemonTrainers-forge-0.9.4c+1.20.1.jar
                  Mod ID: 'cobblemontrainers', Expected range: '[1.1.11,)'
                  decidedBy=DEPENDENCY_FAILURE
  local, fixed    staged CobblemonTrainers-1.1.11+1.5.2-forge.jar
                  "CobblemonTrainers Forge initialized"
                  decidedBy=TIMED_OUT

The remaining TIMED_OUT is the host: Docker on that machine had 1.92 GiB against a
documented ~3 GB per worker. It is a fair-run guard, so the grinder says "no fair
run" rather than blaming the mod — which is the behaviour those rungs exist for.

**Two existing guards caught real mistakes and neither assertion was weakened.**
`UnreadableStagedVersionTest.theEdgesOfReadabilityAllAccept` found a crash the
qualifier change introduced on the empty component from `1..2`, where `first()`
throws and `all { isLetter() }` is vacuously true — the latter would have read
`1..2` as `[1, 0, 2]` and refused `>=99.0`, the exact inversion that file forbids.
`BootDecisionTest.theDecisiveSetIsSmallAndExplicit` did what it was written to do:
an addition to the set that may publish a mod has to be argued, not appear.

**Roughly 14 of the 46 rows are a redeploy, not a fix.** The daemon predates
`a23781751` (2026-09-11 22:31 UTC): current `develop` resolves `wover` to
`worldweaver` and picks the exactly-matching build for all three lines, while the
daemon published nine rows with it `[MISSING]`. `/status` carries no build
identifier, which is why establishing that took a behavioural probe.

**Sixteen rows stay open, deliberately.** Loader-version-too-old (8),
loader-absent (4), Minecraft-version-wrong-in-line (2) are one coherent piece of
work — which Minecraft version and loader build a line is ground under, given what
the jars demand — and worth scoping rather than bolting on. `sewingkit` and
`betterquesting` (2) are CurseForge-only and need a key to verify a numeric id;
inventing one sends every lookup to whatever project holds it, which is the call
`tacz` and `obscure_api` already make.

Measured: clientside 659, api 421, grinder 532 — all green.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
All three were observed going unresolved on the public grinder 2026-09-13, and
all three are now verified against the live CurseForge API by the versions in
their published file names — the numeric id alone proves nothing, the versions
are what identify the project a dependant is asking for.

- `betterquesting` is `better-questing` (238856). The bare id matches no project.
  `BetterQuesting-Forge-1.20.1-4.0.71.jar` is what
  `better-questing-standard-expansion` was asking for with `[4.0,)`.
- `botanypots` is `botany-pots` (353928), which also completes the entry added a
  commit ago with a null CurseForge half.
  `botanypots-neoforge-1.21.1-21.1.44.jar` satisfies `botanytrees`' `[21.1.34,21.2)`.
- **`sewingkit` is the interesting one: two projects answer to it, and the one
  whose slug matches the mod id exactly is the wrong one.** `310830` is published
  under the slug `sewingkit` and stopped at `SewingKit-1.0.2.jar` for Minecraft
  1.14.2. `411896` is `sewing-kit`, ships `SewingKit-26.1.2-2.8.1.jar`, and its
  2.x line is what `toolbelt`'s `[2.0.0,)` names. A slug guess would have picked
  the dead project by name and staged a Minecraft 1.14 jar — which is the whole
  argument for carrying numeric ids.

**This does not make `tool-belt` bootable on 1.20**, and the guard says so:
411896's newest 1.20.1 build is `1.8.1`, below the demanded `[2.0.0,)`. Nothing
upstream satisfies it on that line. Resolution is fixed; the honest report of an
unsatisfiable range is a separate problem, noted below.

Run red before committing: 3 failed —
`expected: <Alias(ref=411896)> but was: <Guess(ref=sewingkit)>`,
the same for `betterquesting`, and the exhaustive id map short by three entries.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Turns the parent commit's pin green, and completes the `botanypots` entry that
landed a commit earlier with a null CurseForge half.

Verified end to end: `botanypots` resolves to `botany-pots` / `353928` and the
selector picks `botanypots-neoforge-1.21.1-21.1.44.jar`, satisfying `botanytrees`'
declared `[21.1.34,21.2)`.

**`sewingkit` is the one worth remembering.** Two CurseForge projects answer to
it. `310830` carries the slug `sewingkit` — an exact match for the declared mod id
— and stopped at `SewingKit-1.0.2.jar` for Minecraft 1.14.2. `411896` is
`sewing-kit`, and its 2.x line is what `toolbelt`'s `[2.0.0,)` means. A slug guess
picks the dead project by name and stages a Minecraft 1.14 jar into a 1.20 pack;
this is the argument for numeric ids stated as a live example rather than a
principle.

It does **not** make `tool-belt` bootable on 1.20 — 411896's newest build there is
`1.8.1`, below the demanded range, so nothing upstream satisfies it. The gap that
remains is a reporting one: a readable range that no available file satisfies is
known before any container starts, and spending one to rediscover it produces an
INCONCLUSIVE where UNVERIFIABLE is the honest answer. Left for its own change,
because `pickDependencyFile` treats a constraint as a preference by design and
inverting that deserves its own evidence and its own guards.

`theCurseForgeIdsAreCarriedWhereTheyCouldBeVerified` enumerates by hand — the
alias map is private, so it cannot catch an id added to the registry and left
unlisted, only a listed id whose ref changes. Said so at the call site rather than
leaving the gap implied.

Measured: clientside 661 green.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Three mod ids observed going unresolved on the public grinder 2026-09-13, each
verified against the live CurseForge API by the **versions** in its published file
names rather than by its id alone: `betterquesting` is `better-questing` (238856),
`botanypots` is `botany-pots` (353928, completing an entry that landed with a null
CurseForge half), and `sewingkit` is `sewing-kit` (411896).

**`sewingkit` is the case for numeric ids, stated as an example rather than a
principle.** Two projects answer to that id, and the one whose slug matches it
exactly is the wrong one: `310830` is published as `sewingkit` and stopped at
`SewingKit-1.0.2.jar` for Minecraft 1.14.2, while `411896` is `sewing-kit` and its
2.x line is what `toolbelt`'s `[2.0.0,)` names. A slug guess picks the dead project
by name and stages a Minecraft 1.14 jar into a 1.20 pack.

Verified end to end for the one that can be: `botanypots` resolves and the selector
picks `botanypots-neoforge-1.21.1-21.1.44.jar`, satisfying `[21.1.34,21.2)`.

**`tool-belt` stays unbootable on 1.20 and the guard says so** — 411896's newest
build there is `1.8.1`, below the demanded range, so nothing upstream satisfies it.
What remains is a reporting gap rather than a resolution one: a readable range no
available file satisfies is knowable before any container starts, and spending one
to rediscover it yields INCONCLUSIVE where UNVERIFIABLE is honest. Deliberately not
changed here — `pickDependencyFile` treats a constraint as a preference by design,
and inverting that needs its own evidence and its own guards.

`theCurseForgeIdsAreCarriedWhereTheyCouldBeVerified` enumerates by hand because the
alias map is private, so it catches a listed id whose ref changes but not an id
added and left unlisted. Recorded at the call site rather than left implied.

Measured: clientside 661 green, grinder 532 green.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`JarSelfDeclaration.platformProvides` returns nothing, so this commit changes no
behaviour. It exists because the guard for the next one cannot compile without it,
and a guard that cannot compile is not a red pin.

It lives beside `platformIdsFor` deliberately: that function already owns the one
fact the answer needs — NeoForge answers to `forge` on Minecraft 1.20.1, where it
runs Forge builds, and to `neoforge` everywhere after — and a second copy of that
mapping is the duplication this repository has paid for repeatedly.

Measured: 661 existing clientside guards green, unchanged.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`LoaderProvidedIds` reads a `provides` block out of an installed loader jar, which
Quilt and Fabric publish and Forge and NeoForge do not — so for those two the map
came back empty, and `DependencyBacktrack` treated a demand on `forge` or
`neoforge` as naming something absent, which it skips by design. The loader itself
disagrees, in as many words:

    Mod ID: 'forge', Requested by: 'iceberg', Expected range: '[47.2,)',
    Actual version: '47.1.106'

Measured on the public grinder 2026-09-13: `advancement-plaques` and
`item-highlighter` (on both platforms) were each staged
`Iceberg-1.20.1-forge-1.1.25.jar`, which demands `forge [47.2,)`, into a NeoForge
`47.1.106` pack — the 1.20.1 fork froze there — and all three were published
INCONCLUSIVE. With the pair in hand the judge can demote Iceberg and backtrack to
a build that fits, exactly as it already does for `fabricloader` on Quilt.

Run red before committing: 664 tests, 3 failed — `{forge=47.1.106}`,
`{neoforge=21.1.250}` and `{forge=47.4.23}` all against `{}`.

Two cases are green already and pin the boundary rather than the change: Fabric
and Quilt stay empty, because their real `provides` block differs per build
(quilt-loader 0.30.1 provides `fabricloader 0.19.3`, 0.31.0-beta.4 provides
`0.19.5`) and inventing an answer here would shadow the true reading; and a blank
loader build provides nothing, since a map claiming a version we do not have is
worse than no map.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Turns the parent commit's pin green and wires the answer into the one place it
was missing: `dependencyToDemote` seeds its provided-ids map from
`JarSelfDeclaration.platformProvides` before reading the install.

`LoaderProvidedIds` reads a `provides` block out of the installed loader jar, and
its own doc says Forge and NeoForge "are not looked for at all: they publish no
`provides` block". True, and it left the map empty for them — so
`DependencyBacktrack` saw `iceberg` demanding `forge [47.2,)`, found nothing
providing `forge`, and skipped the requirement as naming something absent. The
loader then refused the pack and the *candidate* wore the verdict: three published
rows on 2026-09-13, `advancement-plaques` and `item-highlighter` on both
platforms, each staged `Iceberg-1.20.1-forge-1.1.25.jar` into a NeoForge 47.1.106
pack that could never satisfy it.

The real reading wins on a collision — `platformProvides(...) + loaderProvides(...)`
— because the install's own jar is evidence where this is derived from a build
number. For Fabric and Quilt it adds nothing at all, so the `fabricloader` path
that already worked is untouched.

**The wiring itself is not independently pinned, and that is a gap worth naming.**
`DependencyBacktrackStagingTest` builds its packs out of `fabric.mod.json`
descriptors, so a Forge-family case needs a TOML fixture the harness has no way to
write; adding one is its own change. The mutation that reproduces the red is
dropping `JarSelfDeclaration.platformProvides(loader, loaderVersion, minecraftVersion) +`
from that line — the knowledge is pinned by `PlatformProvidesTest`, the use of it
is not.

Measured: clientside 666 green, grinder 532 green.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`LoaderProvidedIds` reads a `provides` block out of the installed loader jar, and
its own doc says Forge and NeoForge "are not looked for at all: they publish no
`provides` block". Correct about the block, wrong about the consequence — the
loader does provide that id, and says so in the console that refused the pack:

    Mod ID: 'forge', Requested by: 'iceberg', Expected range: '[47.2,)',
    Actual version: '47.1.106'

With the map empty for those two, `DependencyBacktrack` saw the requirement name
something absent and skipped it by design. `Iceberg-1.20.1-forge-1.1.25.jar` then
sailed into a NeoForge `47.1.106` pack — the 1.20.1 fork froze there — and the
*candidate* wore the verdict. Three published rows on 2026-09-13:
`advancement-plaques` and `item-highlighter` on both platforms. With the pair in
hand the judge demotes Iceberg and backtracks to a build that fits, exactly as it
already does for `fabricloader` on Quilt.

`platformProvides` lives in `JarSelfDeclaration` because `platformIdsFor` already
owns the fact it needs — NeoForge answers to `forge` on Minecraft 1.20.1, where it
runs Forge builds, and to `neoforge` everywhere after. Fabric and Quilt stay empty
on purpose: their real `provides` block differs per build, so the answer has to be
read off the install and a derived one would shadow it. The real reading wins on a
collision for the same reason.

**The wiring is not independently pinned, and the commit says so.**
`DependencyBacktrackStagingTest` builds its packs from `fabric.mod.json`
descriptors, so a Forge-family case needs a TOML fixture that harness cannot
write. The knowledge is pinned by `PlatformProvidesTest`; the mutation that
reproduces the red on the use of it is quoted in the fix commit.

**Everything else in this class was probed and does not reproduce on develop.**
Against real platform data: `the-undergarden` and `productivebees` now pick
Minecraft 1.21.1 where the daemon booted 1.21 — a version those files do not
declare at all — and `additional-structures` and `ct-overhaul-village` now pick
NeoForge where the daemon booted Forge. Those rows are the redeploy already
recorded against `a23781751`, not open defects.

Measured: clientside 666 green, grinder 532 green.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Two defects in `VersionOfFile`, both found by probing the `amendments` row against
live Modrinth data rather than by inspection.

**The prefix is routinely the version-LINE, not the exact version the file
declares.** `moonlight-1.20-2.16.35-forge.jar` declares Minecraft `1.20.1` and
publishes as `1.20-2.16.35-forge`, so matching only the declared version left the
string intact and moonlight 2.16.35 compared as version **1.20** — below every
range a dependant states. Measured 2026-09-13: with `[2.16,)` the selector
preferred `moonlight-1.20-2.13.82-forge.jar` over four 2.16.x builds beside it,
because none of them could be read as satisfying anything.

**And a dot continues a number rather than separating one.** The strip accepts
`.` as a separator today, so a file declaring Minecraft `1.20.1` whose mod version
is `1.20.1.3` has its own version read as `3`. That is live in what already
merged, and it is the direction that manufactures refusals.

Run red before committing: `expected: <2.16.35-forge> but was:
<1.20-2.16.35-forge>` and `expected: <1.20.1.3> but was: <3>`. Every existing
`VersionOfFileTest` case stays green — all of them are `-` separated, which is how
the real cases are spelled.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Turns the parent commit's two pins green.

**The line, as well as the exact version.** `VersionOfFile` now tries each
declared Minecraft version *and* its line, longest spelling first so `1.20` cannot
shear a string that really began `1.20.1`. `moonlight-1.20-2.16.35-forge.jar`
declares Minecraft `1.20.1` and publishes as `1.20-2.16.35-forge`; it now reads as
`2.16.35-forge` instead of comparing as version 1.20.

**And a dot no longer separates.** `-`, `_` and a space do; `.` continues a
number. Accepting it read `1.20.1.3` on a file declaring Minecraft 1.20.1 as
version `3` — a defect that was live in the merged code and pointed the wrong way,
since a version read as far smaller than it is fails every lower bound.

Verified against the live Modrinth API, all three through the real selector:

  moonlight  [2.16,)            1.20-2.16.35-forge  -> 2.16.35-forge  (was: 2.13.82)
  unionlib   [12.0.18,12.1.0)   1.21.1-12.0.18-…    -> 12.0.18-NeoForge
  create     [0.5.1.e,0.5.2)    1.20.1-0.5.1.j      -> 0.5.1.j

The last two are the cases the original change was written for, re-checked here
because tightening the separator set could have regressed them.

Found while probing the `amendments` row, which turned out not to reproduce on
develop at all — the selector already picks `moonlight-1.20.4-2.9.9-forge.jar` for
a 1.20.4 pack where the daemon staged a 1.20 build. The defect was in the fix
shipped two merges ago, not in the row.

Measured: clientside 668 green, grinder 532 green, api 421 green.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
merge: a version prefixed with its Minecraft line is still the mod's version
All checks were successful
Documentation / Writerside webhelp (push) Successful in 4m24s
Continuous / Build JAR (push) Successful in 18m8s
Docker Test / build image (push) Successful in 26m29s
Qodana / scan (push) Successful in 22m19s
Docker Test / build image (pull_request) Successful in 20m37s
Documentation / Help image (push) Successful in 7m38s
Continuous / Build AppImage (x86_64) (push) Successful in 3m57s
Continuous / Build AppImage (aarch64) (push) Successful in 2m7s
Continuous / Build Install4J Media (push) Successful in 11m41s
Qodana / notify (push) Successful in 34s
Continuous / Continuous Pre-Release (push) Successful in 7m3s
Test / build (push) Successful in 1h59m36s
Test / build (pull_request) Successful in 1h54m52s
8eaa9a37fc
Two defects in `VersionOfFile`, both in code this branch shipped two merges ago,
and both found by probing the `amendments` row against live Modrinth data rather
than by re-reading the change.

**The prefix is routinely the version-LINE, not the exact version the file
declares.** `moonlight-1.20-2.16.35-forge.jar` declares Minecraft `1.20.1` and
publishes as `1.20-2.16.35-forge`, so matching only the declared version left the
string whole and moonlight **2.16.35 compared as version 1.20** — below every
range a dependant states. Measured: with `[2.16,)` the selector preferred
`moonlight-1.20-2.13.82-forge.jar` over four 2.16.x builds sitting beside it,
because none of them could be read as satisfying anything. Each declared version
and its line are now tried, longest spelling first so `1.20` cannot shear a string
that really began `1.20.1`.

**And a dot was separating a number it should continue.** A file declaring
Minecraft `1.20.1` whose mod version is `1.20.1.3` had its own version read as
`3`. That was live, and it points the wrong way: a version read as far smaller
than it is fails every lower bound it meets. `-`, `_` and a space separate now;
`.` does not, which is how every real case measured here is spelled.

Re-verified through the real selector against the live API, including the two
cases the original change existed for — tightening the separator set could have
regressed them and did not:

  moonlight  [2.16,)           1.20-2.16.35-forge  -> 2.16.35-forge  (was 2.13.82)
  unionlib   [12.0.18,12.1.0)  1.21.1-12.0.18-…    -> 12.0.18-NeoForge
  create     [0.5.1.e,0.5.2)   1.20.1-0.5.1.j      -> 0.5.1.j

**The row that started this does not reproduce.** `amendments` on develop already
picks `moonlight-1.20.4-2.9.9-forge.jar` for its 1.20.4 pack, where the daemon
staged a 1.20 build — moonlight publishes a real 1.20.4 Forge build and the
selector finds it. The defect was in the fix, not in the row, which is the
argument for probing a closed-looking case instead of trusting it.

Measured: clientside 668 green, grinder 532 green, api 421 green.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Griefed merged commit 3d3c0f68e4 into alpha 2026-09-14 19:28:51 +02:00
Sign in to join this conversation.
No reviewers
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set

Reference
Griefed/ServerPackCreator!674
No description provided.