This is why... #673
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!673
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?
Red on purpose — it does not compile, because there is no shared helper naming the per-attempt staging directory, and the reaper cannot yet be told which platform's candidate finished. The grinder already treats the same slug on Modrinth and on CurseForge as two candidates ("Freshness is per (platform, slug)") and runs them on parallel workers. Staging is keyed on `(slug, loader)` alone, and it *wipes* the directory before using it; `BootWorkspaceReaper.reap(slug)` then deletes it again once either candidate's verdicts are in. So two runs of one slug share a server pack, and each is free to delete it out from under a container the other is still booting. Observed 2026-08-23 on `creativecore`, whose two platform runs finished 71 seconds apart: - NeoForge 26.2.0.66 / Minecraft 26.2 → SURVIVED (exit 137) on CurseForge and CRASHED (exit 1) on Modrinth. Same loader build, same Minecraft, same mod. - Fabric / Minecraft 26.2 → CRASHED (exit 127) on CurseForge. 127 is a shell that could not find the command it was told to run. - Both Modrinth Fabric re-checks INCONCLUSIVE (exit 1, exit 0) — one of them on `CreativeCore_FABRIC_v2.14.13_mc26.1.jar`, the very file the CurseForge run had booted to a ready-line two minutes earlier. `BootWorkspaceReaperTest`'s fixture now builds its layout through the same helper production will use, so the reaper's parse and the verifiers' naming cannot drift into disagreeing — today they agree only by two separate string literals happening to match. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>Per-attempt scratch space was keyed on `(slug, loader)`. Staging *wipes* that directory before using it, and `BootWorkspaceReaper.reap(slug)` deletes it again once a candidate's verdicts are in. The grinder, meanwhile, treats the same slug on Modrinth and on CurseForge as two candidates — verdict freshness is keyed `(platform, slug)` for exactly that reason — and grinds them on parallel workers. So both runs of one slug shared a server pack, and each was free to delete it out from under a container the other was still booting. `AttemptDirectory` now names the directory `<platform>-<slug>-<loader>` and reads it back to its owner. Both halves live in one place because three callers depend on them agreeing: `ClientsideVerifier` (jar-scan downloads), `BootVerifier` (staged packs) and the reaper, which decides what to delete from the name alone. Until now they agreed only by separate string literals happening to match. The loader suffix is still cut rather than the slug prefix-matched, so `creativecore` does not claim `creativecore-extras`. What it was costing, from the `creativecore` report of 2026-08-23, whose two platform runs finished 71 seconds apart: - NeoForge 26.2.0.66 / Minecraft 26.2 → SURVIVED (exit 137) on CurseForge, CRASHED (exit 1) on Modrinth. Same loader build, same Minecraft, same mod. - Fabric / Minecraft 26.2 → CRASHED (exit 127) on CurseForge. Exit 127 is a shell that could not find the command it was told to run — the pack had gone. - Both Modrinth Fabric re-checks INCONCLUSIVE (exit 1, exit 0), one of them on `CreativeCore_FABRIC_v2.14.13_mc26.1.jar`, the very file the CurseForge run had booted to a ready-line two minutes earlier. Every one of those is a boot scored as evidence about a mod when it was really evidence about a deleted directory, and a crash is the one outcome that reaches HIGH. Teeth checked: reaping on the bare slug fails `reapingOnePlatformLeavesTheSameSlugOnAnotherPlatformAlone`; restoring either producer's `"${project.slug}-$loader"` fails `theJarScanOfTwoPlatformsSharingASlugDownloadsIntoSeparateDirectories` and `theSameSlugOnTwoPlatformsStagesIntoSeparateDirectories`. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>Red on purpose (both fail to compile — `LoaderVerdict` has no `bootedLoader`, `ContainerCandidateVerifier` has no `reapTarget`). Two audit findings from this branch's own changes, pinned before either is touched. H1 — only a loader's *own* clean boot may disprove another loader's crash. Since the other-version re-check began spanning loaders, a verdict's `bootResult` can be the result of a boot run under a different loader: `reconcileOtherVersionRecheck` returns the surviving attempt's own outcome, and that attempt may now be a cross-loader one. Reproduced by calling both functions: reconcileOtherVersionRecheck(NeoForge CRASHED, ["sodium-fabric-0.5.jar (Fabric, Minecraft 1.21.11)" SURVIVED]) → SURVIVED loaderDisprovingTheCrash(Forge CRASHED "embeddium-", [.., NeoForge SURVIVED]) → NeoForge note: "Crashed, but NeoForge booted a server with the same entry 'embeddium-'" NeoForge never booted a server. The build that did is `sodium-fabric-0.5.jar`, which `embeddium-` cannot strip — so the invariant the entry comparison exists to protect is not satisfied, and the note states something untrue. The stem split is the one `FilenameStemDeriver.deriveStems` documents, not a contrived shape. M1 — reclamation must ask for the identity the staging was *named* from. Staging uses `ProjectFiles.platform`/`slug`; the reaper was being handed the candidate's copy of both. `Grinder` already logs "Platform mismatch for …: candidate says 'X', resolved report says 'Y'", so the codebase knows they can disagree, and a slug is a mutable name a rename can move. On disagreement the reap matches nothing and leaks a server pack per attempt. `reapTarget` is pinned as a pure decision rather than through `verify`, which needs an ApiWrapper, a loader cache and a container engine — the same split the boot verifier's own decisions already follow. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>The other-version crash re-check samples across loaders, and `reconcileOtherVersionRecheck` returns the surviving attempt's *own* outcome. So a verdict for one loader could end up carrying `bootResult = SURVIVED` produced by a boot of another, and nothing recorded the difference. Two things read that field and both were misled. `ClientsideVerifier.loaderDisprovingTheCrash` compares two verdicts' entries so that a published stem can never strip a build proven to boot a server. With a borrowed survival the build that actually booted belongs to a third loader whose stem may differ, so the comparison guards nothing: Forge CRASHED entry `embeddium-` NeoForge SURVIVED entry `embeddium-` ← actually a Fabric boot of sodium-fabric-0.5.jar `embeddium-` cannot strip `sodium-fabric-0.5.jar`, yet the crash was cleared and the report printed "NeoForge booted a server with the same entry 'embeddium-'". NeoForge booted nothing. That stem split is the one `FilenameStemDeriver. deriveStems` documents, not a contrived shape. `BootOutcome.bootedLoader` is stamped by `runPrepared` — the one place that knows what was booted — and carried to `LoaderVerdict.bootedLoader`. The disproof now also requires `other.bootedLoader == other.loader`, restoring the invariant exactly as documented, and the Markdown report's Boot cell reads `SURVIVED (via NeoForge)` when the two differ rather than letting a row claim a boot it never had. `outcomeFor` deliberately does not learn the loader: it classifies a console, and that is all it should need to know. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>Red on purpose — two source guards fail, and the pacing pin does not compile (`GrindPacing.pollInterval` does not exist). All three are audit iteration 25's findings against this session's own re-grind queue. H1 — the drain gave the pass loop a *second* `GrindPool`, while the shutdown hook holds one handle and reads it once, deliberately ("reading it twice could signal one pool and wait on another"). With no `running` check between the two pools a stop landing in a drain is signalled, awaited and reported clean, and then `main` takes a catalog batch and starts new containers **after** the hook has finished, with systemd's TimeoutStopSec counting down. Containers live in the docker daemon's cgroup, not the unit's, so the hook is the only thing that can stop them. Asserted against `main`'s own source, like the other entry-point guards: the loop needs an ApiWrapper, Docker and a report port to run, and none of that is needed to know the check is there. M1 — `status.beginPass` ran after the drain and counted only the catalog slice, so for the whole of a 300-candidate drain `/status` showed the *previous* pass's number and size while `active` showed workers grinding candidates belonging to neither. That is the one operation an operator is most likely to be watching. M2 — the inter-pass wait is one uninterruptible sleep, and `pauseAfterPass` returns `betweenSweeps` (default 21 600 s) after a completed sweep that verified nothing. Queue a re-grind into a just-dozed daemon and nothing happens for six hours; the queue exists precisely so a known-wrong verdict is not served while a timer runs down. Pinned as the pure decision — how long to wait before looking again — so no test has to sleep. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>Red on purpose, and the finding is pre-existing rather than this session's — surfaced because the conventions require a bug found while working to be raised and fixed in its own commit rather than deferred. `val pass = pool.grindAll(batch.candidates)` shadows the `var pass` counter, so `log.info("Pass #$pass complete: …")` interpolates the `GrindPass` data class. It compiles, it runs, and the line still begins "Pass #", which is why it survived: it is only visible in the output. Found in the production log, not the code. `~/.spc-grinder/grinder.log` carries 14 of them, each a multi-kilobyte dump of every reached candidate's URL and popularity in the one line an operator greps for pass progress: Pass #GrindPass(reached=[GrindCandidate(projectUrl=https://modrinth.com/mod/ lambdynamiclights, slug=lambdynamiclights, popularity=49644693, platform=… Asserted against `main`'s source because that is where the shadowing is: a log line's text is not reachable from a unit test without an appender, and the defect is the declaration rather than the formatting. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>Audit iteration 26. **H1** — `pollInterval` clamps a negative remainder to zero. The caller computes `wakeAt - now` after testing `now < wakeAt`, with a synchronized queue read in between, so the final slice — bounded by construction to (0, 15s] — goes negative when that read stalls longer than the remainder. `Thread.sleep` throws IllegalArgumentException on a negative timeout; that is not InterruptedException, so it escaped the wait's catch, escaped `while (running.get())` and ended `main`, leaving a fire-and-forget daemon quietly not grinding with no crash anyone was watching for. Landmined in place. **M1** — `CrashLogStore` seeks to the tail instead of reading the console whole. The cap bounded what was written, not what was read: boot consoles are streamed uncapped and bounded only by the 15-minute timeout, so a chatty mod can leave hundreds of megabytes, doubled again as UTF-16 — and `keep`'s `runCatching` catches Throwable, so the OutOfMemoryError would have been swallowed and the daemon left running on an unknown heap. Decoding can clip a multi-byte character at the seek point, which is why the truncation notice sits in front of it: the first line is already declared incomplete. **M2** — `RequeueSelection.fromLinks` refuses a link no platform resolves and names it back. `ModPlatforms.ofUrl` answers `Unknown` for a typo'd host, and queueing that reported "Queued 1 of 1" before failing hours later inside the daemon, in a log nobody is reading. M2's fix consolidates rather than patches, and that is deliberate: the **one-shot path had the identical hole** — `args.map { GrindCandidate(it, slugFromUrl(it), 0, ModPlatforms.ofUrl(it)) }`, the same expression — so fixing only the queue would have left the same defect one call site away. Both now go through `fromLinks`, and `slugFromUrl` moved with it. Teeth checked: restoring `readText()` fails `anOversizedConsoleIsNeverReadWhole- IntoMemory` (measured on a 64 MiB console), and restoring the two-branch `pollInterval` fails `aRemainderThatHasAlreadyElapsedSlicesToZero`. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>mirrorre-runnable deafdd5d73`9.0.0-alpha.7` died in `Mirror release outward` on a GitHub 422 naming three fields at once -- `tag_name is not a valid tag`, `Published releases must have a valid tag`, and an invalid `target_commitish`. All three are one cause with three symptoms: GitHub did not have the release commit, so there was nothing to mint the tag from. `GET /commits/50fd50f37` answered `422 No commit found for SHA`, GitHub's `alpha` still sat on `RELEASE: 9.0.0-alpha.6` 69 commits back, and `pushed_at` was some five hours older than the tag. The Forgejo release was complete and correct throughout -- id 1730, 12 assets, the VirusTotal section, the tag on the right commit -- so nothing in the workflow was wrong. Its precondition was false. The cost was in the reading: a 422 about `tag_name` sends you to the tag, the changelog and the release payload, three places that were all fine. `Require GitHub to have the release commit` now probes `GET /commits/${{ github.sha }}` before the POST and fails naming the mirror, with the repair steps in the message. It polls 20x15s rather than failing at once, because a push-mirror's `Sync when new commits are pushed` is an opt-in checkbox and the periodic interval defaults to 8h -- a mirror merely queued behind a large push is worth a few minutes. `401`/`403` short-circuits as a credential problem rather than waiting five minutes to blame the wrong subsystem. `Mirror to GitHub` also gains the reuse-and-PATCH path the `release` job already has, and skips assets already attached. Re-running this job alone is the only safe repair -- a whole-workflow re-run would re-publish Maven and Docker for an already-released version -- and without reuse a re-run after a half-finished asset loop dies on GitHub's `already_exists`. Run 222 is the case in point: its mirror job uploaded all twelve assets and then failed in the step after. `target_commitish` is not the fix for this, and its comment now says so: it covers an absent *tag* only, and the commit it names still has to be there. `9.0.0-alpha.6`'s GitHub release carries `target_commitish` `f7ebba4e` where `.1` through `.5` carry `main`, which is proof the mechanism works when the mirror is current. Verified: both new shell bodies pass `bash -n`, all thirteen workflows still parse, and `mirror` keeps job index 5 so the run-URL references elsewhere stay valid. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>Recreates the `news` job `main` still carries in `github_release.yml` and `github-prerelease.yml`. Those two differ only in the words "release" and "Pre-Release", so `prepare.outputs.prerelease` picks between them and this is one job rather than two near-identical workflows. The message keeps 0xC0FFEE, the four i.griefed.de images, the author block and both the `content` line and the embed `description`; the `Platform` field becomes Forgejo, which is where the release now lives and what the embed links. Two findings from WORKFLOW-AUDIT.md are fixed by construction rather than carried across. M6: the original downloaded ChaoticWeg/discord.sh's *master* and executed it, which is remote-code-execution-by-trust for what is only a JSON builder -- the payload is now built the way qodana.yml already builds its own. M4: the deprecated `::set-output` disappears with the separate date step that fed it. Every value reaches python through `env:` rather than being interpolated into the shell, per L2. `needs: [prepare, release, maven, docker]` -- deliberately not `mirror`. This announces the Forgejo release, which is the canonical one, and `mirror` is the job that failed on both releases of 2026-08-23; gating the announcement on it would have silenced two perfectly good releases. It does wait for `maven` and `docker`, on the same reasoning `mirror` gives for its own `needs`: nobody should be pointed at a release whose artifacts and images do not exist yet. `virustotal` is left out because its failures are explicitly tolerated and this does not reproduce the release notes. Verified by executing the step, not by reading it: with a `curl` shim it produces valid JSON for both the release and prerelease shapes, with the right wording, URLs and `color == 0xC0FFEE`, and the heredoc dedents correctly inside the YAML block scalar. The guard was exercised both ways -- unset and empty `WEBHOOK_URL` print the skip line, exit 0 and build no payload -- with `set -e` after it, so a webhook that is configured and then fails is still a hard failure. `${{ github.server_url }}/${{ github.repository }}` is known to expand on this instance because qodana.yml's notify job posts with it, which is also how `WEBHOOK_URL` is known to be configured. Both embed URLs answer 200, and Discord's own docs confirm `username`/`avatar_url`/`content`/`embeds` and a 10-embed cap against the one embed sent. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>newsis not gated on the mirror 01cc1f2f54Red on purpose. Under `--network none` the daemon has no address to map, so it writes no `<ip> <hostname>` line into `/etc/hosts` and `getaddrinfo` on the container's own name fails. Observed against a live daemon (docker 29.7.2): wget: bad address '11419499a196:1' which is the same lookup failure a boot reports as UnknownHostException: 928f022c75b5: Temporary failure in name resolution three times, before any mod is loaded, because log4j calls `InetAddress.getLocalHost()` while it configures itself. Asserted through `wget` rather than by reading `/etc/hosts`: it calls the same `getaddrinfo` the JVM does, so the guard covers resolution and not merely that a line was written. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>Adopt the name resolution the docker daemon gives a *networked* container: it writes an `<ip> <hostname>` line into /etc/hosts, which is the only thing that makes a container's own name resolvable. A grinder boot runs `--network none` and therefore has no address, so no such line was written and `getaddrinfo` failed on the container's own name. A Minecraft server asks for it immediately — log4j calls `InetAddress.getLocalHost()` while configuring itself — so every boot opened with three UnknownHostException: 928f022c75b5: Temporary failure in name resolution stacktraces before a single mod was loaded (Modrinth-chloride-NeoForge.log). The hostname is now fixed (`spc-grinder`) rather than the daemon's default, because the mapping must be part of the create call and the container id only exists after it. It is pointed at loopback: the mod must stay unable to reach anything, but it must be able to look *itself* up. Measured against docker 29.7.2, `--network none`: before wget: bad address '11419499a196:1' after wget: can't connect to remote host (127.0.0.1): Connection refused i.e. resolution now gets as far as the connect. `--add-host` is honoured with no network at all, which is what makes this possible without giving the boot one. `theContainersOwnHostnameResolvesWithoutANetwork` was red on the commit before this one and is green here. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>Red on purpose, and it shows why the endpoint needs a context of its own rather than only a bundled file: without one the catch-all `/` answers a browser's unprompted icon request with the whole verdict table — /favicon.ico was served as Optional[text/html; charset=utf-8] Asserted on the PNG signature, because a 404 page and an HTML fall-through are also non-empty 200 bodies. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>Red on purpose, and the red is the live defect: the console of `CurseForge-ars-nouveau-Forge.log` (2026-08-23), pasted verbatim, classifies as CRASHED today — expected: <INCONCLUSIVE> but was: <CRASHED> so a mod whose code never ran was on its way to a clientside HIGH. The server died in `BootstrapLauncher.main` before FML existed; nothing about the mod was exercised. Also pins the ServerStarterJar's own pre-launch give-ups (no run-script to read arguments out of), and adds the new rung to the whole-ladder guard order test, between launch-failure and killed/OOM. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>The starter jar cannot launch a Forge install whose module path it has to synthesise a boot layer for. `USE_SSJ=false` is the escape hatch HELP.md already documents for it ("people ran into trouble when using Forge and Minecraft 1.20.2 and 1.20.3") — a pack author flips it by hand, an unattended grinder never can, so the knob is now taken by default on every pack it generates. Only `setupForge` reads it; NeoForge is untouched. Measured on Forge 1.20.2-48.1.0 installed by its own `--installServer`, booted under `--network none` with a 3g cap on Temurin 17 (the grinder's Java for that Minecraft): starter jar IllegalStateException: Could not find parent layer for module `java.management.rmi` read by `JarJarMetadata` at ...SecureModuleClassLoader.<init>(SecureModuleClassLoader.java:137) at ...BootstrapLauncher.main(BootstrapLauncher.java:117) argfile [Server thread/INFO]: Done (5.183s)! For help, type "help" Same install, same JVM, same flags otherwise. Note the module named in the failure is *not* the one from the production log (`java.base` read by `net.minecraftforge.eventbus`) — the iteration order differs per run, which is why the classifier's guard matches the message and not the module. Set on the install boot as well: caching a layer installed one way and launching it the other would break the offline boot. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>Red on purpose, and a gap the Forge argfile fix opens rather than an existing one: Forge now launches with `@libraries/.../unix_args.txt`, so an install layer cached without that file fails with the JVM's own Error: could not open `libraries/.../unix_args.txt' verbatim from Temurin 17 — non-zero, no ready-line, currently CRASHED. It is the same incomplete-cached-install case the jarfile messages beside it already cover; only the file the boot depends on changed. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>Red, and the red is audit iteration 27's H1: `Error: could not open` is unanchored and case-insensitive at rung four, while the client-only-class marker is at rung seven, so a console holding both [19:41:26] [main/ERROR] [polytone/]: Error: could not open assets/…json java.lang.NoClassDefFoundError: net/minecraft/client/multiplayer/ClientLevel scores INCONCLUSIVE — a true clientside HIGH dropped: expected: <CRASHED> but was: <INCONCLUSIVE> The launcher writes its message as the whole line; every mod line carries a timestamp and level prefix. That is the difference the guard has to key on. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>Red on purpose — the guard names a template function that does not exist yet. Established against real installs on 2026-08-23, because the first hypothesis ("every Forge from 1.20.2 on") was wrong and only measurement showed it: | Minecraft | argfile the installer writes | ServerStarterJar | |---|---|---| | 1.17 – 1.20.1 | `-p <module path>`, cpw securejarhandler | works — cpw's loader falls back | | 1.20.2 | `-p <module path>`, Forge securemodules | dies before the server starts | | 1.20.3 onward | `-jar forge-<ver>-shim.jar` | works — the starter jar's own jar mode | Boots: `1.20.2-48.1.0` on Temurin 17 dies at `SecureModuleClassLoader.<init>` through the starter jar and reaches `Done (5.183s)! For help` from its argfile; `1.21.1-52.1.0` reaches `Done (6.593s)!` *through* the starter jar, logging `Launching in jar mode, using jar: forge-1.21.1-52.1.0-shim.jar`. 1.20.2's install carries no shim jar and its argfile opens `-p … --add-modules ALL-MODULE-PATH`; 1.20.3's and 1.21.1's do carry one. 1.20.3 is bypassed even though its shim jar says it would work: HELP.md records it as affected, and over-including a Minecraft version with two Forge builds in total costs only hosting compatibility, while under-including it costs a server that cannot start. The version matrix includes `26.2` and `26.20.2` — the latter matches 1.20.2 component for component below the major, so a rule that skips the major test bypasses the starter jar for every modern pack. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>The templates already bypassed it automatically for one reason (Java 24+, which cannot grant the Security Manager SSJ needs to trap the installer's exit). This adds the second: Minecraft 1.20.2/1.20.3 Forge, which SSJ cannot launch at all. Until now the only remedy was the operator finding the note in HELP.md and setting USE_SSJ=false by hand — and an unattended consumer never can. The affected range was *measured*, and the first hypothesis was wrong. Forge's installer writes one of two argfiles: | Minecraft | argfile | ServerStarterJar | |---|---|---| | 1.17 – 1.20.1 | `-p <module path>`, cpw securejarhandler | works — cpw's loader falls back to the platform classloader | | 1.20.2 | `-p <module path>`, Forge securemodules | dies: `Could not find parent layer for module` | | 1.20.3 onward | `-jar forge-<ver>-shim.jar` | works — SSJ's own jar mode, no synthesised layer | Boots on Temurin, `--network none`, 3 GiB, through SSJ: 1.20.1-47.4.0 Done (ready-line reached) 1.20.2-48.1.0 IllegalStateException at SecureModuleClassLoader.<init> (its argfile from Forge itself: Done (5.183s)! For help) 1.21.1-52.1.0 Done (6.593s)!, logging "Launching in jar mode, using jar: forge-1.21.1-52.1.0-shim.jar" 1.20.2's install carries no shim jar and its argfile opens `-p … --add-modules ALL-MODULE-PATH`; 1.20.3's and 1.21.1's do carry one. So "every Forge from 1.20.2 on" — which reading the securemodules source alone suggested, since the throw is still in 2.2.21 — would have cost every modern pack the hosting compatibility SSJ exists to provide. 1.20.3 is bypassed on HELP.md's word rather than a boot: it ships the shim, so it likely works, but it has two Forge builds in total and over-including costs only that compatibility while under-including costs a dead server. All three shells, verified by execution rather than by reading, across 1.17.1/1.19.2/1.20/1.20.1/1.20.2/1.20.3/1.20.4/1.21.1/26.2/26.20.2 — bash via the new test, fish and PowerShell by driving the extracted function in containers. All ten agree in all three. `26.20.2` is the trap the major test exists for: it matches 1.20.2 component for component below the major. Whole templates also pass `fish -n` and PowerShell's own parser. The duplicated argfile block is folded into one, which is the enabling change rather than cleanup: a second refusal reason would otherwise be a third copy. The Java guard's literal text is untouched, so its own pin still holds. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>Red, and it is my own doc comment that is wrong rather than merely unpinned: the helper's KDoc claims "anything unreadable falls through to a bypass, which is the safe direction", and it does not — Minecraft 26w05a was launched via the ServerStarterJar; expected Forge's argfile Comparing an unreadable component is not harmless either. bash shouts at the operator: bash: 26w05a: value too great for base (error token is "26w05a") and PowerShell's `[int]` cast throws outright (`THREW: RuntimeException`), which the ps1 template would propagate out of the function. The bypass is the correct polarity for the same reason the Java guard's is: the argfile path works for every Forge from 1.17 on, while the starter jar has a known failure, so a version nobody can parse must not be handed to the latter. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>Two call sites in `setupForge`, one of them mine and one pre-existing, both comparing a `$SEMANTICS` component without checking it is a number first. The new launch-path guard exposed the old one. What it costs, per shell: bash `bash: 26w05a: value too great for base` printed at the operator fish the comparison is an error ps1 `[int]` THROWS — a snapshot-shaped version takes the whole start script down, which is a live defect in the launcher-era check And the polarity was wrong in the new guard: the doc claimed an unreadable version falls through to the bypass, while it actually fell through to the ServerStarterJar — the one route with a known failure. The argfile path works for every Forge from 1.17 on, so unreadable now means bypass. The launcher-era check keeps falling to the modern era, which is where anything not plainly 1.x-and-old belongs anyway, so its pinned behaviour is unchanged. Both call sites fixed together because it is one concern: screen a component before comparing it. Verified by execution in all three shells across 1.17.1/1.19.2/1.20/1.20.1/1.20.2/1.20.3/1.20.4/1.21.1/26.2/26.20.2/26w05a — all eleven agree, no shell complains, and both templates still pass `fish -n` and PowerShell's own parser. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>Red against a live daemon: `sh: line 0: /tmp/echo: Permission denied`. Docker mounts a `--tmpfs` `nosuid,nodev,noexec`, and the rootfs is read-only, so nothing can write a `.so` and map it executable. Measured with the production posture otherwise unchanged (no network, read-only rootfs, all caps dropped, no-new-privileges), JNA loading its own native library: /tmp:rw UnsatisfiedLinkError: /tmp/jna….tmp: failed to map segment from shared object /tmp:rw,exec JNA-OK pointerSize=8 That failure reaches a boot console as `NoClassDefFoundError: Could not initialize class com.sun.jna.Native` — seen in `Modrinth-polytone-NeoForge.log`, harmless there, but a mod needing JNA *at load time* would die for the environment and arrive at the classifier looking like a crash. Asserted by executing a binary out of `/tmp`, because the mount flag is the mechanism and running the file is the promise. The flags are then checked for what must NOT be given away with it: `nosuid` and `nodev` stay. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>Owner-approved weakening of the sandbox, and the cost is smaller than the benefit turned out to be. Docker mounts a `--tmpfs` `nosuid,nodev,noexec` and the rootfs is read-only, so nothing inside a boot could write a shared object and map it executable. The report that started this was cosmetic — `Could not initialize class com.sun.jna.Native` in polytone's crash report — but booting a real server under the actual posture showed it is not: noexec [io.netty…NativeLibraryLoader]: /tmp/libnetty_transport_native_epoll_ aarch_64….so exists but cannot be executed even when execute permissions set; check volume for "noexec" flag [minecraft/ServerConnectionListener]: Using default channel type exec [minecraft/ServerConnectionListener]: Using epoll channel type So every boot the grinder has ever run fell back from Netty's native epoll transport to NIO. And JNA itself, with the production posture otherwise unchanged (no network, read-only rootfs, all caps dropped, no-new-privileges): rw UnsatisfiedLinkError: /tmp/jna….tmp: failed to map segment from shared object rw,exec JNA-OK pointerSize=8 What is given away: a mod can run a native binary it wrote into `/tmp`. Against a workload that is already an untrusted JVM — an arbitrary-code execution engine — inside a container with no network, no capabilities, no privilege escalation, a read-only rootfs and a non-root user, none of which changes. `nosuid` and `nodev` stay: docker applies both even when only `exec` is asked for, verified rather than assumed (`rw,exec` and `rw,nosuid,nodev,exec` both yield `rw,nosuid,nodev,relatime`). `aBootCanExecuteFromItsTmpfsWhileKeepingTheRestOfItsHardening` was red on the commit before this one (`/tmp/echo: Permission denied`) and is green here; it executes a binary out of `/tmp` rather than reading the mount flag, then checks the flags for what must not have gone with it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>Last batch: the CLI verbs, the remaining web layer, and the GUI leaves. Reading each one rather than filling it in caught two things worth having: - LarsonScanner has two companions, and an earlier pass in this session put the outer one's prose ("the scanner's fallback colours") on the *inner* one, which holds rendering-quality levels. Wrong text on the wrong member is worse than none — both now say what they actually hold. - `UpdateDialogs.updateButton` looked documented and was not: the line above it is a commented-out block ending in `*/`, which the insertion pass mistook for a doc comment. Only dokka still reporting it revealed that. Facts recorded where an implementer will meet them rather than only in the module notes: `ControlPanel.panel` carries the anchoring rule (a running generation is tied to the always-visible bar, so a tab switch cannot cancel it), `FileCleanupSchedule` carries the direction of its danger (it deletes files whose ids are absent from the database, so it must never run against one it cannot read), and `SmartScroller.adjustmentValueChanged` states its whole purpose — a log pane that always jumps to the end is unreadable while it is being read. Module totals now 0/0/0/0. Suites unchanged: api 361, clientside 139, grinder 352, app 149, zero failures. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>Completes the rule engine: SPC_GRINDER_BOOT_RULES (default <home>/boot-rules.json) reaches BootVerifier through ContainerCandidateVerifier as a supplier, so a file edited during a multi-day run takes effect on the next boot. firedRule is carried LoaderVerdict -> GrindVerdict -> report and CSV, as a column rather than only a phrase inside Detail. "How many verdicts did this rule decide?" is the only way to find a rule firing too broadly, and sorting the table on it answers that at a glance. /status gains bootRules { source, ruleCount, errors }. That is not decoration: the loader deliberately keeps the last good rule set when a save breaks the file, which would otherwise hide the breakage completely -- an operator would see rules that silently stopped matching. deploy/boot-rules.example.json ships beside the unit with the two worked examples, including one that deliberately carries NO verdict, since "NoClassDefFoundError on another mod's screen class" is usually a dependency problem rather than sideness. Three existing expectations changed, all the CSV header gaining Rule: VerdictCsvExporterTest's two header assertions and VerdictReportRendererTest.embedsTheCsvForTheDownloadButton. An existing expected value changing is why this is feat: and not refactor:. Knob documented in README and the systemd unit in this same commit, as ReadmeConfigurationTest and SystemdUnitConfigurationTest require. Clientside 173 tests, grinder 364 tests, 0 failed. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>An unknown verdict string used to drop the rule, and an absent one used to mean "annotate, let the ladder decide". Both now resolve to INCONCLUSIVE. Why that is the safer direction, and it is not obvious: dropping a rule sounds neutral, but it hands the console straight back to a ladder that may reach CRASHED on its own -- and CRASHED is the one outcome that publishes to everyone polling /as-properties. INCONCLUSIVE is the only verdict that can never reach HIGH, so a rule somebody has not finished thinking about now costs coverage instead of risking a false positive. The tradeoff, stated because it reverses an earlier requirement ("if none is specified, determine by grinder"): pure annotate-only rules no longer exist. A matching rule always decides. The shipped example's second rule relied on that, and now documents INCONCLUSIVE as what it means. Failing safe still does not mean failing silently -- a misspelt verdict is recorded in ConsoleRuleSet.errors and surfaced on /status, so an operator sees the typo rather than wondering why a rule "stopped working". Drops and fallbacks now go deliberately opposite ways: no id or no pattern still DROPS the rule (it could never be traced back to, or could never match), while an unreadable verdict FALLS BACK. Clientside suite: 173 tests, 0 failed. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>Red: Unresolved reference 'versionConstraint' -- ModDependency has no such field yet -- plus a changed expected value in fabricDependenciesAreRecordedWithoutThePlatform. The exclusion sets conflate the platform with a mod, and their own doc comment gives the rule they break: "ids that are the platform rather than a mod". FabricScanner (fabric|fabricloader|java|minecraft) ^^^^^^ Fabric API -- a mod, and the most depended-on one in the ecosystem QuiltScanner (quilt_loader|quilt_base|quilted_fabric_api|java|minecraft) ^^^^^^^^^^^^^^^^^ QFAPI, Quilt's port of it -- also a mod Both are genuinely required on a server by the mods that declare them, so dropping them meant they could never be reported as the dependency they are, and never rescued back into a pack that had disabled them. String.matches is a FULL match, so removing `fabric` affects only the literal id -- `fabric-api-base` never matched it either way, and aBareQuiltDependencyCarriesNoConstraint plus the extended both-forms guard pin that a dependency stating no range keeps a null constraint rather than an invented one. These are manifest parsers, which fail silently -- a wrong branch yields a plausible value, not an error -- so every guard here builds a real jar in a @TempDir and executes the scanner, per the module's established pattern. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>Red: Unresolved reference 'nekodetectorFindings'. Reported 2026-08-29 with a full stack: a host whose runtime classpath lacked SecurityScans died with an unhandled NoClassDefFoundError straight out of checkConfiguration, taking the generation coroutine with it ("Exception in thread pool-5-thread-1"). The modpack was fine; the scanner was simply not there. Two independent reasons it was fatal, both pinned here: - scanUsingNekodetector catches `Exception`, and NoClassDefFoundError is an `Error`. It was never going to be caught. - The failure happens while RESOLVING THE CALL -- the class cannot be loaded, so no statement inside that method ever executes. Catching inside it could not have helped at any point. The guard has to sit at the call site, which is what nekodetectorFindings will be. Nekodetector is a third-party scanner resolved from jitpack and is an optional safety net, not a precondition for building a server pack. realFindingsAreStillReported is the counterweight: this is the malware path, so a guard that silently emptied a real result would be worse than the crash it replaces. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>Turns the previous commit's guards green. ModFile gains `version`, populated from Modrinth's version_number and CurseForge's displayName. Both platforms already had it and both threw it away, so a constraint could be recorded but never actually matched. CurseForge's displayName is often decorated ("JEI 15.2.0.27 for 1.20.1"), which is fine: VersionConstraint reads what it can and accepts what it cannot. pickDependencyFile takes an optional constraint and treats it as a PREFERENCE, never a filter: it narrows to the satisfying files, and falls back to the whole set when none satisfies. Returning null where it used to return a file would turn a bootable candidate into a refusal, and refuseForMissingDependencies scores a refusal INCONCLUSIVE -- so the mod would silently stop being verified rather than fail visibly. The narrow-then-fall-back shape is what makes that impossible by construction, and the loader resolution (including the one-way Quilt-to-Fabric fallback that exists precisely for Fabric API) is extracted so both attempts share it. Clientside suite: 187 tests, 0 failed. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>Red: refuseForMissingDependencies takes no `unmapped`, and neither unmappedDependencyNote, stageableRequirements nor refuseForTooManyDependencies exists. This split is the decision that determines whether reading jar-manifest dependencies improves the engine or wrecks it, so it is pinned before any of it is built. Platform refs and manifest ids are not equally trustworthy. A platform ref is a project the author explicitly linked. A manifest id is a bare string that may name something bundled inside another jar (fabric-api-base ships INSIDE Fabric API), something the loader itself provides, or something optional in practice. refuseForMissingDependencies aborts a boot as INCONCLUSIVE -- so treating every unresolvable manifest id as a refusal would convert a large share of today's WORKING boots into INCONCLUSIVE. That is a strict regression wearing a feature's clothes, and anUnmappedManifestDependencyDoesNotRefuseTheBoot is what stops it. unsatisfied -> refuses. Platform misses, and manifest ids the registry DID map and then failed to stage: cases we chose to trust, so a failure there is a real gap. unmapped -> never refuses, always reported. A registry coverage gap must be visible, not silent. Beside those: the environment's own ids (minecraft, java, the loaders) are never staged as mods; a requirement the platform already resolved is not downloaded twice; and MAX_INJECTED_DEPENDENCIES caps the pack, because a 40-jar pack's failure says nothing about the candidate. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>main 294 -> 259 lines, but the line count was never the point. ~20 assertions across 8 test files grepped that function's SOURCE TEXT, because main boots Docker and cannot be executed -- and a source scan degrades silently, stopping coverage of anything that moves out of the file it scans without ever failing. GrinderConfiguration reads every knob once and is executable. KNOBS is a real list the README and systemd-unit guards now ITERATE rather than regex out of Kotlin, and from(lookup) takes the environment as a parameter, so a test asserts what the daemon would do with a value instead of that a literal appears somewhere. Those two guards went to ZERO source greps and gained assertions they could not previously make at all: a malformed number falls back to its documented default, a blank value reads as unset, every path defaults beneath the home while staying individually overridable. GrindLoop is the sweep, and it had NO TEST WHATSOEVER while it lived inside main -- requeue-before-catalog, committing only what was reached, polling `running` between steps. It takes evictUnusedInstalls and verdictCount as functions rather than LoaderCache and VerdictStore, which is what keeps its tests free of a Docker-bound installer. The guard that matters most operationally is now executed rather than grepped: a stop arriving DURING the drain must not start the catalog pass, or the daemon burns another boot budget per candidate after being asked to stop. Two of my first three loop assertions were wrong, and the loop was right: `running` is polled between steps deliberately, so a counter-based fake stops it mid-pass and proves nothing. The flag has to be flipped from the injected sleeper, which is where a real stop lands. Landmined. 18 source greps remain, in ReportBindWiringTest, ContainerLimitsWiring Test, FallbackListWiringTest, ShutdownWiringTest and GrinderSpc EnvironmentTest. They assert JOINS -- a configured value reaching the collaborator it configures, the shutdown hook's ordering -- which genuinely cannot be executed, and they now grep config.<property> rather than env("NAME", "default"). The values they stood in for are asserted for real elsewhere. Behaviour is unchanged: the daemon reads the same variables with the same defaults and runs the same passes. Three GrindPoolShutdownTest source greps were deleted rather than retargeted, because GrindLoopTest asserts the same properties by execution. Not done, deliberately: the composition itself stays in main. Extracting it would move the remaining wiring guards without making any executable, since what they assert is precisely that the composition happens. Grinder suite: 386 tests (was 382), 0 failed. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>Turns the previous commit's four red guards green, and keeps the fifth -- the ordering guard -- green. Clientside suite 221, 0 failed. Adds one marker and widens another, both in the band *below* `clientOnlyClassMarker`, so decisive client-only evidence still outranks every excuse here: sandboxNetworkMarkers (new) UnknownHost/Connect/NoRouteToHost/ SocketTimeout -- boots run `--network none` dependencyFailureMarkers + Quilt's `requires version [x, y) of z` + mixin ClassMetadataNotFoundException + the legacy MixinTweaker CNFE Measured by classifying the real published logs before and after: the 21 logs Griefed sent 21 CRASHED -> 11 CRASHED / 10 INCONCLUSIVE 200-log random sample 200 CRASHED -> 113 CRASHED / 87 INCONCLUSIVE Both true positives in the 21 are retained: `arcane-vortex` on FML's `for invalid dist DEDICATED_SERVER`, and `avm-mod` on `net/minecraft/ class_746`. Nothing that carried client-side evidence moved. Deliberate false negative: a mixin whose missing target *is* a client class is now excused as INCONCLUSIVE, because ClassMetadataNotFoundException does not match the client-only marker. That is the safe direction -- an excused true positive costs a re-check, a published false positive costs a user a working mod -- and the alternative is broadening the one marker the ladder documents as un-fakeable. Residual, not addressed here: 113 of the 200 still score CRASHED and some carry no sideness signal either (a mod's own missing dependency, a loader built against another Minecraft version). Those need per-case evidence rather than another blanket marker; the console-rule file exists for them. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>Turns the previous commit's three red guards green and keeps the fourth green. Grinder suite 407, 0 failed. VerdictField gains the `sortKey` the plan specified -- defaulting to `text`, overridden only by CONFIDENCE, whose cell text is an enum name. Sorted as text that ran alphabetically, so INCONCLUSIVE ("nothing was learned") outranked MEDIUM and LOW whenever a reader clicked the header. Only the default order had ever carried a rank. The rank itself now lives once, as VerdictField.CONFIDENCE_RANK. Both the report's default order and VerdictCsvExporter's own copy used to declare it separately, so the table and the export could drift into disagreeing about what "highest confidence first" means. The default order is now expressed through the same sortKey as the named sort, so those two cannot disagree either. The key is zero-padded ("00".."03") so it sorts as text alongside every other column, and the sorter needs no special case for a numeric one. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>The verification step the plan asked for and that had never been run: a real server-pack generation over a modpack containing Fabric API, with and without a clientside list naming it. Two of the three pass. `aHistoricalFabricDependencyAlsoRescuesFabricApi` fails, and it is a real defect rather than a bad test. Verified against the real artifact rather than assumed: Fabric API 0.92.11+1.20.1, fetched from Modrinth's CDN, declares `"id": "fabric-api"` and `"provides": ["fabric"]`. So a mod writing `depends: {"fabric": "*"}` -- the historical id, and the one Griefed named when asking for this work -- is satisfied by that jar through `provides`. Neither FabricScanner nor QuiltScanner reads `provides`; both record only `id` and `depends`. ModListCompiler's rescue then matches `ModDependency.modID` against the disabled mod's own `modID` literally, "fabric" against "fabric-api", and misses. A custom clientside list naming Fabric API therefore still strips it out from under every mod that declares the historical id -- producing exactly the pack that installs and dies on load which B0 was meant to prevent. So B0 only half-landed: it fixed the exclusion sets, but the rescue cannot use what it now records unless the alias is recorded too. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>Turns the previous commit's guards green, and closes what the deployed grinder was doing to the served fallback list. CRASHED was reachable two ways -- clientOnlyClassMarker, which no broken harness can fabricate, and the bare exit-code rung, which means only "exited non-zero and nothing recognised why" -- and afterwards the two were indistinguishable, so /as-properties published both alike. Sampled against the live grinder on 2026-08-31, FOUR OF FIVE published boot logs were the latter, and create_ltab was already in the list because of it. BootDecision names the rung that settled a boot and marks exactly two as decisive: CLIENT_ONLY_CLASS, and OPERATOR_RULE because a rule reaching CRASHED stated it deliberately (an undecided rule resolves to the ladder or to INCONCLUSIVE, never to CRASHED). Classification.decidedBy carries it through BootOutcome -> LoaderVerdict -> GrindVerdict, and FallbackPropertiesRenderer publishes nothing else. Three marker sets for the four misclassified logs, all BELOW clientOnlyClassMarker so a mod reaching a client class *through* a mixin still reads CRASHED -- outranking it there would discard true positives, the expensive direction: mixinApplyFailureMarkers @Inject/@Shadow found no target, FAILED during APPLY -- the jar and its Minecraft disagree, so the mod never ran loaderSolverFailureMarkers Quilt's "Unhandled solver error" and "(0 valid options, 0 invalid options)", a phrasing sharing NOTHING with Fabric's, so dependencyFailureMarkers never reached it runtimeMismatchMarkers "Missing language javafml version [46,)", java.lang.module.ResolutionException -- a Forge jar staged for a NeoForge boot All five consoles are committed as literal excerpts, log 5 included as a CONTROL: the one the ladder already classified correctly must stay that way, or the fix has moved the problem rather than solved it. A legacy verdict has no recorded decision and therefore does not publish. That empties the grinder's 34 contributed entries until a sweep re-grinds them, which is the intended trade -- an empty contribution beats a wrong one, and grandfathering the old rows in would keep exactly the entries this gate exists to remove. Four existing tests failed on the gate, as designed: their fixtures built HIGH verdicts with no decision. grindVerdict() now defaults to a decisive one -- a fixture standing for "a HIGH finding" should stand for a legitimate one -- and the refusal is pinned explicitly in FallbackPropertiesPublicationGateTest rather than implied by every fixture. Also corrected two documentation drifts found while verifying: the module doc said the ladder was "eight rungs" (it was eleven, is now fourteen) and theGuardOrderIsPinnedAsAWhole's own KDoc omitted the rule and sandbox rungs while asserting both. The count is replaced with an instruction to re-derive it from classify, having been wrong twice. Clientside 238 tests, grinder 418 tests, 0 failed. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>My own harness was wrong on its first live run and would have overstated the finding. It graded 91 consoles for 43 HIGH verdicts, because a candidate is booted several times -- first attempt, newest-build re-check, each other-version re-check -- and every non-survived attempt keeps its own console. So it counted one verdict repeatedly and, worse, counted a re-check attempt against a verdict some OTHER attempt decided. Now grouped by tuple, and a verdict is defensible if ANY of its kept consoles carries decisive evidence -- the charitable reading, and the only one that matches what a verdict means. The difference is not cosmetic. Against the live grinder: per console (wrong): 67 of 91 rest on no decisive evidence per verdict (right): 27 of 43 Measured 2026-08-31 at grinder.serverpackcreator.de, 43 HIGH verdicts over 91 consoles, no rule file: 16 CLIENT_ONLY_CLASS <- provable 12 EXIT_CODE <- not evidence 8 DEPENDENCY_FAILURE <- not evidence 4 MIXIN_APPLY_FAILURE <- found by the new marker 1 RUNTIME_MISMATCH <- found by the new marker 1 LAUNCH_FAILURE 1 LOADER_BOOTSTRAP_FAILURE The audit FAILS today, which is its job. It goes green once the fixes are deployed and a re-grind replaces those verdicts. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>Three verdicts recovered by the LWJGL rule, measured against the live store rather than estimated: deployed, no rules 16 defensible / 27 not + the new markers 16 / 27, redistributed (EXIT_CODE 12->6, RUNTIME_MISMATCH 1->7) + the LWJGL rule 19 / 24 The markers recover nothing by design -- they move verdicts to INCONCLUSIVE, which is the correct answer. Only a verified rule recovers, and it recovered exactly the three that were verified. Also records the operator action no code change covers: the cached loader install for NeoForge 21.11.45 / Minecraft 1.21.11 is broken and all 90 boots against it are worthless, so that tuple needs invalidating and re-grinding. And a gotcha the first audit run hit: a Gradle test JVM's working directory is the MODULE directory, so SPC_GRINDER_BOOT_RULES wants `deploy/boot-rules.example.json`. Prefixing the module name finds nothing and the audit reports "0 rule(s) from none" rather than failing, which is easy to miss in the header line -- I missed it once. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>Why the browser install can be skipped without anybody noticing, which is the reported symptom ("installed playwright and chromium, still getting Could not download"). `install-grinder.sh` discovered the service's JVM with sed -n 's/^Environment=JAVA_HOME=//p' "$script_dir/$UNIT_NAME" i.e. from the unit **in the checkout**. Every knob in the shipped unit is commented out, JAVA_HOME included — verified: that sed returns the empty string against it — and the operator uncomments what they need in `/etc/systemd/system/spc-grinder.service`, which is the copy systemd reads. `update-grinder.sh` seals it: it `rm -rf`s its checkout and re-clones on every run, so the shipped copy is pristine every time and an operator's edit is invisible by construction. Consequences on a host whose java comes from JAVA_HOME in the installed unit rather than from systemd's bare PATH (a Temurin tarball under /opt, SDKMAN, asdf — none of which are on /usr/local/sbin:...:/bin): - `service_java` resolved empty, so the headless-browser install added in the previous commit hit its no-JVM branch and SKIPPED, having printed one warning into a long transcript; - the pre-existing "the service will not find a JVM" warning fired at a service that starts perfectly well, which is how an operator learns to ignore it. `unit_file` is now resolved once: the shipped copy when `--install-unit` will overwrite the installed one (it is what will be in effect), otherwise the installed copy when there is one, otherwise the shipped copy as a first-install preview. Executed against all three states, the block picks INSTALLED / SHIPPED / SHIPPED respectively. The startup banner prints which copy it read, because every check in the preflight means something different depending on the answer. JAVA_HOME deliberately does not go through `unit_value`, which keeps the *first* `Environment=` line: `Environment=SPC_GRINDER_HOME=` sits at line 56 and JAVA_HOME at 158, so that helper would have returned the wrong variable. Two related traps closed while here: - **The closing summary told the operator to edit the checkout's unit.** With update-grinder.sh that directory is deleted at the start of the next run, so a configuration made there disappears with no indication why. It now names the installed unit whenever one exists, and says a daemon-reload plus restart is what applies an edit. - **The browser steps are non-fatal, so their warnings scroll past** and a deployment looks clean while missing the one thing locked CurseForge files need. A `headless browser: <status>` line is now part of the final summary, and the install lists what the service account can actually see in its own `~/.cache/ms-playwright` — which is the question being asked, answered by observation rather than by assertion. Verified by measurement, there being no harness for deploy shell scripts: `bash -n` clean; `--help` renders and `--nonsense` still rejects; JAVA_HOME extraction returns `/usr/lib/jvm/temurin-21-jdk` from a unit with it uncommented and empty from the shipped one; the resolution block, lifted verbatim out of the script so the harness cannot drift from it, picks the expected copy in all three states. Grinder suite 425, unchanged. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>`safeHref` parsed with `new URL(url, window.location.origin)`, and a base turns every unparseable value into a same-origin link: `safeHref(null)` returned `http://<report>/null` and `safeHref("::::")` returned `http://<report>/::::`. A worker row then rendered what looks like a project link and leads to a 404 on the report server itself. Parsed with **no base**, anything that is not an absolute URL throws and yields no link at all, which is correct here — a platform's `projectUrl` is always absolute, so a relative value is bad data rather than a link. The `javascript:`/`data:` refusals are unchanged; they were never the broken half. Found by the guard in the preceding commit, which is the reason that guard exists: nothing compiles a page held as a string constant. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>install-grinder.sh and update-grinder.sh were two halves of one procedure. They cross-referenced each other about fifteen times and duplicated, verbatim: the three-call docker preflight, the nologin system-account creation, the "absolute and at least two components deep" guard protecting every rm -rf, and the whole root-equivalent docker-group policy including its prose. They also had inverse root requirements, and that is what makes them one script rather than two. install refused root because a Gradle build as root leaves root-owned files in build/ that the next ordinary build cannot overwrite; update required root because its only job was dropping to an unprivileged build account. The uid already decided which half could run, so it is now the mode switch -- and there is deliberately no --mode flag, because a flag could only ever agree with the uid or lie. ./install-grinder.sh build this checkout and install it sudo ./install-grinder.sh --bootstrap first install on a fresh host sudo ./install-grinder.sh update from a fresh clone BREAKING: update-grinder.sh is gone. Its flags moved onto install-grinder.sh unchanged, and `--` is still accepted as a no-op, so `sudo ./install-grinder.sh -- --skip-image` keeps working. There is nothing left to pass through: one script, one flag namespace. The name was kept rather than moving to deploy-grinder.sh, on purpose. The copy of update-grinder.sh already deployed on the grinder host invokes $SRC/repo/serverpackcreator-grinder/deploy/install-grinder.sh BY PATH, so that filename leaving develop would have broken the next unattended update. It still resolves, and hands off to the build half as the build user exactly as before. Two things fixed while merging rather than carried across: - Arguments handed to the clone's copy are %q-quoted per argument instead of interpolated as ${installer_args[*]}. `bash -lc` takes one string, so an argument containing a space would have been re-split by the child's parser into something the caller never wrote. No current flag can trigger it; the next one taking a value would have. - Both modes now use the more helpful of the two docker-preflight messages, the one that names the apt line, rather than the terser "docker not found on PATH". Verified by running it, since shell deploy scripts have no harness here and never had one: - shellcheck clean at -S style, its strictest level, as the old pair was. - The deploy half end-to-end in a debian:stable container against a local git remote whose checked-out installer is a recorder. The hand-off runs the CLONE's copy, as the unprivileged build account, in the right cwd with the right HOME; --bootstrap forwards exactly --skip-image --clear --install-unit, no duplicate. Refusals confirmed: missing account without --bootstrap, SRC inside PREFIX, all three SRC shape guards, and a pre-existing account outside the docker group refused even under --bootstrap. No sudoers drop-in leaked in any case, including the runs that died at the clone. - The EXIT trap driven directly, both halves. A failed install that had stopped the service restarts it; a successful one does not; one that never stopped it does not touch it; and the temporary sudo grant is removed even when the run dies. The two old traps are one handler now, and it needs no mode branch because each half is already a no-op in the other mode. - The build half's guards executed on this host with the docker preflight stubbed: the PREFIX and SPC_GRINDER_HOME shape guards, and both --clear refusals (the account's whole home, the install prefix). The same bad SPC_GRINDER_HOME without --clear is correctly not rejected, since nothing is deleted. - Operative-line diff of the old pair against the combined script: every dropped line is a rename, a helper extraction or a message unification. No behaviour is missing. - ReadmeConfigurationTest and SystemdUnitConfigurationTest green after the README rewrite. README section 4 gains a one-command deploy; section 8 now describes one script. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>Red on purpose: `LoaderCache.installThrewMessage` does not exist, so the three failures are all `Unresolved reference 'installThrewMessage'` and nothing else. Run before committing. The line it pins, from the live daemon 2026-09-03: Loader install threw for NeoForge 21.1.23 / Minecraft 1.21.1: null `${it.message}` on a throwable that carries none prints exactly that, so the operator learns that a tuple failed and nothing about why -- not even the exception's type, which is free and is the difference between "the daemon refused us" and "an NPE in our own staging". The tuple two lines above it in the same journal named its cause (`Status 404: No such image`); this one could not. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>`jei-1.21.1-forge-19.52.0.422.jar` is tagged on both platforms for Minecraft 1.21 *and* 1.21.1, while its own `META-INF/mods.toml` declares versionRange="[1.21, 1.21.1)" whose `)` excludes the very version the file is named after. Upstream-wrong, not misread: JEI's gradle.properties on its 1.21.1 branch carries `minecraftVersion=1.21.1` beside `minecraftVersionRange=[1.21, 1.21.1)` — the range is built as `[start, thisVersion)` where it should be `[start, nextVersion)`. `ForgeTomlScanner.getVersionRange` reads it verbatim and `VersionConstraint.mavenRangeHolds` trims its bounds exactly like Maven's `parseRestriction`, so both halves are correct. What is wrong is that the descriptor check is a **post-selection veto rather than a selection filter**: the newest tagged version is picked, contradicted, and staging gives up — while 1.21, which the platform tags and the jar accepts, is never tried. The refusal publishes `BootResult.INCONCLUSIVE`, which overwrites a decisive verdict. Run before committing; it fails behaviourally, not by compile error, reproducing the live message against real manifest versions: Refusing to boot Forge on Minecraft 26.2: testmod.jar declares Minecraft '[26.1.2, 26.2)', but the pack is 26.2. The other four tests in the class stay green, so the fixture breaks nothing. Versions are derived from the cached manifest rather than hardcoded, so it does not rot as the snapshot moves. The jar it writes carries a real `META-INF/mods.toml` read by the actual `ForgeTomlScanner` — nothing is faked past the network boundary. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>Answers the two pins. Selection sees only platform metadata — the jar is not downloaded yet — so the newest tagged Minecraft version is picked and the descriptor gate may contradict it. Instead of giving up, re-stage on the newest version the jar's own range does accept. - `BootCandidateSelector.newestVersionSatisfying` — pure: newest version of a file that the host can boot and the jar accepts; `null` when there is none, so the caller keeps its original refusal rather than re-selecting the version just rejected. - `Prepared.Failed.declaredMinecraftConstraint` — set only when the jar's range is *why* staging stopped. Deliberately narrower than "the refusal reason": a jar carrying the wrong loader's descriptor has no second version to try, so it must not trigger a retry. The predicate is re-asked rather than inferred from `contradiction` being non-null, because that same string also reports a loader mismatch. - `reselectOnMinecraftContradiction` — exactly one retry, via `stageBootPack` rather than `prepareBootPack`: the re-selected version satisfies the constraint that caused the refusal, so a second contradiction is a different fault and must surface, not loop. Unchanged by design: fail-toward-accept. An unreadable or unparseable range still accepts, so it never reaches a refusal and never reaches this path. Suites read from build/test-results rather than inferred from BUILD SUCCESSFUL — this repo has had a green build that executed nothing: clientside 266 tests / 0 failures (262 before, +4 pins), grinder 446 / 0 (29 skipped), app green. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>`advancement-plaques` 1.7.2 for Forge / Minecraft 26.2 was refused with "Required dependency unavailable for Forge / Minecraft 26.2: prism", spending a `BootResult.INCONCLUSIVE` on a mod that never required prism. Both sources say optional: - its own `META-INF/mods.toml` declares `iceberg` `mandatory=true`, and `prism` and `toastcontrol` `mandatory=false` - Modrinth lists prism (`1OE8wbN0`) `optional` against iceberg (`5faXoLqX`) `required` The platform half was already right — `ModrinthPlatform` keeps only `dependency_type == "required"` and `CurseForgePlatform` only `relationType == 3`. The manifest half never existed: neither `mandatory` nor `type` appears anywhere in `-api`'s main source, so `ModDependency` has no field to carry the distinction and `stageableRequirements` cannot filter on one. Every declared entry is a hard requirement, whatever the author wrote. The two loader families spell it differently and both are pinned: Forge's `mods.toml` uses `mandatory = true|false`; NeoForge's `neoforge.mods.toml` dropped that for `type`, a string defaulting to `"required"` and also taking `"optional"`, `"incompatible"` and `"discouraged"` (verified against NeoForged's own mod-files documentation, not assumed from Forge's shape). `NeoForgeTomlScanner` only overrides the file name, so one implementation must serve both — including NeoForge on 1.20.2-1.20.4, which still uses `mods.toml` and `mandatory`. Absent-means-required is pinned deliberately. It is NeoForge's documented default and the safe direction: wrongly treating a required dependency as optional boots a mod without something it needs, which fails as a crash and can publish a *wrong* verdict, whereas wrongly treating an optional one as required only refuses the boot and learns nothing. Run before committing; red for the missing field only — `Unresolved reference 'optional'` in the api pins, `No parameter with name 'optional' found` in the clientside one. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>Answers the pins. Three parts: - `ModDependency.optional` — new, defaulted `false`, so every existing positional construction keeps compiling. The model had no way to express optionality at all, which is why no consumer could respect it. - `ForgeTomlScanner.isOptional` — reads **both** loader spellings: Forge's `mandatory = false` and NeoForge's `type` being `optional`, `incompatible` or `discouraged`. One reader serves both because `NeoForgeTomlScanner` overrides only the descriptor file name, and NeoForge on Minecraft 1.20.2-1.20.4 still ships `mods.toml` with `mandatory`. `incompatible` is in that set deliberately: it means the mod must *not* be present, which is the opposite of something to fetch. - `stageableRequirements` drops optional entries, so they neither get staged nor refuse a boot. Absent-or-unreadable means required, which is NeoForge's documented default and the safe direction: reading a required dependency as optional boots a mod without something it needs and fails as a crash, which can publish a *wrong* verdict; reading an optional one as required only refuses the boot and learns nothing. Not changed, deliberately: optional dependencies are still *recorded* on `ScannedMod`, only flagged. Stripping them from the scan would also remove them from `ModListCompiler`'s dependency rescue, which keeps a mod on the server because something depends on it — and this module's stated rule is that dropping a mod that does belong on the server breaks the pack while keeping a superfluous one costs a few megabytes. Filtering at the boot-staging consumer fixes the grinder without touching what lands in a user's server pack. Suites read from build/test-results after `--rerun-tasks` with the previous results wiped, not inferred from BUILD SUCCESSFUL: api 387/0 (383 before, +4 pins), clientside 267/0 (266 before, +1 pin). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>Stage 1 of the result-system redesign. Pins `Verdict { CONFIRMED, CLEAR, ERROR, INCONCLUSIVE }` and the pure policy that decides it, before any consumer is rewired. The distinction the old scheme could not make is the point: **a grind that was prevented is not a grind that learned nothing**, and reporting both as INCONCLUSIVE has cost this project twice. When `spc-grinder-runtime:latest` vanished from the Docker daemon, every candidate published INCONCLUSIVE about a boot that never happened, overwriting decisive verdicts the TTL would have left alone. The JEI and `advancement-plaques` refusals did the same, one candidate at a time. The engine always knew nothing had run; it had no verdict that could say so. ERROR is that verdict. CLEAR is the other half: a boot that ran clean and matched nothing is *proven server-safe*, which a single INCONCLUSIVE bucket destroys — "we proved it is fine" and "we learned nothing" are not the same claim. Two decisions recorded in the pins rather than left implicit: - **A crash with no confirming rule is INCONCLUSIVE, never CONFIRMED.** A flat reading of "matches a rule means exclusion-worthy" would invert the existing ladder, where excuse-markers (missing dependency, sandboxed network) sit *below* decisive client-only evidence precisely so host trouble cannot become a clientside verdict. Only a rule confirms. - **Everything but CLEAR keeps its logs.** ERROR and INCONCLUSIVE because that was asked for; CONFIRMED additionally, which was not. A confirmation publishes a mod to the fallback list, the highest-stakes output here, and a verdict that cannot name its own evidence cannot be audited: the rule id says which rule fired, only the console says what it fired on. Run before committing; red only for the missing types (`Unresolved reference 'Verdict'`, `'VerdictPolicy'`, `'StagingOutcome'`, `'BootObservation'`). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>Stage 2 of the result-system redesign. The eleven hardcoded marker groups in `BootLogClassifier` become ordinary, editable rules — "no hardcoded rules" — and this pins that the move is behaviour-preserving, because green tests written by the same pass that moves code prove nothing on their own. File order has to reproduce the ladder exactly, and the pins are shaped around the two ways that goes wrong rather than around the happy path: - **The inversion guard.** A console carrying an excuse *and* the decisive client-only marker must still confirm. Excuses sit below the evidence because a clientside mod may phone home and die on a client class both, and the marker must win — the rule this repo has held since 2026-08-29. Ordering the file the other way silently converts true positives to INCONCLUSIVE, and no single-line sample would notice. - **The fair-run guard.** A console carrying a "never got a fair run" signal *and* the decisive marker must NOT confirm: if the loader never bootstrapped, the client-class line did not come from this mod being exercised. Getting this wrong publishes host trouble as a mod's fault, which is the missing-runtime-image and poisoned-loader-cache failure both. One sample per extracted group, each taken from the evidence that group's own documentation cites, so a pattern that stops matching its founding case fails here rather than silently going quiet. The ready-line, the timeout and the exit codes are deliberately NOT rules: they are structural readings of how the process ended rather than of what it said, which is what `BootObservation` models. A rules file is for the console. Run before committing; red only for the missing type (`Unresolved reference 'DefaultBootRules'`). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>Stage 3. The platform's `server_side` and the jar's own descriptor are the two signals that decide a mod without ever booting it, and they were the last hardcoded clientside determination left — buried in `aggregateFor`'s `when`, where no operator could reach them. **One canonical fact line, not a stream per source, and that is the whole design decision here.** The old fold does not read the two signals independently: its most careful branch reads them *together*, to notice that the platform marks the server unsupported while the jar declares server/both. That is a contradiction and the case where confidence should fall rather than rise. A regex matches one line at a time, so facts spread across separate lines could never express it; rendering them into a single line makes conjunction ordinary, because a pattern naming two fields is an AND. Dropping that would leave the rules *more* confident than the code they replace, which is the wrong direction for a redesign premised on the old verdicts being unreliable. Pinned, and each is a way this goes quietly wrong: - the fact line's field names, because they are an interface operators write patterns against and a rename would look like "no mod is clientside any more" rather than like a break - a contradiction yields INCONCLUSIVE, never CONFIRMED - silent metadata confirms nothing — only a boot may speak for a mod nothing declares - a deferred scan confirms nothing: a distribution-locked CurseForge file could be neither scanned nor booted, so there is no evidence about it at all - the two streams do not leak into each other Run before committing; red for the missing types (`MetadataFacts`, `RuleSource`, `BootRule.source`). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>Corrects stage 3 before stage 4 consumes it. As shipped, a metadata rule could reach CONFIRMED on its own — a short-circuit that would have published mods on their own say-so, without a boot, and would have let the self-report outrank the very evidence it is unreliable about. It is the widening flagged in stage 3's message; the answer is that it must not happen at all. **Precedence is now explicit: console over metadata, absolutely.** - A declaration — client, both or server — never stands in for a boot. Every mod is still booted and the console decides. - A mod declaring **server** whose console reaches a client-only class is CONFIRMED **client**. Not an edge case: it is the target. A mod honestly declared client-only is already excludable from its metadata and costs nothing to find, so the ones worth a container are those coded unclean — claiming the server while calling the client. The console rules are the instrument for catching exactly that, which is why they are the ones worth crafting delicately. `Declaration { CLIENT, SERVER, CONTRADICTORY }` is a separate vocabulary from `Verdict` on purpose. A metadata rule sets `declares` and may not set `verdict`; giving the two streams one codomain is precisely what would let a self-report be published as a finding, and `ConsoleOutranksMetadataTest.noMetadataRuleCarriesAVerdict` fails the build if one ever does — because that regression would otherwise be silent. `VerdictPolicy.decide` takes `declared` and never consults it. Accepting it makes the decision honest about what it was given rather than about what it used, and the pins sweep all four declaration values through both the confirmation and the unexplained-crash paths to prove the declaration changes neither. Two rules added that the old fold had no use for but this one does: `platform-server-required` and `manifest-server-or-both`, both declaring SERVER. They are what make the money case identifiable — a SERVER declaration contradicted by the console is the finding, and it cannot be reported as such if nothing records the claim. Existing expectations changed, deliberately and flagged: `MetadataRuleTest`'s three confirmation assertions now assert declarations. That is the stop-and-flag signal working — this is labelled `fix:` rather than `refactor:` because the behaviour is what changed. clientside 301/0 (295 before, +6), the 46 pre-existing classifier guards among them, re-run with --rerun-tasks after wiping build/test-results. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>Stage 4a. What one loader's evidence adds up to under the four-state verdict, with the console deciding and the metadata only declaring. This is where the redesign becomes visible in the report. The cases pinned are the ones the old fold got wrong or could not express: - **the target** — both sources declare the server supported, the boot dies on a client-only class: CONFIRMED, with the SERVER declaration recorded, because a contradicted claim *is* the finding and cannot be reported if nothing kept the claim - a prevented grind is ERROR, not a boot that learned nothing - a clean boot is CLEAR, not folded in with doubt - a crash decided by the bare exit-code rung is INCONCLUSIVE: it means only "exited non-zero, nothing recognised why", which is the rung that had 27 of 43 published HIGH verdicts resting on no decisive evidence - a client declaration with no boot stays INCONCLUSIVE — a self-report may not publish a mod - **not booting on purpose is not an ERROR.** The `-clientsidereport` verb asks for metadata only; nothing was prevented. ERROR has to stay reserved for a grind that could not be performed or it stops meaning anything an operator can act on, which is the whole reason it exists. - every confirmation names the rule that produced it, whether that is an operator's rule or the built-in marker reporting its own id — a verdict that cannot name its evidence cannot be audited or revoked Run before committing; red for the missing pieces only (`Unresolved reference 'verdictOf'`, and `No parameter with name 'stagingPrevented'` — `BootOutcome` cannot yet say a grind never started, which is precisely the gap ERROR exists to close). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>Stage 4a. `ClientsideVerifier.verdictOf` is the replacement for `aggregateFor`: console decides, metadata declares, and the two are returned together as a `VerdictAssessment` because a verdict is only auditable alongside the rule that produced it and the claim it contradicts. Three supporting pieces, each closing a gap the old model could not express: - **`BootOutcome.stagingPrevented`.** Without it a refusal and a boot that learned nothing were the same INCONCLUSIVE, which is how a host-wide defect came to be published as one verdict per candidate and overwrote decisive ones the TTL would have left alone. Set at the staging-refusal sites; it is what `Verdict.ERROR` is derived from. - **`BootObservation.Unclear`.** A boot that ran and ended with nothing recognised. Distinct from `TimedOut` only in how it arrived; both mean the grind happened and taught us nothing. - **`BootDecision.ruleId`**, derived from the enum name so the two cannot drift — `CLIENT_ONLY_CLASS` is `client-only-class`, exactly the id the bundled file ships. A confirmation therefore always names a rule an operator can find and edit, whether it came from their rule or a built-in rung. **Only a decisive rung may confirm**, reusing `BootDecision.decisive`: the built-in client-class marker, which no broken harness can fabricate, or an operator rule that stated CRASHED deliberately. The bare exit-code rung means "exited non-zero, nothing recognised why" and now yields INCONCLUSIVE — it is the rung that had 27 of 43 published HIGH verdicts resting on no decisive evidence. `aggregateFor` is untouched and still wired; nothing in production changes yet. 4b moves the grinder onto `verdictOf` and retires it, which is also where the store starts clean. clientside 309/0 (301 before, +8), grinder 446/0 (29 skipped) unchanged, both re-run with --rerun-tasks after wiping build/test-results. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>Stage 4b-i. `/as-properties` feeds an SPC instance's `fallback.updateurl`, so a row reaching it becomes a mod excluded from real server packs — the highest-stakes output this engine has. The gate is now exactly one condition: `Verdict.CONFIRMED`. The old gate needed two, `Confidence.HIGH` *and* a separate decisive-rung check, because HIGH was also reachable from the bare exit-code rung — "exited non-zero, nothing recognised why". Measured against the live daemon, 27 of 43 published HIGH verdicts rested on no decisive evidence. Under the redesign that second condition is structural: `verdictOf` only reaches CONFIRMED from a decisive rung, so CONFIRMED *means* decisive and the gate asks once. - **An ERROR never publishes, whatever the volume.** During the missing-runtime-image outage every candidate produced exactly that shape, and a gate leaking it would exclude mods from users' packs on the strength of a broken Docker host. Pinned across 20 rows, not one, because the failure mode is a flood rather than a single row. - A metadata declaration publishes nothing at all — a mod is excluded because a console proved it, never because the mod said so about itself. The deliberate narrowing, confirmed as intended. - Retention is asked of the verdict (`keepsLogs`) rather than re-derived, so artifacts and the outcome justifying them cannot drift apart. - **A row from the old schema loads as INCONCLUSIVE**, publishing nothing until re-ground. That is "start clean" without deleting anything: the old `Confidence` scale has no honest mapping onto the new verdicts, so no old row is treated as evidence and each is re-earned by a real boot rather than translated. The re-verify TTL does the rest. Red for the missing field only. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>Stage 4b-i. `/as-properties` now gates on `Verdict.CONFIRMED` alone, `GrindVerdict` carries the verdict and the declaration, and `verdictOf` is wired through `LoaderVerdict` into the store. **One gate condition where there were two.** `Confidence.HIGH` was also reachable from the bare exit-code rung — "exited non-zero, nothing recognised why" — so a separate decisive-rung check had to sit beside it; on the live daemon 27 of 43 published HIGHs rested on no decisive evidence. That check now lives upstream in `verdictOf`, which only reaches CONFIRMED from a decisive rung, so CONFIRMED *means* decisive and asking twice would only let the two drift. **Old rows load as INCONCLUSIVE and publish nothing.** That is "start clean" without deleting: the `Confidence` scale has no honest mapping onto the four verdicts, so no stored row is treated as evidence and each is re-earned by a real boot. The re-verify TTL does the rest, and the store keeps its history meanwhile. `verdict` and `declared` are appended at the *end* of both constructors. Inserting them mid-list broke two positional call sites, which is the cheap version of the lesson: an optional field added anywhere but the tail is a source-breaking change to every positional construction. **Five existing tests changed, each by judgment rather than by rename** — this is the stop-and-flag signal, and the label is `feat:` because publication behaviour is what changed: - `onlyAVerdictDecidedByDecisiveEvidenceIsPublished` keeps every assertion byte-identical; only its local helper changed, to model the fold that now happens upstream. Its concern — a bare non-zero exit must never publish — is unchanged and still pinned here end-to-end, as well as at its new home in `VerdictAggregationTest.aCrashNoRuleExplainedIsInconclusive`. - four fixtures that stood for "a finding" now say `verdict = Verdict.CONFIRMED` explicitly rather than relying on a confidence that no longer decides anything. The fixture default is deliberately INCONCLUSIVE, not CONFIRMED: a fixture written before the redesign should stand for an unmigrated row, never silently for a published finding. clientside 309/0, grinder 450/0 (29 skipped), both re-run with --rerun-tasks after wiping build/test-results. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>Stage 4b-ii. The publication gate moved to `Verdict` in 4b-i while the table and CSV still showed `HIGH/MEDIUM/LOW` — coherent internally, unreadable for an operator, who would see a row ranked LOW being published and a row ranked HIGH withheld. Two columns, pinned together because they are useless apart. A CONFIRMED row says a console proved the mod reaches client-only code; the `Declared` column beside it says whether the mod had *claimed the server*. That is the difference between an honestly-labelled client mod and one coded unclean, and the second is the only one worth attention — the reason the boot is paid for at all. - the default order leads with CONFIRMED (the findings), then INCONCLUSIVE (whose consoles are the raw material the next rule is written from), then ERROR (an operator's problem, not a mod's), then CLEAR (nothing to do). The fixture slugs are deliberately alphabetical in the *same* order the verdict rank produces, so the assertion would pass on a slug sort too — and the test says so, rather than quietly proving less than it looks like. - an absent declaration renders blank, never "UNKNOWN" or "null": every CurseForge project is in that state, since the platform publishes no sideness at all, and a word there would tell a reader we asked and were told. - **a drift guard between the two retention rules.** `BootArtifacts.worthKeeping` decides per *attempt*, long before a verdict exists; `Verdict.keepsLogs` states the same policy for the published row. They are independent expressions of one rule and nothing makes them agree, so this asserts they do. Run before committing; fails behaviourally on the real CSV header, which still reads `Confidence`. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>Last of the re-cut, and its own commit because it is documentation, not code. `BootLogStore`, `FallbackPropertiesRenderer` and `VerdictQuery` carried `[Confidence.HIGH]` and `[Confidence]` links that now resolve to nothing — dokka would have broken on them. Two of the three also *stated* the old gate ("only HIGH is ever published", "confidence ordering"), which would have told the next reader something false about how publication works. The mentions left are deliberate history in backticks, explaining why the scale is gone rather than pointing at it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>Root CLAUDE.md (clientside 266 → 309, grinder 446 → 455), both module files, and the refactor log. The entries are written around what a reader will get wrong rather than around what changed: - the ladder's *order* is still in code while its *content* is in the file, so "finishing the job" by turning `classify` into a bare loop would lose the killed-exit-code rung that sits between rungs - a metadata rule may declare but never decide, and the guard that enforces it exists because the regression is silent — the file would simply start publishing mods that were never booted - the metadata fact line's field names are an interface operators write patterns against; a rename presents as "nothing is clientside any more" rather than as a break - `/as-properties` will serve a visibly shorter list after deploy, because nothing is translated from the old scale Stale current-state prose was corrected and historical prose left alone: "iron-chests published HIGH" records what was measured and stays; "combine signals into a per-loader Confidence" described a type that no longer exists and did not. Also records an open item rather than hiding it: `ConsoleRule` and `BootRule` are two implementations of one idea, left uncollapsed because merging them breaks a documented operator-facing file format. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>Audit iteration 34, HIGH-1 and HIGH-2. `Verdict.ERROR` exists to separate "the grind could not be performed" from "the grind ran and taught us nothing". Staging refusals got `stagingPrevented` when the verdict was introduced; two other never-ran paths did not, so they still publish INCONCLUSIVE — the exact conflation the redesign was built to remove. - **A thrown pack post-processor.** That hook is the grinder's `overlayLoaderInstall`, a *loader-cache* operation, so it fails precisely when the host is broken. The worst possible site for this bug: it is the missing-runtime-image shape, a host defect published as a verdict about a mod. - **`RunResult.NotStarted`** — the runner reporting it never started the server at all ("No start.sh in the generated server pack."). **`aStagedGrindWithNoObservationIsAnError` looks like it already covers the second, and does not.** It asserts on `boot == null`, while `NotStarted` yields a *non-null* outcome carrying INCONCLUSIVE, so `verdictOf` never reaches that branch. A guard that appears to cover a case it cannot reach is worse than an absent one, because it stops anyone looking — so these are pinned on the outcome itself, not only through the policy. `aRealBootThatFailedIsNotMarkedPrevented` is the counterweight and **passes already**: a container that ran and crashed on a client-only class must stay evidence, or this fix would trade a false INCONCLUSIVE for a lost true positive. Run before committing. Three fail behaviourally (`expected: <true> but was: <false>`, and `expected: <ERROR> but was: <INCONCLUSIVE>`); the counterweight passes. An earlier draft failed on a non-null `logFile` parameter instead — a fixture bug, fixed before committing so the red is the missing behaviour and nothing else. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>Answers audit iteration 34, HIGH-1 and HIGH-2. Two never-ran paths still published INCONCLUSIVE because `stagingPrevented` was added to the staging refusals and nowhere else: - `runPrepared`'s thrown post-processor. In the grinder that hook *is* `overlayLoaderInstall`, so it fails when the loader cache is broken — meaning a broken host was being published as a verdict about every mod that wanted that tuple. The missing-runtime-image outage in miniature, and the single most likely site for it to fire. - `outcomeFor`'s `RunResult.NotStarted`, which is the runner saying it never started the server. Both now set `stagingPrevented`, so `verdictOf` returns `Verdict.ERROR` and neither can reach `/as-properties`, whose gate is CONFIRMED alone. The stale KDoc on `outcomeFor` said `NotStarted` is INCONCLUSIVE and has been corrected rather than left to mislead the next reader into thinking the old behaviour was intended. **Scope held deliberately.** `aRealBootThatFailedIsNotMarkedPrevented` passed before this change and still passes: a container that ran and crashed on a client-only class stays evidence. Marking that prevented would have traded a false INCONCLUSIVE for a lost true positive, which is the worse trade — this engine exists to find those crashes. Also fixes LOW-1: `DefaultBootRules` declared `private val bundled` beside `fun bundled()`. Legal Kotlin, but a property and function sharing a name read as a typo at the call site; the property is now `cached`. clientside 309 → 313; full tree 1303/0 across five modules, re-run with --rerun-tasks after wiping build/test-results. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>Replaces `BootResult` × `Confidence` with `Verdict { CONFIRMED, CLEAR, ERROR, INCONCLUSIVE }` and moves every clientside-determining rule into an editable file. The old pairing conflated *what happened* with *how sure are we*, and could not say the thing an operator most needed: **whether the grind ran at all**. `ERROR` is that missing verdict, and its absence is what let the missing-runtime-image outage publish a host-wide defect as one INCONCLUSIVE per candidate, overwriting decisive verdicts the TTL would have left alone. `CLEAR` is the other half — a clean boot that matched nothing is *proven server-safe*, which a single INCONCLUSIVE bucket destroys. **Only a rule reaches CONFIRMED**, and only from a rung `BootDecision.decisive` marks, so the bare exit-code rung — "exited non-zero, nothing recognised why", which carried 27 of 43 published HIGHs — can no longer publish anything. `/as-properties` gates on CONFIRMED alone; expect a visibly shorter list until boots accumulate, since no stored row is translated from the old scale. **The console decides and the metadata only declares.** A metadata rule sets `declares` and may not set `verdict`, with a guard failing the build if one does. The target case is a mod claiming *server* whose console reaches a client-only class: an honestly-declared client mod is already excludable from its metadata and costs nothing to find, so the container is paid for the dishonest one. Three conflict resolutions worth recording: - `CLAUDE.md`'s clientside cell was edited by both branches from the same base. Resolved by keeping **both** notes rather than taking a side, and the count re-derived from `build/test-results` rather than trusted: 266 + 1 + 47 = **314**, where both branches' own figures (267 and 313) were each correct alone and wrong merged. That is exactly what the "re-derive the count" instruction in that column exists to catch. - `REFACTOR-LOG.md` had two appended sections; neither supersedes the other, so both are kept. - `BootVerifier.kt` auto-merged. Verified rather than assumed: `ManifestDependencyTest` (17), `OptionalDependencyTest` (4), `PreventedGrindTest` (4), `ConsoleOutranksMetadataTest` (6) and `VerdictPublicationTest` (4) all pass, so the optional-dependency filter and the verdict work still hold in the same file. Suites: api 387 (1 skip), clientside 314, grinder 455 (29 skip), app 149, plugin-example 3 — **1308 total, zero failures**, re-run with --rerun-tasks after wiping build/test-results. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>Answers the `iris` report: `NoClassDefFoundError: org/lwjgl/Version` scored INCONCLUSIVE. Both signatures move from `boot-rules.example.json` — a template the daemon never loads — into the bundled defaults, and get rungs in the decisive band beside `client-only-class`. **Not an ordering fix.** No rule matched that line at all, so there was nothing to order: it fell through to the bare exit code, which means "exited non-zero, nothing recognised why" and cannot confirm. The gap predates the four-verdict redesign. Placed in the decisive band, which is where they belong on the same reasoning `client-only-class` sits there: a dedicated server ships no LWJGL, and FML printing "for invalid dist DEDICATED_SERVER" is the loader itself refusing a client-only class. Neither can be fabricated by a broken harness — that is the bar for this set, and it is why they outrank every excuse while still yielding to every fair-run guard. Both orderings are pinned. `fml-invalid-dist` also stops a zero exit hiding a crash: NeoForge's ServerStarterJar prints the refusal in full and exits 0. **Three existing guards changed, each by concern, and one of them is a consequence worth naming:** - `onlyTwoDecisionsAreDecisiveEvidence` → `theDecisiveSetIsSmallAndExplicit`. The set legitimately grew from two to four; the assertion now says what qualifies rather than how many there are. - `ConsoleRuleLadderTest.aRuleCrashesAConsoleThatAZeroExitWouldHaveExcused` used FML's invalid-dist as its example of a gap operator rules exist to close — **and this commit closes that gap**, so the test was demonstrating something no longer true. It now uses a deliberately *synthetic* signature, because the mechanism is what it pins and a real one can be promoted out from under it again. That is the second time a real example in that test has been consumed by a default. - `onlyTheClientOnlyRuleConfirmsFromAConsole` → `onlyDecisiveClientEvidenceConfirmsFromAConsole`, listing all three. `third-party-screen-class` stays an example deliberately: its own note calls it "often a dependency problem, not sideness", it states no verdict, and a default firing on a dependency's GUI class would publish mods on someone else's crash. clientside 314 → 321, grinder 455 (29 skipped), both re-run with --rerun-tasks. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>`xaeros-world-map` was refused with "Required dependency unavailable for Quilt / Minecraft 26.2: xaerolib". Its Quilt/26.2 jar declares `depends: { "xaerolib": ">=1.0" }` **and ships it** — `"jars": [{"file": "META-INF/jars/xaerolib-fabric-26.2-1.7.1.jar"}]`, whose own descriptor reads `id: xaerolib, version: 1.7.1`. Fabric and Quilt Loader load nested jars, so the requirement was already satisfied when we went looking for it. **The near-miss is what made it fatal.** A Modrinth project `xaerolib` exists, so the manifest id *mapped* — but it publishes 13 versions, none tagged Quilt and none tagged 26.2, so nothing could be staged. A mapped-then-unstageable id lands in `unsatisfied`, which refuses; had the project not existed at all it would have landed in `unmapped`, which does not. The mod was refused for a library it was carrying. **Not one mod's quirk.** Sampled the same day: `sodium` bundles 9 nested jars, `modmenu` 1. Any bundled library that also exists as a thinly-tagged standalone project reproduces this, and each occurrence costs an INCONCLUSIVE that overwrites whatever the store held. The pins are shaped around the ways this goes wrong rather than the happy path: - ids come from the **nested descriptors**, not from guessing at file names - a nested jar's `provides` aliases count, since a dependant may name any of them - a dependency that is *not* bundled is still required — or this hides real failures - the Quilt `quilt_loader.jars` shape is read as well as Fabric's, since a Quilt candidate is exactly what reported it - **an undeclared jar in `META-INF/jars/` is NOT bundled.** Fabric loads the declared list; treating a stray file as satisfied would skip staging something genuinely needed and produce a failure to blame on the mod. This is the one direction where being generous is dangerous. - an unreadable jar yields nothing rather than throwing Bundled wins unconditionally: the author shipped that exact build, and fetching a different version of the same id is how a conflict is manufactured and then blamed on the mod. Red for the missing `BundledJars` type and the missing `bundledIds` parameter. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>`xaeros-world-map` was refused with "Required dependency unavailable for Quilt / Minecraft 26.2: xaerolib" while shipping `xaerolib` inside its own jar. `BundledJars.idsIn` reads a candidate's nested jars and `stageableRequirements` drops anything they provide. **Why a refusal rather than a harmless miss.** A Modrinth project `xaerolib` exists, so the manifest id *mapped*, but it publishes 13 versions with none tagged Quilt and none tagged 26.2, so nothing could be staged. A mapped-then-unstageable id goes to `unsatisfied`, which refuses; had the project not existed at all it would have gone to `unmapped`, which does not. The near-miss is the whole mechanism — being *almost* resolvable is worse here than being unknown. **A class, not a quirk.** Jar-in-jar is ordinary: `sodium` bundles nine nested jars, `modmenu` one. Any bundled library that also exists as a thinly-tagged standalone project reproduces this, and each occurrence spends an INCONCLUSIVE that overwrites whatever the store held. It also relieves `MAX_INJECTED_DEPENDENCIES`, which bundled libraries were counting against. Bundled wins **unconditionally** (Griefed's call): the author shipped that exact build, so fetching another version of the same id is how a conflict is manufactured and then blamed on the mod. **Only declared nested jars count, and that restraint is load-bearing.** Fabric loads the jars its descriptor lists; a stray file under `META-INF/jars/` is not on the classpath, and treating one as satisfied would skip staging something genuinely needed — the one direction in which being generous here produces a failure to blame on the mod. Unreadable input yields no ids for the same reason: "we could not look" has to mean "assume nothing is bundled". Ids come from each nested descriptor's own `id` and `provides`, never from its file name — a name like `xaerolib-fabric-26.2-1.7.1.jar` carries a version and a loader the id does not. Both loader spellings are read, Fabric's `jars: [{file}]` and Quilt's `quilt_loader.jars: [string]`, since a Quilt candidate is what reported this. **Verified against the real artifact, not only the fixtures:** run over the actual `xaeroworldmap-fabric-26.2-1.45.0.jar`, `idsIn` returns `[xaerolib]` and the stageable set narrows from `[xaerolib, fabric-api]` to `[fabric-api]`. The probe was temporary and is not committed — the suite stays offline. clientside 321 → 329; full tree 1323/0 across five modules. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>`sodium` — Modrinth `client_side: required, server_side: unsupported`, a client renderer nobody disputes — was published INCONCLUSIVE. Its NeoForge 26.2.0.76 boot crashed reaching LWJGL, which is decisive evidence no harness can fabricate, and the other-version re-check then sampled `sodium-fabric-0.9.2-beta.1+mc26.1.2.jar`, which booted cleanly. `reconcileOtherVersionRecheck` replaces a crash outright with any survivor, so the proof was discarded. **A clean boot elsewhere is not a counter-argument to this particular evidence.** The re-checks exist to tell "this build crashed" from "this mod cannot run on a server" — a real distinction that stopped `iron-chests` publishing off one bad build. But client-only evidence has already answered it: the server loaded the mod and the mod reached for the client. Another build merely *starting* proves nothing, because a client mod can start a server without being any use on one — the asymmetry this module has documented since the boot-test existed. **And it crosses loaders** (Griefed's call): a mod's features are the same on Fabric and NeoForge, only the implementation differs, so one loader's proof makes every loader's entry exclusion-worthy. Pinned in both directions, because the guard being weakened here is load-bearing: - a client-only-proven crash is neither re-checked, nor cleared by a survivor, nor superseded by another loader's clean boot - an **unexplained** crash still is — `anUnexplainedCrashIsStillDisprovedByAnotherLoader` keeps the `iron-chests` protection intact, which is the whole reason cross-loader reconciliation exists - propagation does not rewrite what each loader actually did: Fabric's row still reads SURVIVED, and the inheriting row must name the loader that proved it or the verdict cannot be audited - with no proof anywhere, nothing propagates Deliberately **not** propagated from `OPERATOR_RULE`, though it is `decisive`: an operator's rule reaching CRASHED says *this console* is a crash, which is not necessarily a statement about sideness. Only the three rungs that are client-only evidence by construction propagate. Red for the missing `provesClientOnly` and `propagateClientOnlyProof` only; the other unresolved references cascade from them. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>Answers the `sodium` report. Its NeoForge 26.2.0.76 boot crashed reaching LWJGL — decisive evidence — and the other-version re-check then sampled a Fabric build that booted cleanly, which `reconcileOtherVersionRecheck` treats as replacing the crash outright. A client renderer Modrinth itself marks `server_side: unsupported` came out INCONCLUSIVE. `BootDecision.provesClientOnly` marks the three rungs that are client-only evidence **by construction** — the client-class marker, LWJGL, and FML's invalid-dist. For those: - the other-version re-check is not run at all: the question it answers is already answered, so the boots would buy nothing and a survivor among them would actively discard the proof - a survivor cannot clear it if a re-check is somehow reconciled anyway (defence in depth, since that is exactly where sodium's proof was lost) - another loader's clean boot cannot supersede it - **every other loader of the project inherits CONFIRMED** The last one is the substantive change and it is Griefed's call: a mod's *features* are the same on Fabric and NeoForge, only the implementation differs, so a build reaching client-only code proves the **mod** is client-only. It matters concretely because the loaders carry different stems — `sodium-neoforge-` and `sodium-fabric-` — so excluding only the proving loader would leave the other half of the project shipping into every server pack. **What is deliberately not weakened.** An *unexplained* crash is still disprovable by another loader, which is the `iron-chests` guard and the reason cross-loader reconciliation exists; `anUnexplainedCrashIsStillDisprovedByAnotherLoader` pins it. `OPERATOR_RULE` does not propagate despite being `decisive`: a rule reaching CRASHED says *this console* is a crash, not that the mod is client-only. And an inheriting verdict keeps its own `bootResult` — Fabric's row still reads SURVIVED — with a note naming the loader and rung that proved it, because a verdict that cannot say where its evidence came from cannot be audited. Suites: clientside 329 → **337**; full tree **1331/0**, confirmed on two consecutive `--rerun-tasks` runs after wiping `build/test-results`. **A flake was observed and is recorded rather than dismissed.** One earlier full-tree run failed two `BootVerifierSelectionTest` cases — `rejectsAProjectThatTargetsOnlyNonReleaseVersions` with a `java.util.ConcurrentModificationException`, and `acceptsARealReleaseAndAdvancesToDownload` with "No bootable file/Minecraft/loader combination for Forge", i.e. a momentarily empty release set. Both drive a real `ApiWrapper` whose `ManifestUpdater` refreshes concurrently; neither touches the reconciliation this commit changes. It did not reproduce in three runs on `develop`, three on this branch, or the two full-tree runs above. A `ConcurrentModificationException` is never acceptable, so this is a latent defect in the manifest-refresh path worth its own investigation — noted here because the evidence is otherwise lost, not because this commit causes it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>`VersionMeta` refreshes manifests on a background coroutine (`refreshScope.launch { refreshManifests() }`, `Dispatchers.IO`) — B31's ~392 ms startup win — while every `update()` in `versionmeta` does `clear()` then re-`add()`s on a plain collection, and `MinecraftMeta.serverReleases()` returns **the live list**. A reader gets one of two failures: - `ConcurrentModificationException` while iterating - an **empty or partially-filled list**, read between the `clear()` and the `add()`s The second is the dangerous one because it does not throw. `BootVerifier.bootableCombination()` rebuilds its release set from `serverReleases()` on every staging call, so an empty read fails every candidate against the gate and the boot is refused with "No bootable file/Minecraft/loader combination for <loader>" — a verdict about the engine's own timing wearing the shape of a statement about the mod. Both were observed on 2026-09-04 in `BootVerifierSelectionTest`, one as the CME and one as exactly that message. **Reproduced, with the production path in its own stack trace:** `VersionMeta$1.invokeSuspend → refreshManifests → MinecraftMeta.update` throwing `ConcurrentModificationException` on the refresh coroutine. **The pin is deterministic, and getting there took two false starts worth recording.** A 200-round timing test reproduced the CME; trimmed to 60 rounds it passed, which makes it a coin toss rather than a guard. Worse, when it did "pass" at 4000 rounds the exception was thrown on the *refresher's* thread while the assertions lived on the reader's — a test that goes green while the very defect it targets is printing a stack trace beside it. So the pin asserts the invariant that *makes* the concurrent case safe: the accessor must hand out a snapshot, not the collection the refresh mutates. Deterministic, and 2.8s instead of 110s. The stress loop is kept as a bounded net and is documented as unable to prove safety — a torn read is something the reader genuinely can see, and it is the symptom that costs a candidate. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>Completes the fix across the eight remaining `versionmeta` classes — `ForgeLoader`, `NeoForgeLoader`, `FabricLoader`, `FabricInstaller`, `QuiltLoader`, `QuiltInstaller`, `LegacyFabricInstaller`, `LegacyFabricVersioning`. Each builds fresh collections and publishes them in one assignment to a `@Volatile` field holding an unmodifiable view. All eight shared the shape the Minecraft metas had: `clear()` then re-`add()` on a collection handed straight to callers, mutated by `VersionMeta`'s background refresh coroutine while `LoaderVersionResolver` reads it. Eleven accessors were red against the pin. **Three published signatures narrowed, and `!` is for these** — `LegacyFabricMeta.supportedMinecraftVersions()` from `MutableList<String>` to `List<String>`, and `ForgeMeta.getForgeMeta()` / `NeoForgeMeta.getNeoForgeMeta()` from `HashMap` to `Map`. The old types did not merely leak internal state, they advertised it as mutable. A caller that only reads is unaffected; one that mutated was corrupting metadata another thread was reading. Recorded in `claude-docs/API-BEHAVIOUR-CHANGES.md`. `-app`'s `VersionsController` and `VersionMetaResponse` follow the narrowed types. **A third latent bug found while rewriting `NeoForgeLoader`.** Its `update()` ended with for ((key, value) in versionMeta.entries) { versionMeta[key] = value.reversed() } — walking the *published* map's entries while writing back into it, so a concurrent reader could observe the reversal half-applied and get some Minecraft versions' NeoForge builds newest-first and others oldest-first. It now runs on the builder, before publication. Suites: api 389 → **403** (the pin's dynamic cases), clientside 337, grinder 455 (29 skipped), app 149, plugin-example 3 — **1347 total, zero failures**, `--rerun-tasks` after wiping `build/test-results`. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>Answers audit iteration 37. **HIGH-1 — `BundledJars` no longer spools nested jars to disk.** It wrote each declared nested jar to `File.createTempFile(...).apply { deleteOnExit() }` and deleted it in a `finally`. The `finally` freed the disk; nothing freed the *registration* — `deleteOnExit` adds the path to `java.io.DeleteOnExitHook`'s static set, which never shrinks. This runs per staged jar, per boot attempt, for every candidate of a catalog sweep, and `sodium` declares nine nested jars, so a daemon running for weeks accumulated a dead entry per nested jar and a shutdown hook that would eventually walk tens of thousands of already-deleted paths. Fixed by removing the spool rather than the `deleteOnExit`: only the nested descriptor is ever read, and a `ZipInputStream` over the entry's stream gets it with no file at all. The leak and the I/O go together. **MED-1 — a superseded `ERROR` keeps its reason.** `propagateClientOnlyProof` overwrote every non-proof verdict with CONFIRMED, including a loader whose grind was *prevented*. Publishing that entry is right — the mod is client-only and the entry comes from platform metadata, not from a boot — but the ERROR vanished from the report, so a host defect stopped being visible on exactly the projects where a proof happened to exist. The note now says the grind did not run. **MED-2 — `VersionMeta.update()` is `@Synchronized`.** Each meta publishes a consistent snapshot now, but nothing serialised `update()` itself, and it is called both from the refresh coroutine and by callers. Two overlapping runs could leave one meta on generation A beside another on generation B, so a lookup could miss a version its own release list contained. Uncontended in the normal case, and a manifest refresh is far too coarse to sit on any hot path. **LOW-1 — the immutability assertions can no longer pass vacuously.** Both were written as `if (asMutable != null) { assertThrows(...) }`, so an accessor that stopped presenting as `MutableList` would have made the test report success while asserting nothing — the defect class iteration 34 found. The cast is now asserted before it is used. Full tree 1347/0 across five modules, `--rerun-tasks` after wiping `build/test-results`. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>Chasing the `306612` report to its cause. `BootCandidateSelector.pickForLoader` is `files.firstOrNull { loader in it.loaders && mc in it.minecraftVersions }` — it never asks whether the file can actually be downloaded. A distribution-locked file (`downloadUrl == null`, the author's opt-out) is picked like any other, `JarDownloader` returns `null`, and the dependency is reported unmet while an obtainable file sits directly behind it. Two failures, and the second is the one that explains a *Quilt* report specifically: - a locked **newer** build beats an obtainable older one - a locked **exact-loader** build beats an obtainable Fabric one, so the Quilt-to-Fabric fallback — which exists precisely because libraries publish Fabric-only files — never gets reached Both reproduce; the three guards that protect existing behaviour pass unchanged (exact loader still beats an obtainable fallback, the version constraint still narrows, and an all-locked project still yields a file). That last one matters: when everything is locked the pick must still return something, so the refusal reads "distribution-locked" — true and actionable — rather than "publishes no Quilt file for Minecraft 1.20.4", which is false. Preference, never filter: the rule this function already follows for version constraints, and for the same reason — returning `null` where a file exists turns a diagnosable refusal into a misleading one. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>Answers audit iteration 40, HIGH-1. `FabricQuiltStepDownTest` injected `availableVersions` straight into `CachedLoaderVersions`, so it never called `knownLoaderVersionsNewestFirst` — the only production function the fix changed. A grep found **zero** tests reaching it, and every assertion in that file would have passed before the fix, because it proves `CachedLoaderVersions` steps down when handed a list and then hands it one itself. The pin's red was `Unresolved reference 'LoaderStepDown'` — a *compile* error — so the behavioural assertions were never observed failing, which is what hid it. Third time in this audit series that a compile-red pin has masked a guard that could not reach its subject. `knownLoaderVersionsNewestFirst` needs an `ApiWrapper` and cannot be executed in a unit test, which is the same situation as the joins inside `main` that `GrinderSpcEnvironmentTest` and `ReportBindWiringTest` assert against the source text. This uses that established pattern rather than inventing a seam. **Verified by mutation, not by assertion.** Deleting the Quilt branch from the production `when` turns this red, naming the exact line that went missing; restoring it turns it green. Forge and NeoForge are held too, so the refactor that routed them through `LoaderStepDown` cannot be silently unpicked either. Two escaping slips were fixed before this landed: `${'$'}` survived into the Kotlin source in both the search string and the failure message, so the guard first searched for a literal `$perMinecraft` and then reported a literal `$wiring`. Both were caught by reading the failure rather than the intent. grinder 460 → 461. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>`iris` published `iris-` (Fabric), `iris-neoforge-` (NeoForge) and `iris-` (Quilt) — three rows where two say nothing about which artifact was looked at. The two columns answer different questions and both are needed. `NamePattern` is the common prefix over a project's whole history and must stay broad, because `/as-properties` matches it with `startsWith` and it has to cover every build ever published. `Filename` is derived from the sampled file alone, so it keeps the loader token history erases — and on a Quilt row it reads `iris-fabric-`, because Quilt boots Fabric builds and the pattern describes the file rather than the row's label. Pinned including the two ways this could go wrong: - a row with no sampled file renders **blank**, not the historical stem repeated, so the column cannot imply an artifact was examined when none was - **the published entry is unchanged.** `/as-properties` must keep serving the broad `suggestedEntry`; if the narrow pattern leaked into it, a mod would stop being excluded for every build the narrow form misses — which for iris is its entire pre-2022 history Red for the missing `filenamePattern` only. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>Turns DependencySlugTest green. Refusals said `306612` and `531761`; they now say `fabric-api` and `balm`. `unsatisfiedLabel` names a resolved project by `ProjectFiles.slug` and always did — but **both** platforms' `resolveDependency` passed `nativeRef` into that parameter positionally, so the label resolved the project and read back the ref it started from. The earlier labelling fix only ever helped the two branches that append something (`(unresolved X project)`, `(distribution-locked on X)`); the plain resolved case, which is the common one, printed the id. CurseForge is free: `modNode` is the `/mods/{id}` response already fetched for `websiteUrl`, and the slug sits in it unread. Modrinth costs **one extra GET per resolved dependency** — its dependency path fetched only the version list, and a version object carries no slug. Paid on the dependency path only, deduped within a candidate by `visited`. It falls back to the ref when the lookup fails rather than losing the project: the slug is presentation, the files are the functional half, and the ref is a working Modrinth URL, so the fallback degrades to exactly the previous behaviour. The project URL now uses the slug too, which is the same defect one field over — a dependency link a human can read. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>Turns DependencyFileWindowTest green, and is the half of the `architectury-api` report that actually cost a verdict. `resolveDependency` reads one page of 50 files — deliberate, and still right: a dependency needs *a* usable file, not a history. What was wrong is that it asked **unfiltered**, and CurseForge answers newest-first across every loader and Minecraft version. A library that publishes as often as Fabric API (1000+ files there) therefore has nothing but current Minecraft in its newest 50, so a boot on 1.20.4 found no candidate and staging refused — publishing ERROR over whatever the store held, for a file that has existed since December 2023. `/v1/mods/{modId}/files` takes `gameVersion`, which is exactly the missing narrowing; parameters verified against https://docs.curseforge.com/rest-api/. `resolveDependency` gains a `minecraftVersion`, defaulted null so nothing else has to care, and both call sites already had the value in scope. **`modLoaderType` is supported and deliberately not sent.** Asking for Quilt returns nothing for Fabric API and would re-create the same refusal one layer down — `BootCandidateSelector.fallbackLoaders` has to *see* the Fabric builds to fall back to them, and Fabric API is its canonical case. Version narrows the set; loader choice stays in the selector, together with the obtainability preference. Modrinth accepts the parameter and ignores it, with the reason in the doc: its version endpoint returns a project's whole version list in one response, so there is no newest-N window to fall outside of. The defect is CurseForge's paging, not the interface's. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>Red on exactly one case — `anInstallFromDifferentTemplatesIsRebuilt`, expecting 1 install and getting 0. The other five pass, which is what proves the fixture rather than the guard. `LoaderCache.isInstalled` compares a cached layer's recorded template digest against the current one, and `TemplateProvenanceTest` proves it does. **Nothing in `src/main` ever called it:** $ grep -rn "isInstalled" src/main/ | grep -v "fun isInstalled" >>> no match <<< `ensureInstalled` decides a cache hit through `markUsed`, which only asks whether the completion marker exists. So the digest was written on install and never read back, and a start-script template change kept being served from a layer the old templates produced — the exact failure the mechanism was built to prevent, and one this module's documentation (and `TemplateProvenanceTest`'s own class comment) described as already fixed. **The evidence is the installer call count**, deliberately: it is the only observable that separates "served from cache" from "installed again", and the one a marker check cannot fake. Asserting on the marker would have passed against the broken code. This sits beside `TemplateProvenanceTest` rather than replacing it — that one asserts the decision, this one asserts the decision is reachable. A unit test of a predicate cannot see a caller that never consults it, which is the third instance of that boundary in two days. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>`ensureInstalled` now asks `isInstalled` — which compares the recorded template digest against the current one — instead of `markUsed` alone, which only asks whether the completion marker exists. The provenance machinery was complete and unreachable: `TemplateProvenance.digestOf` computed, the supplier wired from `GrinderApplication`, the digest written into every marker — and never read back, because the only reader had no production caller. A start-script template change was served from the layer the old templates produced, indefinitely. `markUsed` still runs on a hit: stamping the tuple as used is what keeps it alive against `evictUnusedSince`, and that is a separate job from deciding whether it may be served. Two accepted consequences, both deliberate: - **`templateProvenance()` is now evaluated on every cache lookup rather than only on install.** In production it digests the handful of start-script templates; against a boot measured in minutes it does not register. - **A rebuilt tuple logs its mismatch twice**, once at the racy fast path and once under the lock. The alternative is a second silent predicate beside the logging one, and two ways to answer the same question is how the metadata scanners drifted. Once per rebuilt tuple, once per template change. A provenance miss falls through to the ordinary install path, so it is also subject to the failure cooldown — correct, since a stale layer is a miss, not a usable install. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>Red on seven, green on the two that assert unchanged behaviour. `from` documents that nothing here throws — "a typo in a unit file should not stop a service that has verdicts to serve" — and `"abc"` honoured that. `"0"` did not: it parses perfectly, is simply unusable, and travelled onward to whatever consumed it. The consequences were not uniform, which is why this is closed in one place: - `SPC_GRINDER_WORKERS=0` reached `GrindPool`'s `require`, which `GrindLoop` builds **inside the pass loop** — so the daemon started, bound the report port, logged a healthy startup line, then died on a message naming `workerCount` rather than the variable the operator set. Under `Restart=on-failure` that is a restart loop shaped like a crash. - `SPC_GRINDER_INTERVAL=-1` throws nothing at all: the pause is negative, the wake-up instant is already past, and the loop paces itself by not pausing — a silent hot loop over the catalogue, and the worse of the two precisely because nothing reports it. Coercion rather than rejection is the deliberate reading of that contract: the value actually used is on the startup line either way, so an operator who set nonsense sees a default in the log rather than a dead unit. Values that legitimately mean something at their boundary are pinned as **kept**: port `0` (any free port), CPU/memory `0` (uncapped), log budget `0` (keep nothing), and the flush interval's zero. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>Three range-checking readers — `intIn`, `longAtLeast`, `capAtLeastZero` — replace the bare `toIntOrNull() ?: default` on every numeric knob, so a value the daemon cannot use falls back exactly as `"abc"` already did. `from` still never throws, which is its documented contract. `capAtLeastZero` also rejects non-finite values: `"NaN"` and `"Infinity"` both parse to a Double and both reach `ContainerResources.forLimits`, whose `require(cpus.isFinite())` would then stop the daemon at startup over a typo. Boundaries that mean something are inside the allowed range and are pinned as kept: port `0` (any free port), `0` cores or GiB (uncapped), a `0` log budget (keep nothing). The flush interval is untouched, because negative there already means write-through and is a real choice. `everyVariableReadIsDeclaredAsAKnob` needed the three new reader names. Its regex alphabet is explicit on purpose and must stay so — `Knob("SPC_GRINDER_HOME", …)` declares knobs in the same file, so a regex matching any call with a quoted name would match the declarations and the guard would assert nothing. That is now stated at the line, since this change is precisely the case that would tempt someone to generalise it. Its assertions are unchanged; only the set of function names it scans grew. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>Where two KDoc blocks sit adjacent with nothing between them, Kotlin binds only the second and discards the first — so six declarations carried documentation the compiler and dokka both threw away, while the declaration each block described was left undocumented. Against this module's comment-everything rule, and invisible in review because the prose is right there in the file. Grinder.kt `grind`'s explanation of `force` -> `grind` (it sat above `queueBlamedDependencies`, which has its own doc; the module's central function had none, and the lost paragraph is the one explaining why a queued grind must bypass the freshness check) GrinderApplication.kt `env` -> `env` DockerLoaderInstaller.kt `readyLine` -> `readyLine` (its doc sat above `installLogName`) FallbackPropertiesRenderer.kt `normalise` -> `normalise` ReportServer.kt `queryParameter` -> `queryParameter` VerdictReportRenderer.kt two blocks that both described `headerCell`, merged into one Text is moved verbatim except the merge, which is the one case where neither block was misplaced — the sort-link behaviour and the `<th>`/`SortKey` rationale are both about that function, so they are now one doc with the page-reset note kept as its own paragraph. Verified by re-running the detector that found them: zero adjacent-KDoc pairs remain in `src/main`. Documentation only — no declaration, signature or statement is touched. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>Red for the missing `missingRuleIds` and the private `bundledPattern`. `BootLogClassifier` keeps the ladder's *order* in code and looks each rung's *pattern* up in `boot-rules.default.json` by id. An id that does not resolve produced `Regex("(?!)")` — matches nothing — with no log, no error, nowhere. That is a silently disabled rung, and nothing guarded it. Which rung goes decides how it hurts, and both directions are bad: - lose `client-only-class`, `lwjgl-on-a-dedicated-server` or `fml-invalid-dist` and every true positive falls through to the bare exit-code rung, which is not decisive — so **nothing is ever published again** and the engine merely looks like it found nothing. - lose a fair-run guard such as `out-of-memory` or `launch-failure` and host trouble stops being excused, so a starved box publishes its biggest mods as clientside. That one is already on this engine's record. The file ships in our own jar, so a rename there is a packaging bug and belongs to the build — not to a verdict store read weeks later. The second case gives the guard teeth: without it, `everyRungFindsItsBundledPattern` would pass by construction if the recording mechanism itself were broken. A bundled file that cannot be read *at all* stays a separate, deliberate degradation and is not what this pins. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>`bundledPattern` records and logs an unresolved rule id instead of quietly returning a regex that matches nothing. The never-matching fallback stays — the ladder must keep working — but it is no longer invisible. Two silent paths, not one, and the compiler found the second: `BootRule.regex` is `runCatching { Regex(pattern) }.getOrNull()`, so a rule that *is* present but carries an uncompilable pattern also yields `null` and disables its rung exactly like a missing id does. Both are now recorded. Why it matters more than a missing log line: a disabled decisive rung means every true positive falls through to the exit-code rung, which is not decisive, so nothing is published and the engine merely looks like it found nothing. A disabled fair-run guard is the mirror image — host trouble stops being excused and a starved box publishes its biggest mods as clientside. `BootLogClassifier` had no logger at all; it has one now, used only here. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>Two adjacent KDoc blocks mean Kotlin binds only the second and discards the first, so seven declarations carried documentation nothing ever saw while the declaration each described went undocumented. `BootLogClassifier` had **three** stacked at one point: `BootResult`'s doc and `Classification`'s doc both piled above `enum class BootDecision`, which has its own — so two public types in the module's most safety-critical file were undocumented while their prose sat sixty lines away on a third. BootLogClassifier.kt `BootResult`, `Classification`, `clientOnlyClassMarker` -> their own declarations BootVerifier.kt `boot` and `refuseForMissingDependencies` -> theirs ClientsideVerifier.kt `loaderDisprovingTheCrash` -> its own — and this one carried the landmine about checking *whose* boot a SURVIVED belongs to, which dokka was dropping entirely BundledJars.kt a near-duplicate of `idsOfNested`'s doc, superseded by the block below it that also carries the do-not-spool-to-a-temp-file landmine; deleted rather than moved **`BootDecision.decisive`'s own doc said "exactly two qualify" and there are four.** It listed `CLIENT_ONLY_CLASS` and `OPERATOR_RULE`, and never followed when `lwjgl-on-a-dedicated-server` and `fml-invalid-dist` were promoted from examples to shipped defaults — so the doc understated what may publish a clientside entry by half. Corrected, with the instruction to re-derive it from the constants. Documentation only; no declaration, signature or statement changed. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>The ladder's content lives in a shipped JSON and its order in code, so an id that stops resolving switches a rung off silently — landmined with both directions of harm, because which rung goes decides whether the engine stops publishing or starts publishing host trouble. Two corrections the audit forced, both in claims this file stated confidently: - `BootDecision.decisive` marks **four** rungs, not two. It never followed when `lwjgl-on-a-dedicated-server` and `fml-invalid-dist` became shipped defaults, so both this file and the KDoc understated what may publish an entry by half. - the ladder is **sixteen** rungs, not fourteen. That number has now been wrong three times, which is why the instruction to re-derive it from `classify` is repeated at both sites rather than the number trusted. Also records that the order guard now covers all sixteen and is mutation-verified, and that a confirmation credits only the rule that decided. clientside 362 → 368, re-derived from build/test-results. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>The report server has no machine-readable feed that keeps a verdict's shape: /export.csv flattens every field to a string, so stagedDependencies arrives comma-joined and has to be re-split by the consumer. VerdictField, VerdictQuery and VerdictSelection are internal to this module, so nothing outside it can reuse the selection — it has to travel over the wire. Six guards, red before the endpoint exists. Five fail because /verdicts.json falls through to "/" and is served the HTML table; the sixth (leavesTheStatusDocumentUntouched) is green by design — it pins that the mapper change /verdicts.json needs stays inert for the endpoint operators script. Two guards are worth naming. The timestamp one pins verifiedAt as an ISO string: ReportServer's mapper is a bare jacksonObjectMapper() with no JavaTimeModule, which writes an Instant as {"epochSecond":…,"nano":…} — parseable, but not a timestamp any client recognises, and not what JsonVerdictStore writes to disk. The agreement one asserts the JSON and the CSV return identical rows across four queries, so the two renderings agree because they share VerdictSelection.select, not because two row-pickers were kept in step by hand. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>Turns the table-model guards green. Module suite 44 tests, no failures, no new compiler warnings; the built jar's META-INF/extensions.idx carries both GrinderTabExtension and GrinderPreGenExtension. A TabExtension contributes exactly one tab, so Confirmed / Other Verdicts / Dashboard / Settings are a nested JTabbedPane inside GrinderTab. The two list panes are one class with different rows and a different banner, over one shared selection — the split into "proven" and "everything else" is a statement this interface makes to the user about risk, not a distinction a server pack generation observes. A JTable rather than a column of checkboxes, and a TableRowSorter rather than rebuilding the model, because a mature grinder holds thousands of verdicts. The filter quotes its input, so an operator typing "c++" gets a search rather than a PatternSyntaxException. The Dashboard reads /status, not /dashboard: that page is an HTML shell whose numbers arrive from JavaScript, and Swing's HTML renderer executes none. The fields are the ones StatusDashboardRenderer.READ_FIELDS names, read defensively — this points at a daemon the user upgrades independently, so a reshaped field renders as an em dash rather than emptying the tab. The worker rows follow the daemon's actual WorkerSnapshot (worker/platform/slug/busySeconds), which was worth checking rather than guessing; the first draft invented name/subject. Threading is a plain SwingWorker with results applied on the EDT. No coroutines: a plugin cannot reach ServerPackCreator's lifecycle-cancelled scopes, and GlobalScope is the anti-pattern this project spent a sprint removing from its own GUI. The Swing Timer that drives the Dashboard fires on the EDT and only starts the worker, so no request ever runs there. One landmine found by the compiler and worth keeping named: inside a JButton.apply { } the identifier `model` resolves to the button's own ButtonModel and silently shadows the pane's table model. The bulk-select listeners now call a named method instead. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>Red, and the reds are of two different kinds — worth separating, because only the second kind proves a defect exists. Compile-red, for logic that has to be extracted before it can be tested at all: SelectionAttribution (7 guards), PlainTextRendering (4) and StatusFormatting (7). Every failure is an unresolved reference to one of those three types, plus the inference errors that follow an error type sitting opposite `emptySet()`. Assertion-red, the one that demonstrates a live bug: `reportsAWrongShapedDocumentRatherThanNoVerdictsFound` fails with `expected Failed, got Ok(value=[])`. A 200 carrying valid JSON that is not a verdict document currently reads as "no verdicts found", which an operator cannot tell from a grinder that has genuinely ground nothing — and the guard that names this hazard only ever covered non-JSON. SelectionAttributionTest is the important one. It pins which config key each ticked entry is written under, logic that was buried in a Swing class and therefore untested, and it is wrong: `partition { it in shownInOther }` files an entry shown in *neither* pane as CONFIRMED, and the module's deliberate never-prune rule guarantees such entries accumulate. Every tick silently reclassifies what the user accepted at their own risk as a proven finding. PlainTextRenderingTest pins that grinder text is never parsed as HTML, with a control guard asserting Swing *would* otherwise have parsed it — without that, the other three assert a null property for reasons unrelated to the fix. Green on first run, and kept as coverage rather than as regression pins: the two `/verdicts.json` paging guards, `requestsTheDocumentedEndpoints` (the fixture serves "/" and so matched every path — nothing proved the client asked for the right one), the bare-array and empty-list client guards, the non-tick column class, the negative poll interval, and GrinderTabExtension's identity. The locale guard was also green, and that is a withdrawn finding rather than coverage — see the correction in claude-docs/REFACTOR-AUDIT.md. Its doc comment now states what it actually proves. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>Turns the previous commit's compile-red and assertion-red guards green. plugin-grinder 44 → 69 tests, api 407 → 409, grinder 501 → 503, app 149. Zero failures, and no compiler warning from this module. MED-1, the defect. SelectionAttribution now owns which config key each ticked entry is written under, and it keeps a stale entry — one neither pane is showing — under the pane it was saved in. The old `partition { it in shownInOther }` filed every such entry as CONFIRMED, and the module's deliberate never-prune rule guarantees they accumulate, so every tick was quietly reclassifying what the user accepted at their own risk as a proven finding. What a pane *shows* still wins over what was stored, which is how a re-ground verdict moves lists; an entry that is neither shown nor stored goes to the side that warns. MED-2, the security finding. PlainTextRendering builds the labels and the table cell renderer with `html.disable`, and every component carrying grinder- or daemon-supplied text now goes through it: all eight verdict columns, the worker, crawl, boot-rule, rule-error and loader-cache lines, the dashboard card values and both status lines. Measured, headless: a JLabel and a DefaultTableCellRenderer both install an HTML view for a string starting with `<html>`, and Swing's HTML subset fetches remote images — so a mod name was enough to make a user's window issue a request. The grinder's own web report was hardened against this same input class; the Swing surface had reintroduced it. A-3. A 200 carrying valid JSON that is not a verdict document is now Failed rather than Ok(empty), which an operator could not tell from a grinder that had ground nothing. readVerdicts returns null for that; an empty `verdicts` array still reaches the success branch, so a genuinely empty grinder is unchanged. MED-3. ExtensionScopingTest gains the half that costs something — an extension running once per installed plugin rather than once. Written after the fix, so it was verified red by reverting the one-line change: all four guards then fail with 4 where 2 is correct. LOW-2/3, efficiency: the pane summary is a set intersection instead of selection × rows on every filter keystroke, and getValueAt reads exclusionEntry once per tick cell instead of twice. LOW-4/5/7, tidying: the unused JsonNode import, the dead SettingsPane.isUsable (GrinderTab already asks the same question through resolvedUrl), and copyExamplePluginsToApp → copyPluginsToApp, which has taken two plugins since the scaffold commit. LOW-6: the dashboard Timer stops in removeNotify and resumes in addNotify, so an unattended ServerPackCreator no longer polls its grinder forever. Also fixed, and not in the audit because it was found by re-running the check the audit did not repeat: getColumnClass used `java.lang.Boolean::class.java`, which warns "not recommended for use in Kotlin". `Boolean::class.javaObjectType` is the same boxed class without the warning — and still not `Boolean::class.java`, which is primitive boolean.class and has no JTable renderer. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>Red on purpose. Two guards fail against current code: JarSelfDeclarationTest.aNeoForgeBootOnMinecraft1201AcceptsAForgeJar expected: <null> but was: <Mantle-1.20.1-1.11.117.jar carries only Forge descriptor(s), so it is not a NeoForge mod> BootCandidateSelectorTest.theNeoForgeFallbackToForgeAppliesOnMinecraft1201Only NeoForge 20.1.x loads a Forge 1.20.1 mod unchanged expected: <dep-forge.jar> but was: <null> NeoForge 20.1.x is a fork of Forge 47 that kept the net.minecraftforge packages, javafml and META-INF/mods.toml; the package rename landed with 1.20.2, from where the two are separate ecosystems. 1.20.1 is therefore the entire compatibility band, not the start of one. The live false positive: the grinder published an ERROR row for CurseForge/mantle on NeoForge, "Refusing to boot NeoForge on Minecraft 1.20.1: Mantle-1.20.1-1.11.117.jar carries only Forge descriptor(s), so it is not a NeoForge mod" -- for a file CurseForge ticks Forge AND NeoForge, and which had booted to a ready-line under Forge minutes earlier in the same run. The remaining three guards are green already and stay as regression cover: the band ends at 1.20.1, the concession is one-way (Forge still cannot read neoforge.mods.toml), and a real NeoForge build still beats the Forge fallback. Also strengthens theFallbackDoesNotApplyToOtherLoaders, whose Forge fixture was tagged 1.20.1 and asked for at 1.21.1: it answered null because no file carried the version, so the assertion could not see the cross-loading rule its own message was about. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>Red on purpose, three guards, and the middle one reproduces the live symptom exactly: MetadataScannerTest.aConnectorPlaceholderIsScannedAsTheFabricModItWraps the placeholder mods.toml declares nothing; the fabric.mod.json beside it declares client expected: <CLIENT> but was: <SERVER_OR_BOTH> JarSelfDeclarationTest.aConnectorPlaceholderNamesItselfInItsModsToml JarSelfDeclarationTest.anythingWithoutTheMarkerIsNotAConnectorPlaceholder kotlin.NotImplementedError: the placeholder marker is not read yet A Sinytra Connector "placeholder" is a Fabric mod wrapped so a platform can tag it Forge. Read from the live continuity-3.0.0+1.20.1.forge.jar: its META-INF/mods.toml carries [properties] "connector:placeholder" = true and version-less dependency entries, and the fabric.mod.json in the same jar holds the actual mod, declaring "environment": "client". Scanning that with the Forge scanner reads the stub, which declares no sideness at all. Measured live 2026-09-06: Modrinth/continuity's Forge row came back jarScan=SERVER_OR_BOTH and declared=CONTRADICTORY against a platform declaring client_side=REQUIRED, while the same project's Fabric row read CLIENT off the same descriptor. The false contradiction is what arms ClientsideVerifier's other-version crash re-check, which spends up to three boot budgets (~45 min) arguing with a contradiction that was never there. JarSelfDeclaration.isConnectorPlaceholder is declared as TODO() so the test tree compiles and every guard runs red for the one reason. The fourth guard is green already and stays as regression cover: a genuine multi-loader jar carries both descriptors too, so the redirect keys on the marker, never on the pair. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>Turns the three red guards green. A Sinytra Connector placeholder is a Fabric mod wrapped so a platform can tag it Forge: its META-INF/mods.toml carries [properties] "connector:placeholder" = true and exists only to get the file past Forge's mod discovery, while the fabric.mod.json beside it holds the actual mod. Scanning the stub reads no sideness at all. JarSelfDeclaration.isConnectorPlaceholder reads the marker (nightconfig's TomlParser, already on the compile classpath via -api), failing toward false like everything else in that object. MetadataScanner substitutes the scanner's INPUT, not the dispatch: the loader -> scanner choice still goes through ModScanner.scannerFor, so this class and ModListCompiler cannot drift the way they once did. Keyed on the marker, never on carrying both descriptors -- a genuine multi-loader jar ships a real mods.toml beside a real fabric.mod.json and each speaks for its own loader. What it fixes, live 2026-09-06: Modrinth/continuity's Forge row came back jarScan=SERVER_OR_BOTH and declared=CONTRADICTORY against a platform declaring client_side=REQUIRED, while the same project's Fabric row read CLIENT off the identical descriptor. The contradiction was manufactured by the scanner choice, and ClientsideVerifier.declaresServerSupport -- the same predicate -- is what arms the other-version crash re-check, which spends up to three boot budgets (~45 min) per armed candidate. The Forge boot is still attempted: a working Connector setup would still be verified, and its INCONCLUSIVE stands on its own evidence rather than on a false metadata contradiction. Griefed's call. Not fixed here, and not ours: Connector beta.49 under Forge 47.4.23 did not convert the jar at all ("Dependency resolution found 0 candidates to load"), which is why the boot failed. The grinder had staged exactly the right files -- newest Sinytra Connector and newest Forgified Fabric API for 1.20.1. --rerun-tasks: clientside 399/399, grinder 503 (29 skip), app 149/149, all green. No new compiler warnings. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>Red on purpose: nine pure guards on the decision, plus the staging join. DependencyBacktrackStagingTest .aDependencyDemandingAnUnavailableVersionIsDroppedToAnOlderBuild the 3.6.6 build demands fabric-api >=0.100.0+1.20.6 and must not survive staging expected: <[YetAnotherConfigLib-3.4.2.jar, Zoomify-2.13.3.jar, fabric-api-0.97.8.jar]> but was: <[Zoomify-2.13.3.jar, fabric-api-0.97.8.jar, yet_another_config_lib_v3-3.6.6.jar]> DependencyBacktrackTest (nine) kotlin.NotImplementedError: the staged set is not checked against its own declared requirements yet / nothing is demoted yet Staging resolves each dependency on its own -- the newest file of that project tagged for the pack's Minecraft -- and never asks whether the resulting SET is coherent. Measured live 2026-09-06, Modrinth/zoomify on Quilt / Minecraft 1.20.5: Modrinth tags yet_another_config_lib_v3-3.6.6+1.20.6-fabric.jar for 1.20.5 and 1.20.6, and its own descriptor declares "minecraft": "~1.20.5", so neither selection nor the descriptor gate objects -- but it also declares "fabric-api": ">=0.100.0+1.20.6", and the newest Fabric API Modrinth publishes for 1.20.5 is 0.97.8+1.20.5 (verified against the live API: four files, 0.97.5 through 0.97.8). No fabric-api satisfies it there, so staging MORE cannot fix the pack; only an older YACL can. 3.4.2+1.20.5 requires nothing but fabric-resource-loader-v0. The staging test drives the real join -- resolve, download, scan, judge, demote, re-stage -- with a fake platform and a downloader that writes real jars, so the pure decision is proven to be wired to something. It stays offline by injecting a LoaderVersionPolicy answering a build no config check accepts: selection passes, generation fails, and everything asserted happens before generation. aCoherentSetKeepsTheNewestDependency is green already and stays as the counterweight: without it the fix would be indistinguishable from "always take the older dependency". Fixture note, caught by running the pins before committing them: the descriptor map first held whole JSON objects trimmed with trim('{','}'), which strips EVERY trailing brace and left "depends":{... unterminated -- so fabric-api was never staged and the guard would have gone red for its own fixture rather than for the missing implementation. The map now holds descriptor bodies. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>Not this branch's work -- pre-existing drift on develop, surfaced because `./gradlew build` regenerates the report and left the tree dirty. The only delta is com.microsoft.playwright:playwright:1.62.0 dropping out, 44 dependencies to 43. It was removed on 2026-09-02 ("the route existed only to circumvent the distribution block, and by the end it did not work at all") and the generated report was never re-committed, so every full build since has dirtied both copies. Generated by the build, not hand-edited; both files are the same report and move together. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>12 commits. Griefed read three rows off the public grinder — one false positive and two INCONCLUSIVEs he judged solvable — and each turned out to have a different cause than the symptom suggested. Two of the three diagnoses had to be corrected against the live APIs before anything was written. NeoForge runs Forge builds on Minecraft 1.20.1, and on nothing else. NeoForge 20.1.x is a fork of Forge 47 that kept the net.minecraftforge packages, javafml and META-INF/mods.toml, so there a Forge jar and a NeoForge jar are the same file; the rename to net.neoforged landed with 1.20.2 and ends it. CurseForge/ mantle published an ERROR row refusing to boot Mantle-1.20.1-1.11.117.jar as NeoForge — for a file CurseForge ticks Forge AND NeoForge, and which had reached a ready-line under Forge minutes earlier in the same run. The fact had two homes that had silently diverged, JarSelfDeclaration.alsoRuns and BootCandidateSelector.fallbackLoaders, both spelling Quilt -> Fabric, so only one of them could ever have learned it; LoaderCompatibility is now both, and it takes the Minecraft version because the NeoForge claim is meaningless without one. Stated as the single version, never a lower bound: a range would boot Forge jars under NeoForge 1.20.2+, where FML rejects them and the failure is scored against the mod. A Sinytra Connector placeholder is a Fabric mod, and the Forge scanner reads a stub. Modrinth/continuity's Forge row came back jarScan=SERVER_OR_BOTH and declared=CONTRADICTORY against a platform declaring client_side=REQUIRED, while the same project's Fabric row read CLIENT off the identical descriptor. Pulled down, continuity-3.0.0+1.20.1.forge.jar carries [properties] "connector:placeholder" = true with version-less dependency entries, and the fabric.mod.json beside it holds the real mod, "environment": "client" included. The contradiction was manufactured by the scanner choice — and declaresServerSupport, the same predicate, is what arms the other-version crash re-check, so a false one costs up to three boot budgets (~45 min) per armed candidate. The redirect substitutes the scanner's input, not the dispatch, so the MetadataScanner/ModListCompiler drift cannot come back. Reported as "it requires the fabric-api despite being a Forge mod"; the staging was in fact already right — the newest Connector (beta.49) and the newest Forgified Fabric API (0.92.6+1.11.15) for 1.20.1 were both present, and Connector under Forge 47.4.23 still logged "Dependency resolution found 0 candidates to load" and never converted the jar. That half is Connector-internal and is not ours. The boot is still attempted, so its INCONCLUSIVE now stands on its own evidence. A pack whose own jars contradict each other backtracks instead of booting. Staging resolved every dependency alone — the newest file that project publishes for the pack's Minecraft — and never asked whether the resulting set was coherent. Modrinth/zoomify on Quilt / Minecraft 1.20.5 was reported as needing a newer fabric-api; the live API says there is none, Modrinth publishing exactly four files for 1.20.5, 0.97.5 through 0.97.8, the newest of which the grinder had already staged. The unsatisfiable link is yet_another_config_lib_v3-3.6.6+1.20.6-fabric.jar: tagged for 1.20.5, declaring "minecraft": "~1.20.5" so neither selection nor the descriptor gate objects, and demanding "fabric-api": ">=0.100.0+1.20.6". Staging more cannot fix that pack; only an older YACL can, and 3.4.2+1.20.5 requires nothing but fabric-resource-loader-v0. DependencyBacktrack judges the staged set against itself before generation and drops an over-demanding dependency a build, up to ten times. It never demotes the candidate, never refuses — every uncertainty proceeds to the boot exactly as before, because a gate refusing on doubt is the mass-INCONCLUSIVE shape this module has already paid for twice — and ignores both optional dependencies and requirements naming something not staged at all. Cost stated rather than optimised away: a backtrack re-stages from scratch, and zoomify needs seven. Verification. ./gradlew build green with a clean working tree; clientside 410/410 (390 before, 20 new guards), grinder 503 (29 skip), app 149/149, all under --rerun-tasks. Equivalence checked the way this repo asks: develop's unmodified test tree against the branch's production code, 390 pre-existing guards, zero failures and zero compile errors. Every fix landed as a red test() commit first and each pin was run before being committed — which caught a fixture bug where trim('{','}') stripped both closing braces, so that guard would have gone red for itself rather than for the missing implementation. Two things that were not asked for and are worth knowing. The Forge arm of theFallbackDoesNotApplyToOtherLoaders asserted nothing: its fixture was tagged 1.20.1 and asked for at 1.21.1, so it answered null for version reasons whatever the loader rule said, while its message spoke about cross-loading. And the full build regenerated licenses/LICENSE-AGREEMENT.txt, exposing drift that predates this branch — playwright:1.62.0 was removed on 2026-09-02 and the generated report never re-committed, so every full build since has dirtied both copies. Regenerated in its own commit. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>Turns UnreadableStagedVersionTest green. The whole clientside suite is green at 414 tests (410 before the pin), and no existing assertion was touched -- only production code changed here, which is what makes the previous commit's red a boundary anyone can check out. Two guards, both extending a promise the class doc already made ("a version string that is not a version" must never refuse) to the side that never had it: - `readableVersion` gates `satisfies` on the *version*, where `looksLikeVersion` gates only the constraint. It is stricter on purpose: "holds a digit" is too generous for a version, since `Balm 26.2.0.7` holds four and still reads as `[0, 2, 0, 7]`. Every dot-separated component of the core must be numeric, so prose accepts instead of comparing as ~zero. - `numbersOf` drops a leading `v`, so `v2.1` is `[2, 1]` rather than `[0, 1]`. Same defect, older, and carried in the fuzz test's own version list without ever being asserted on. Deliberately NOT done: extracting a version out of a decorated release name. Guessing which digits in `Create 6.0.10 for NeoForge 1.21.1` are the mod's is exactly the silently-plausible-value trap this module keeps paying for -- and the two candidate readings there differ by four major versions. What this costs: a real conflict spelled in a version we cannot parse is now missed, and the pack boots as it did before DependencyBacktrack existed. That direction is the cheap one -- a missed conflict costs one boot, an invented one costs a published verdict, and 47 of them are published right now. The backtrack's other half is untouched: `aReadableStagedVersionStillConflicts` keeps the zoomify case (fabric-api 0.97.8+1.20.5 against >=0.100.0+1.20.6) demoting exactly as designed. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>Committed red, and the red is the pack the loader refuses: `expected: <[create-6.0.8.jar, ...]> but was: <[create-6.0.10.jar, ...]>`. The counterweight passes already, by construction -- it becomes a real guard once demotion can happen at all. `dependencyToDemote` builds its "what is on the classpath" map from `modsDir.listFiles()` and `InjectedDependency.version`. A jar-in-jar library is in neither: it is not a top-level file and the platform never published it. `DependencyBacktrack.conflicts` then skips the requirement naming it, deliberately -- a requirement naming something unstaged is `refuseForMissingDependencies`' case -- so a pack whose own jars contradict each other boots anyway. Live case, CurseForge/createaddition on NeoForge 21.1.250 / Minecraft 1.21.1, 2026-09-07: Mod ID: 'ponder', Requested by: 'create', Expected range: '[1.0.82,)', Actual version: '1.0.64' `ponder` is in none of that verdict's four stagedDependencies. The container was spent and the CANDIDATE wore the INCONCLUSIVE, which is the shape every other guard here exists to prevent. Rare, but it is the direction that publishes a wrong verdict rather than merely wasting a boot. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>Turns the A-1 pin green; clientside 436, zero failures, no existing assertion touched. `readableVersion` asks `component.toIntOrNull() != null` instead of `all { it.isDigit() }`, which is the question `numbersOf` actually needs answered — it ends in `toIntOrNull() ?: 0`, so the two predicates disagreed exactly where the answer becomes zero. A version carrying a date or a CI counter now accepts (no opinion) rather than comparing as though its largest component were nothing. Chosen over widening `numbersOf` to `Long`, which moves the ceiling rather than closing the gap: the same silent `?: 0` would still be there for anything past it, and this module's rule is that a value we cannot read yields no opinion. Sign-prefixed components cannot slip through the looser parse: `substringBefore("+")` and `substringBefore("-")` have already removed everything from the first sign onward, so a `+5` or `-5` component leaves an empty string, which `toIntOrNull` rejects. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>`./gradlew :serverpackcreator-api:updateManifests`, then the api suite re-run **against the copied snapshot** rather than the pre-copy one the task itself depends on: 409 tests, zero failures, one skip, `ShippedManifestSnapshotTest` included. Measured, before -> after: fabric-manifest.xml <latest>0.19.3</latest> -> 0.19.5 (lastUpdated 20260601 -> 20260828) quilt-manifest.xml 0.30.1-beta.2 -> 0.31.0-beta.4 quilt-installer-manifest 0.15.0 -> 0.15.1 neoforge-manifest-new.xml 26.2.0.41-beta -> 21.1.250 forge / minecraft / fabric-intermediaries: content only, no <latest> element The Fabric line is why this was done now. The daemon seeds version metadata from this snapshot at startup and refreshes in a background coroutine; after Griefed cleared SPC_GRINDER_HOME the first Fabric boot raced that refresh, installed what the stale snapshot named, and `CachedLoaderVersions` has preferred that most-recently-used build ever since. Measured on the live daemon: **all 511** Fabric boots ran loader 0.19.3, and 17 of 42 dependency failures were mods demanding `fabricloader >=0.19.5`. **NeoForge's `<latest>` moving backwards is upstream behaviour, not damage.** Their maven `<latest>` is whatever was published last, and 21.1.x LTS still receives releases after 26.2 betas; `NeoForgeMeta` derives the newest build per Minecraft version from the version list, never from that element. Stated because a reader diffing this commit will see a version number go down. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>Red, and the red is a dead JVM rather than an assertion: 53 ApiWrapper constructions, 268 log lines mentioning `example-kotlin`, 15 OutOfMemoryErrors, and the api test task fails as a whole. That is the defect Griefed reported as "the log-output for the example plugin a gazillion times", reproduced here for the first time. The chain: 1. `ApiWrapper.api()` builds a wrapper. The companion's field is assigned only when the constructor RETURNS, and `@Synchronized` is re-entrant on the same thread, so it stays null throughout. 2. The constructor runs `setup()` -> `stageThree()`, which touches `apiPlugins` FIRST. 3. `ApiPlugins.init` calls `loadPlugins(); startPlugins()`, so pf4j runs plugin code from inside a lazy initialiser. 4. `Example.init` calls `ApiWrapper.api()` six times. The field is still null, so a SECOND wrapper is built, which loads the plugins again, which… Why the suite never caught it: tests share a JVM, and whichever class called `ApiWrapper.api()` first did so before anything had copied a plugin jar into `tests/plugins`. `ExtensionScopingTest` installs one in its own `@BeforeAll` and loads it by hand, long after the singleton is published, so the re-entrant call returns it and nothing recurses. The defect needs a populated plugins directory at FIRST startup — every real CLI run, and no test until this one, which installs the jar in `@BeforeAll` and then triggers `api()` from a field initialiser. There is a second cycle underneath, which the fix has to close as well: even with the singleton published, `Example.init` reaches `ApiWrapper.api().serverPackHandler`, whose lazy initialiser needs `apiPlugins` — and Kotlin's `SynchronizedLazyImpl` is re-entrant, so it does not block, it runs the initialiser again and loads the plugins again. `ApiPlugins.loadAndStart` is stubbed `TODO()` so the tree compiles and the first guard fails for one stated reason. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>Turns PluginLoadingOrderTest green and kills the recursion at both ends. Measured on the same reproduction that was red one commit ago: ApiWrapper constructions 53 -> 0 example-kotlin log lines 268 -> 7 OutOfMemoryError 15 -> 0 api 412, app 149, clientside 475, grinder 509 (29 skipped), zero failures. Two cycles, two changes. `ApiWrapper.api()` publishes the instance BEFORE running setup. Setup loads plugins, plugin code calls `ApiWrapper.api()`, and both `@Synchronized` and the inner `synchronized(this)` are re-entrant on one thread — so assigning only after the constructor returned meant the re-entrant caller saw null and built another wrapper. A failed setup still un-publishes, so a later call retries from scratch rather than handing out a half-built wrapper; that was the one useful property of assign-on-success. `ApiPlugins.loadAndStart()` replaces the constructor's `init`, and `stageThree` calls it **last**, after `configurationHandler` and `serverPackHandler` exist. Without that, the plugin's `ApiWrapper.api().serverPackHandler` entered that lazy from inside `apiPlugins`' own lazy initialiser, and `SynchronizedLazyImpl` re-enters rather than blocking: the initialiser simply ran again and loaded the plugins again. Fixing only the singleton would have swapped one recursion for the other. The example plugin is deliberately left alone. Calling `ApiWrapper.api()` from a plugin's `init` is what the example documents and what third-party plugins copy, so the API has to survive it; editing the example would have hidden the defect rather than fixed it. Two rows in claude-docs/API-BEHAVIOUR-CHANGES.md — `ApiPlugins` is published, and an embedder constructing it directly now gets a manager with no plugins loaded until `loadAndStart()`. No signature changed, so nothing fails to compile, which is precisely why it is written down. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>Audit finding M-2. `preventionCauseFor` folds the causes present with `first {}`, which throws `NoSuchElementException` on an empty map. Its only caller guards it -- `refuseForMissingDependencies` returns null before reaching it -- so it is unreachable today, which is precisely the shape this module has paid for before: `UnmetReason.explain` returned null for a value no caller could produce, and two log sites would have printed the literal `null` after some later edit. An `internal` helper with no `require`, no doc saying "never empty" and a name that reads total is a landmine. Red with `NoSuchElementException: Collection contains no element matching the predicate` -- the exception a second caller would get, from a grind worker, naming an enum rather than a dependency. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>Closes audit finding M-2. `firstOrNull { … } ?: PreventionCause.HOST` instead of `first { … }`, so folding an empty unmet-dependency set answers rather than throwing NoSuchElementException from a grind worker. HOST is the answer for the same reason it is every other prevention default: when nothing says whose problem it is, the loud and actionable reading is the safe one. The KDoc now states the empty case, because a helper guarded only by its caller is how `UnmetReason.explain` came to return null for a value two log sites would have interpolated. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>