...we can't have... #674
No reviewers
Labels
No labels
accepted
bug
dependencies
docker
documentation
duplicate
enhancement
github-actions
github_actions
good first issue
gradle
hacktoberfest-accepted
help wanted
invalid
javascript
not-an-issue
npm
question
rejected
wontfix
Working on it
No milestone
No project
No assignees
1 participant
Notifications
Due date
No due date set.
Dependencies
No dependencies set
Reference
Griefed/ServerPackCreator!674
Loading…
Reference in a new issue
No description provided.
Delete branch "develop"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
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, 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>unlessclause satisfies a requirement c1ac29d6d8Quilt 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>unlessclause when staging a dependency 1443d9f464Closes 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>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>nullone 37e2d27975Two 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>unlessalternative counts when it is bundled, not only provided e775fbd42fCharacterization, 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>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>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>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>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>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>`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>`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>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>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>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>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>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>`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>`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>