...nice things! #675
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!675
Loading…
Reference in a new issue
No description provided.
Delete branch "alpha"
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?
Add ConfigurationHandlerCharacterizationTest (manifest parsing for CurseForge/GDLauncher/ATLauncher/MultiMC, zip-archive checks, inclusion suggestions, modloader normalization) and ServerPackHandlerCharacterizationTest (explicit/save/regex file gathering, pre/post-install cleanup, icon and properties handling, script placeholder replacement, mod-list compilation). Adds ATLauncher and GDLauncher instance.json fixtures. API suite: 75 -> 105 tests. Fix getModLoaderCase: "legacyfabric" was detected as Fabric because the Fabric branch matched first via contains("fabric"), and the NeoForge contains-check could never match on a lowercased string. Most specific loader names are now checked first. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>Add standalone-MockMvc tests for ModPackController, ServerPackController, RunConfigurationController, EventController, StatsController and SettingsController, with mocked services — no Spring context, no MongoDB. App suite: 5 -> 39 tests. Web-entity IDs are private set, so tests assign them via the assignEntityId reflection-helper. Fix StatsController: server pack download-history-by-id was mapped to /downloads/modpacks/{id}, colliding with the modpack-history route and making the endpoint unreachable. Now /downloads/serverpacks/{id}. Fix SettingsController: Jackson stripped the "is"-prefix from the Boolean settings-fields, so the frontend setting-store read undefined for isZipFileExclusionEnabled and isAutoExcludingModsEnabled. JsonProperty annotations now pin the frontend-facing names. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>Introduce api.config.SupportedModloaders as the single source of truth for the five exact-match loader-regexes and the canonical loader-names. PackConfig, ConfigurationHandler, ModloaderValidator, ModpackManifestParser and ApiProperties.supportedModloaders now all reference it; no "^forge$"-style literals remain duplicated. Remove service-locator reach-backs into the ApiWrapper-singleton: ServerPackManifest now derives the SPC-version from its own package implementation-version instead of ApiWrapper.api(); PackConfig.save gains a primary save(destination, apiProperties) overload requiring injection, with the old save(destination) retained as a @Deprecated facade. App call-sites (InteractiveCommandLine, ConfigEditor, TabbedConfigsTab) inject apiProperties explicitly. Deprecate ReticulatingSplines in place: it is GUI-only and slated to move to the app-module at the next major version. Physically relocating it now would either duplicate 458 lines of joke-data to keep an API facade or break source-compat, so it stays deprecated until the next major bump. Its two app consumers carry @file:Suppress("DEPRECATION") with a note. ApiWrapper was already a thin composition-root (lazy, constructor-injected collaborators) and is left as-is. Phase 1 (API) is complete. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>Per Griefed, ReticulatingSplines is a deliberate just-for-fun API endpoint and is meant to stay in the API. Revert its deprecation and the @file:Suppress("DEPRECATION") notes on the two app consumers (Reticulation, StatusPanel). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>The frontend had zero tests. Add the Vitest toolchain (vitest@3, @vue/test-utils@2, @pinia/testing@1, happy-dom) with a vitest.config.js that maps Quasar's path-aliases and uses happy-dom; wire `npm test` to `vitest run`. First tests pin the settings store's refresh() data-fetching against a mocked axios boot-module — vi.mock('boot/axios.js') keeps tests off the network and avoids the Quasar-only #q-app/wrappers import chain. Noted for follow-up: stores/index.js registers no Pinia plugins, so the this.$q.notify in refresh()'s error path hits an undefined $q — latently broken error handling, to be decoupled from $q next. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>Enable Quasar's TS support without converting source yet, so the toolchain is green before the file-by-file migration: - Add root tsconfig.json extending the Quasar-generated .quasar/tsconfig.json; remove jsconfig.json (it dangled on a non-existent ./tsconfig.json — now moot). - quasar.config.js: build.typescript { strict, vueShim } and vite-plugin-checker vueTsc:true so dev/build type-check .ts and <script lang="ts"> blocks. - ESLint flat config gains typescript-eslint (recommended rules + TS parser for .vue script blocks); lint/format/checker globs include .ts; add a type-check script (vue-tsc --noEmit). Drop the dead legacy .eslintrc.cjs and the deprecated .eslintignore (Quasar's flat preset already ignores those dirs). - devDeps: typescript (promoted explicit), vue-tsc, typescript-eslint. allowJs stays on and checkJs off, so existing .js/.vue keep compiling untouched. Verified green: vue-tsc 0 errors, eslint clean, vitest 3/3. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>Add characterization tests for the three axios-driven cards, mocking the boot/axios module so mount never hits the network: - ModPackCard: pins the `projectID/versionID.length === 1 ? value : 'N/A'` Modrinth-id template quirk, plus size-in-MB and server-pack count. - RunConfigurationCard: pins the nested-array flattening (`{argument}`/`{mod}` objects -> flat string arrays) and the space-joined start-args display. - ServerPackCard: pins the fetched-field wiring and size-in-MB. Suite 16 -> 23. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>colourprop 05450f15eeStringUtilities.checkForInvalidPathCharacters was an OR-of-negations, so it returned true ("valid") unless the path contained *every* forbidden character at once — letting virtually all invalid destinations through, contradicting its own contract ("true if none of these characters were found"). Change || to && so the method returns true only when not a single forbidden character is present; any one forbidden character now correctly invalidates the path. Both call-sites (InclusionsValidator, GUI InclusionsEditor) treat true as "valid", so the semantics stay consistent. Adds a direct regression test in StringUtilitiesTest (each forbidden char rejected individually) and simplifies the inclusion-destination validator test to a single-forbidden-char case. API suite 210 -> 212, all green. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>Remediation of the workflow audit (see claude-docs/WORKFLOW-AUDIT.md). Security hardening: - H1: drop tj-actions/branch-names (compromised publisher) in test / github-prerelease / github_release; use ${{ github.ref_name }} instead. - H2: add top-level `permissions: contents: read` to every workflow; elevate to contents:write only on jobs that create releases/tags/pages. - H3/L1: SHA-pin all third-party actions (incl. scp-deploy@master and checkout@master); first-party actions/* and gradle/* stay on major tags. - M4: replace deprecated ::set-output with $GITHUB_OUTPUT. - M5: update_readme — move the GitLab token out of the push URL into an http.extraheader, pass secrets via env, drop the needless apt-get. - M6: pin discord.sh to a commit and checksum-verify before executing. - L2/L3: pass dispatch input via env in devbuild; add concurrency to test. Correctness fixes (interleaved with the hardening in the same jobs/hunks, so not isolated into their own commit): - M1: github_release `pages` job given `needs: [preparations]` (was building docs with -Pversion=""). - M2: drop the dead `steps.preinfo` reference from github_release. M3 (`if: always()` on release jobs) intentionally kept per maintainer; clarifying comment added. Workflows fire only on tag-push/release/dispatch and were YAML-validated but cannot be exercised locally. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>The module had all 19 classes in the base `grinder` package. Split by responsibility into four leaf subpackages, keeping the base package as the composition/orchestration core: - grinder.container — ContainerEngine, DockerJavaContainerEngine, ContainerServerRunner - grinder.loader — LoaderCache, DockerLoaderInstaller, VanillaPackGenerator, InstallLayerSnapshot, PackVariables, ImageJavaRuntimes - grinder.report — VerdictStore, JsonVerdictStore, VerdictCsvExporter, VerdictReportRenderer, ReportServer - grinder.source — ModrinthCandidateSource - grinder (base) — GrindModels, Grinder/GrindPool, ContainerCandidateVerifier, GrinderApplication (the entry point / mainClass stays here) Dependencies point inward: subpackages import only the base package's domain models (GrindModels); the base package composes the subsystems. GrinderApplication's fully-qualified name is unchanged, so the build's mainClass needs no edit. Pure refactor: moves via git mv (rename-tracked), package declarations rewritten, cross-package imports added, module.md Dokka links and CLAUDE.md updated. No behavior change — all 47 grinder tests pass with their existing assertions. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>InMemoryVerdictStore.record built its map key with a literal NUL byte instead of a space ("${slug}\x00${loader}"), an invisible character that made git treat the whole Kotlin source as BINARY: no textual diffs, useless blame/log -p, and textually unresolvable merge conflicts. A repo-wide scan found this was the only tracked source file affected. It also left the two VerdictStore implementations disagreeing: InMemory keyed by slug+NUL+loader while JsonVerdictStore.keyOf uses a real space. Both now use a space, so they agree. No behavior change — keys are internal to each store and never persisted as such (the file-backed store rebuilds them on load). Audit finding M1. grinder 57/57 green. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>Three code-hygiene findings from the audit. None change production behavior; all suites stay green with their existing assertions. L4: PACK_MOUNT was declared three times ("/srv/pack" in ContainerServerRunner, DockerLoaderInstaller and the matrix IT) — if the mount point ever moved, two copies would be missed. Now one top-level const in grinder.container, which all three (and ContainerServerRunnerTest) import. L3: drop the remaining `!!`. The IT resolved each cell's loader version twice — once to filter, once with `!!` — so it now resolves once into a Map<Cell, String> that doubles as the validity filter, removing the assertion by construction. ContainerServerRunnerTest's `engine.lastSpec!!` becomes requireNotNull with a message (Boy-Scout: that file was already open for the PACK_MOUNT change), and the new api test uses `?: Assumptions.abort(...)` instead of `!!`. L5: the IT forced the default sh/fish/ps1 templates onto process-wide ApiProperties and never restored them, which would leak into any other test in the same JVM once the gate is enabled. The mutation is now scoped to pack generation and restored in a finally. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>A SIGTERM to the daemon tore the JVM down mid-boot, so the engine's per-run `finally` never executed and the Minecraft server container was left running — observed for real during the continuous-mode verification and removed by hand. DockerJavaContainerEngine now tracks the containers it owns and implements AutoCloseable; close() force-removes whatever is still in flight (idempotent, never throws). GrinderApplication registers that cleanup as a shutdown hook right after building the engine, so it covers the one-shot path too, not just the loop. GrindPool gains requestStop(): workers finish their current candidate and then abandon the queue, so a shutdown ends the pass promptly instead of draining a whole popularity-ranked batch — the in-flight boot is torn down by the engine close, not cancelled mid-grind. Verified against a live daemon: the new closeRemovesAContainerLeftRunningByAn AbandonedRun IT starts a long-running container, closes the engine while it runs, and asserts it is gone ("Removing 1 container(s) abandoned by an interrupted run."). requestStop is unit-tested: stopping during the first candidate leaves the remaining 19 unprocessed. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>LoaderVersionResolverTest: all four modern loaders resolve across 1.21.1 and 1.21.11 — the two-digit patch is deliberate, it catches a resolver that only understands single-digit 1.21.x. LegacyFabric gains its first tests anywhere: it resolves its own era (1.12.2, 1.8.9) and must return null for 1.20.1/1.21.1, which is the Minecraft-support gate holding for a loader whose manifest stops at 1.13.2. Fabric/Quilt are pinned null for 1.12.2 (no intermediary before 1.14). ScriptTemplateMatrixIT: matrix defaults grow to {1.12.2, 1.16.1, 1.20.1, 1.21.1, 1.21.11} x {Forge, NeoForge, Fabric, Quilt, LegacyFabric} — verified from the manifests beforehand that those loader/version pairs really exist, so the new cells are genuine rather than N/A. Adds powerShellInstallerJavaSelectionHonoursTheOverrideAndItsFallback, which extracts RunInstallerJavaCommand from the shipped .ps1 via the PowerShell AST, stubs CMD and executes it: an unset JavaInstaller falls back to Java, a set one wins. That is real execution of template code on Linux, where a full .ps1 boot is impossible because the template shells out to Windows CMD. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>CurseForge's /mods/search refuses index + pageSize > 10 000, so one query can never expose more than the 10 000 most-downloaded mods however long the grinder runs. The catalog is now crawled as a *sequence* of bounded queries instead: 1. the unfiltered catalog (its top 10 000 — the mods that matter most, and what the crawl did before this change), 2. then every game version from /games/{id}/versions, newest first, 3. a version whose pagination.totalCount exceeds the cap is re-crawled once per modloader — this is what actually reaches past 10 000, 4. a loader slice still over the cap is crawled from the bottom as well (sortOrder=asc), covering up to 20 000 mods in that slice. Splitting only where a reported count demands it keeps a sweep at roughly one request per version rather than one per version AND loader. Every response carries totalCount, so sizing is free; the one case that needs a probe is a slice resuming exactly at the cap. The traversal state travels in the crawl cursor as an opaque, source-defined token (CatalogCursor.partition / CandidatePage.nextPartition), which the crawler persists and replays verbatim without interpreting. Grouped into one commit deliberately: the token exists solely to serve this crawl, and split apart neither half would compile or pass. Facts pinned against the published contract (docs.curseforge.com, plus PrismLauncher's Flame integration for the namelessly-documented enums): index + pageSize <= 10 000, pageSize <= 50, pagination.totalCount, sortOrder asc/desc, ModLoaderType Forge 1 / Cauldron 2 / LiteLoader 3 / Fabric 4 / Quilt 5 / NeoForge 6, and the versions response shape. Degradation and residual gaps are explicit, not hidden: no version list means the crawl falls back to the unfiltered top 10 000 (reduced, still working); a failed request or probe keeps its position; and a single (version, loader) slice above 20 000 mods, or a mod carrying no loader tag beyond its version's cap, is logged as skipped with a count. All six loaders are crawled, including the legacy ones — one request each versus making their mods unreachable. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>Replaces the "CurseForge coverage is capped at 10 000, not implemented yet" sections with what the crawl now does, and states plainly what is *not* proven: none of the CurseForge path has ever run against the live API, because this project has no CURSEFORGE_API_KEY. The README names the log lines to check on a first keyed run ("crawl covers N game version(s)", "not sorted by", "holds N mods but only"). Also documents the two residual gaps as remaining work rather than letting the docs imply completeness: a (version, loader) slice above 20 000 mods loses its middle, and a mod with no modloader tag is unreachable beyond its version's cap. Both are logged with counts; a third partition axis (categoryId) is the fix if a real run shows it matters. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>The offline short-circuit in setupFabric returned as soon as it found an installed launcher jar, jumping over the closing SERVER_RUN_COMMAND="${JAVA_ARGS} -jar ${LAUNCHER_JAR_LOCATION} nogui" so the pack launched `java -Dlog4j2.formatMsgNoLookups=true do_not_manually_edit` and died with "Could not find or load main class do_not_manually_edit". The disk branch now falls through to the assignment in all three templates, with the network path moved into an else. The existing guard only asserted that the disk check precedes the network probe, which stayed green throughout. The new test extracts the bash setupFabric, sources it with curl/wget denied and every download and install stub exiting non-zero, stages a launcher jar and asserts the assembled command -- verified to fail when the early return is reinstated. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>SPC stored its home directory in one machine-wide per-user Preferences node, `ServerPackCreator`, shared by the GUI, the web backend, every test suite and the grinder daemon -- and PathsConfig.homeDirectory re-reads it on every access, writing back whatever it resolved. A test suite booting an ApiWrapper therefore relocated a *running* daemon's home into its own scratch directory and then deleted it: every boot afterwards failed on a missing server-icon.png and was recorded as a metadata-only verdict, indistinguishable in the report from "this mod was never bootable". ApiProperties.resolvePreferencesNode() now takes the node from -Dde.griefed.serverpackcreator.preferences.node, else SPC_PREFERENCES_NODE, else the unchanged default (a blank override falls back, since userRoot().node("") is the root node). GrinderApplication claims ServerPackCreator-grinder before any ApiProperties exists and logs it; the build gives every test JVM ServerPackCreator-test-<module>. An isolated node has no stored home, which exposed a second fault: the dev-build fallback is File("").absolutePath, i.e. the test JVM's working directory -- the module's own source tree -- and ApiWrapper.setup() *writes* into the home (README.md, CHANGELOG.md, server_files, manifests). That overwrote serverpackcreator-clientside/README.md's checked-in CLI guide with the bundled root README, failing ClientsideReadmeFlagsTest. PathsConfig therefore honours -Dde.griefed.serverpackcreator.home ahead of that fallback, and the build points each test JVM at <module>/build/spc-test-home. PathsConfigTest and ScriptTemplatesConfigTest clear the property per test, since they exist to exercise the layers an explicit override outranks. Verified: all four suites green, and a full api suite run concurrently with a live daemon left it untouched. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>Griefed pushed back on the mis-prefixed-entry case, correctly. Measured against the real manifest: all 5025 entries across 77 Minecraft keys carry their own key as a prefix, and minecraftVersion is always derived from that key, so the wrong-offset case cannot arise. Only the too-short entry (which throws, uncaught) remains, and a length check covers it. The previously suggested fix was also wrong and is now called out: a startsWith("$minecraftVersion-") guard would reject the sole 1.7.10_pre4 key, whose entry carries the raw underscore form while the Minecraft version is reconciled to 1.7.10-pre4 — the exact case the length-based cut handles correctly. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>Backlog B8. All three templates gated the legacy runnable forge.jar against `SEMANTICS[1] -le 16` -- the Minecraft minor component -- which only carries that meaning under the 1.x scheme. Minecraft 26.2 has minor 2, so every Forge boot on current Minecraft took the legacy path and died with `Error: Unable to access jarfile forge.jar` before loading any mod. Measured in the grinder on 2026-07-30: 24 boot logs, every one of them Forge, never started the server. Forge coverage on current Minecraft was effectively zero, and the failures were only harmless because launchFailureMarkers classifies a never-launched JVM as INCONCLUSIVE rather than a false clientside HIGH. The era test now requires major 1 as well, in default_template.sh:202, default_template.fish:242 and default_template.ps1:359. The test executes the real setupForge across both schemes -- 1.12.2, 1.16.5, 1.17.1, 1.20.1, 1.21.1, 26.1.2, 26.2 -- and asserts the era via SERVER_RUN_COMMAND. Confirmed failing on the unmodified templates first ("Minecraft 26.1.2 picked the legacy forge.jar launcher"), which is the pin-first order this class of change keeps slipping on. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>The second instance of the assumption fixed in the previous commit, found by sweeping for siblings rather than waiting for it to surface. NeoForge's first releases -- Minecraft 1.20 and 1.20.1 only -- live under the legacy net/neoforged/forge/ artifact group and install by URL; everything later installs by bare version. The templates tested only `SEMANTICS[1] -eq 20`, so a future Minecraft 26.20 would be sent at a 1.20-era URL that does not exist for it. The major is now part of the test in all three templates. This case is latent -- no such Minecraft has shipped -- so the test documents the rule rather than a live failure. Confirmed failing first against the unmodified templates ("Minecraft 26.20 was sent at the legacy 1.20-era URL"), and an unreachable bug is cheaper to close now than to rediscover when Mojang makes it reachable. With this, both `1.x`-shaped assumptions in the templates are gone. The Kotlin side was surveyed and is clean: minecraftComparator compares component-wise, ImageJavaRuntimes takes required-Java from MinecraftMeta.requiredJavaVersion, and LoaderVersionResolver delegates to the manifests. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>Backlog B12. `forgeVersionFrom` sliced blindly, so an entry with nothing after its Minecraft key threw StringIndexOutOfBoundsException -- which `update()` does not catch (only MalformedURLException and NoSuchElementException), so one malformed entry would have aborted the Forge load for every remaining Minecraft version. It now returns null for such an entry and the caller logs and skips it, so the cost is one version rather than the whole parse. The guard is a length check on purpose: `startsWith("$minecraftVersion-")` would reject the legitimate `1.7.10_pre4` entry, because entries carry the raw manifest key while the Minecraft version may be the reconciled `1.7.10-pre4` form. Verified against the real manifest: of 5025 entries across 77 keys, the guard rejects 0 -- no coverage is lost. The test that pinned the throwing behaviour now pins the rejection, and the TODO is removed. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>Backlog B3. Suite 23 -> 31. Two files were worth covering; the rest of the untested surface deliberately is not, and that judgement is recorded below. MainLayout carries the only real logic left uncovered. Its drawer list is a hand-maintained array that must stay in step with the router -- add a page, forget the entry, and the page is unreachable from the UI with nothing failing -- so the test compares the links against `routes` in both directions rather than against a copy of itself, which would pass forever regardless of what the app routes. It also pins `drawerClick`: the toggle, and the `stopPropagation` call whose absence breaks the mini-drawer (the click bubbles to the drawer, which toggles it straight back) while leaving every other test green. AboutPage has no logic, so its test covers only what can be wrong invisibly: a link that is empty, relative or not https renders as a perfectly normal row, and the only symptom is a user going nowhere. All three guards were verified by breaking them: removing the History nav entry, removing stopPropagation, and dropping `https://` from the Discord link each fail with the intended message. Not covered, on purpose: SubmissionPage, DownloadsPage, HistoryPage and ErrorPage are pure composition (SubmissionPage's only script content is two scrollbar style objects), and the three tables stay untested for the reason recorded in 4e -- trivial format lambdas versus brittle QTable rendering. Testing those would raise the count without raising confidence. Two harness notes for the next person: `routes` statically imports the download pages, so it drags in `boot/axios` and needs the documented `vi.mock('boot/axios')`; and QPage will not render outside a QLayout, so a page test needs it stubbed as a passthrough or the children never mount. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>Backlog B22. RED on purpose -- "Unresolved reference 'resolveSpcPropertiesFile'" -- with the fix following. The mechanism, now traced end to end from the daemon's own startup log: ApiProperties' default is File("serverpackcreator.properties"), a *relative* path, and PropertyStore.loadProperties adds every file it loads to trackedPropertyFiles, which save() then writes to for the rest of the process. The log shows exactly that -- three save targets, one of them the bare relative name -- so a daemon started from a checkout created and rewrote serverpackcreator.properties in the repository root on every start. This corrects the entry's own conclusion. B22 recorded that the working directory was ruled out, citing an lsof check; that was wrong. With the daemon verifiably running from its home the repository stays clean, and the earlier "still happens with an explicit cd" observation cannot be reproduced -- the cd in that launch form had not taken effect for the process being measured. Absoluteness is the property pinned, because that is what caused the defect: whether the path is correct follows from the home, but whether it depends on the caller's working directory is what put files in the repo. Three cases: no override resolves inside the home, an explicit override wins and is made absolute even when given relative, and a blank override is treated as unset. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>Backlog B22; turns the previous commit's guard green. ApiWrapper.api() with no argument uses ApiProperties' relative default, File("serverpackcreator.properties"), and PropertyStore keeps every file it loads in trackedPropertyFiles and writes to all of them on each save. So the daemon created and rewrote a settings file in whatever directory it was launched from -- started from a checkout, that was the repository root, on every start. It now resolves an absolute path: the operator's SPC_GRINDER_SPC_PROPERTIES when set, otherwise serverpackcreator.properties inside the home it already derives work, cache and the verdict store from. Where it is started from stops mattering. Verified the way the defect was observed rather than in the abstract: rebuilt, launched the daemon deliberately *from the repository root*, confirmed its cwd was the repo root, and the tree stayed clean -- with the previous startups' bare relative save target visible in the log for contrast. Guard verified by restoring the relative default: 2 of 3 fail. Also records the landmine, and that the remaining save into build/install/.../lib/serverpackcreator.properties is the dist's own tracked copy -- expected, gitignored, not worth chasing. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>`WritableDirectoryFilter.accept(File)` was never called by anything, and could not have been: FlatLaf's `SystemFileChooser.FileFilter` declares only `getDescription()` (verified with `javap` against flatlaf 3.7.1), so the method overrode nothing — which is exactly why it compiled without an `override` modifier. FlatLaf drives a *native* OS dialog, which cannot call back into Java per file; its only real filters (`FileNameExtensionFilter`, `PatternFilter`) are `final` and purely declarative. Behaviour is unchanged, and the writability restriction is not lost: - All four call sites already validate the selection *after* the dialog returns, via `File.testFileWrite()` plus a `settings_directory_error` dialog (`GlobalSettings.kt:63,97`, `WebserviceSettings.kt:70,92`). That is what has been enforcing the rule all along. - The four choosers set `fileSelectionMode = DIRECTORIES_ONLY`, so a *file* filter has nothing to act on regardless. - Leaving `fileFilter` unset is supported: `SystemFileChooser.getFiltersForDialog` null-checks the field on every read (bytecode offsets 41/56/77), and `isAcceptAllFileFilterUsed` is already false. Also drops `settings.directory.filter` from the three translation bundles, as this class was its only consumer. Found by Qodana (`UnusedSymbol`, WritableDirectoryFilter.kt:8), which reported it as a trivial unused function. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>Four unrelated leftovers, none of which changes behaviour: - `FabricScanner.scan` built a `ScanResult()` into a local and never touched it; the method returns a freshly-built one. The no-arg constructor only allocates two empty lists, so dropping the local is inert. (`UnusedVariable`) - `ProjectFiles.fileNamesForLoader` had no caller anywhere in the repo — and `-clientside` is not published to Maven, so no compatibility claim protects it. (`UnusedSymbol`) - `ContainerCandidateVerifier` declared a logger it never used; the per-candidate INFO line is emitted by `Grinder.grind`. Its now-unused import goes too. (`UnusedSymbol`) - `InclusionsEditor.removeSelectedEntry` wrote `selected++` and then `--selected`, which cancel exactly: the post-increment compares the *old* value, and the pre-decrement restores it before use, so the whole dance was equivalent to plain `selected`. Rewritten as such and given a real doc comment explaining what the selection is meant to do afterwards. (`AssignedValueIsNeverRead`) api, clientside, grinder and app suites green. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>A doc comment immediately followed by another doc comment attaches to nothing: the first block documents no declaration, and the function it was written for silently ends up undocumented. Seven such blocks existed; three had left load-bearing functions bare. - `BootVerifier`: the docs for `outcomeFor` *and* `shouldRecheckCrash` had both drifted above `refuseForMissingDependencies`, stacked three deep. Both are exactly the units the module notes call out — `outcomeFor` is "the verdict seam every runner shares", `shouldRecheckCrash` the guard that stops a stale loader build being published as a HIGH-confidence clientside mod — and both were undocumented at their definitions. - `GrinderApplication`: the doc for `env` sat above `resolveSpcPropertiesFile`. - `Tetris`: four Java-era blocks stranded by the Kotlin conversion, where getter pairs became properties and two constructor overloads became one primary constructor with defaults. `SquareBoard`'s width/height docs move onto the constructor parameters, `Game`'s onto the class-level `@param`s, and the duplicated `level`/`removedLines` blocks are merged. Text is preserved as written; only its attachment changes. This is also why Dokka never caught these — an unattached block yields no declaration to warn about, so it took Qodana's `KDocUnresolvedReference` on the now-dangling `[logFile]`, `[label]`, `[key]` and `[default]` references to surface them. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>Five links that could not resolve, each for a different reason: - `ClientsideReport`: `[renderMarkdown]` lives on `ClientsideReportRenderer`, not on the report, so the unqualified link never resolved. Qualified. - `ContainerEngine`: the *interface* doc linked `[readyPattern]` and `[timeout]`, which are parameters of `run`. Reworded to point at `[run]` and describe them in prose. - `LoaderVersionPolicy`: carried an `@param versionMeta` although an interface has no parameters — it documents `LoaderVersionResolver`'s constructor, where it now sits. - `MigrationInfoItem` / `ThirdPartyNoticesItem`: `[GlobalScope]` is left over from the GlobalScope removal, which took the import with it. Fully qualified rather than re-imported, since the point of the sentence is that the symbol is *not* used here. Verified by Dokka: `Couldn't resolve link` warnings across api, clientside, grinder and app go from 12 to 0. Note that Dokka only checks Public/Protected/Package, so this was confirmed with `documentedVisibilities` temporarily widened to include Private and Internal as well. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>The 2025.1 linter reported 18 `KotlinUnreachableCode` problems in `CurseForgeCandidateSource.kt` that are not real — 39% of its 46 High-severity findings. Root cause measured directly from the images: docker run --rm --entrypoint sh jetbrains/qodana-jvm-community:<tag> \ -c 'cat /opt/idea/plugins/Kotlin/kotlinc/build.txt' 2025.1 -> 2.1.10-release-473 2026.2 -> 2.3.20-release-208 The project builds with Kotlin 2.3.20 (`libs.versions.toml:4`), so the old linter analysed 2.3 source with a 2.1 frontend — two minor versions behind. Every phantom hit sits after a `runCatching {}.getOrElse { …; return }`, whose type parameter the older frontend infers as `Nothing`, making everything below it look dead. 2026.2 bundles exactly 2.3.20. That the findings are phantom was established independently of this bump: `kotlinc` 2.3.20 compiles the module with zero warnings and never emits `UNREACHABLE_CODE`, and `CurseForgeCandidateSourceTest` passes 15 tests that require those exact lines to execute (line 199 for the version axis, 251 for the categories, 307 for every result mapping). 2026.2 is the current stable tag (== `latest`, published 2026-07-27). Cache keys move with it so the 2025.1 caches are not reused across the frontend change. NOT verified by a local full run: Qodana OOM-killed at exit 137 during Gradle import, because this machine's Docker VM is capped at 1.93 GiB (48 GiB host). The image and its bundled compiler version above are measured; the resulting problem count is not. Confirm against the next CI report. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>Lands RED on purpose, per the pin-first rule — the fix follows in the next commit, so `git checkout <this>^^ && ./gradlew :serverpackcreator-api:test` can be used to watch the guard actually bite. `ServerPackHandler.modFileEndings` and `ConfigurationHandler.zipCheck` each hold a literal that `ModListCompiler` / `ModpackZipInspector` also declare, with nothing linking the two. Nothing is broken today because the copies agree — the hazard is that the copy generation *consults* is undocumented while the documented copy is dead, so an edit aimed at the documented one changes nothing at all. The assertions are on **identity**, not value, because a value comparison would pass against a re-introduced duplicate that happens to agree — which is precisely the state being guarded against. Observed failure: ServerPackHandler.modFileEndings must delegate to ModListCompiler, not hold its own copy ==> expected: java.util.Arrays$ArrayList@61f97194<[jar, disabled]> but was: java.util.Arrays$ArrayList@6afc2700<[jar, disabled]> ConfigurationHandler.zipCheck must delegate to ModpackZipInspector, not hold its own copy ==> expected: kotlin.text.Regex@18fb46dd<^\w+[/\\]$> but was: kotlin.text.Regex@3e4a6fd7<^\w+[/\\]$> Two objects, identical contents, no link — the drift hazard stated exactly. Promoting the two owned constants from `private` to public is the enabling change that lets the guard be *expressed* at all; without it the test cannot compile, and a non-compiling commit would be a broken build rather than a red test. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>The top-level `services:` block started a dind container for every job in the pipeline. Most of them bring their own image and never shell out to `docker`: Qodana, the five eclipse-temurin Gradle jobs, Writerside Build, pages, release_job, Generate Release, Qodana Post. Each paid the full service health-check wait for it - 34s in the failing Qodana log of 2026-08-03 (20:19:41 "Waiting for services" -> 20:20:15 timeout), followed by a WARNING block that reads like the job's own failure and is not. Measured by resolving `extends` over both revisions of the file: jobs declaring a dind service before: 20/20 after: 7/20 The seven are the six that call `docker buildx`/`docker run` plus Update README:on-schedule, which runs `act`. Same image, same `docker` alias, same position for all of them, so nothing changes for a job that was using the service. No Gradle job needs a daemon: every Docker-dependent test in the repo is gated behind GRINDER_DOCKER_IT / GRINDER_TEMPLATE_IT / GRINDER_LIVE_IT / GRINDER_CF_IT, none of which CI sets. Note this does not fix dind itself, which never starts: failed to load listeners: can't create unix socket /var/run/docker.sock: device or resource busy Something is already mounted at that path in the service container, which is runner config and not visible from this repo. Since the Docker jobs do work, they must be reaching a daemon another way - the next commit adds a diagnostic to find out which, rather than deleting the service on a guess. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>The dind service never starts ("can't create unix socket /var/run/docker.sock: device or resource busy" - dockerd cannot create its socket because something is already mounted at that path in the service container), yet Docker Test builds and pushes. So it reaches a daemon by some other route, and which one decides whether .dockerized should exist at all. That is runner config, not visible from this repo, so it gets measured rather than guessed at. Reading the output on the next pipeline: DOCKER_HOST unset + a live /var/run/docker.sock -> the jobs use the host daemon; the service is dead weight everywhere and .dockerized plus this block can both go, with the finding recorded. DOCKER_HOST=tcp://docker:2375|2376 -> the service is the intended endpoint and the socket collision is the real bug; keep .dockerized and fix the runner's volume config. Every line ends in `|| true` so the diagnostic cannot fail the job it is diagnosing. Temporary by construction - delete it once the answer is recorded. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>Closes L-1 and L-2 of the claude-ci-qodana-jbr-cache audit, plus a third defect found while verifying the fix for them. L-2: the guard keyed on `bin/java` rather than on the JBR directory, so a restored-but-incomplete tree took the "no cached JBR" branch - skipping the chmod entirely while printing "Qodana will download one", a claim that is only true if Qodana re-downloads on a partial tree, which was never verified. Now it keys on `[ -d "$jbr" ]`: the chmod always runs when the tree exists, and an empty tree says so instead of asserting the opposite. L-1: `find ... | head -n1` probed only the first JBR while `chmod -R` repaired all of them, so with two cached trees the evidence could describe one Qodana never execs. Now every `*/bin/java` is listed and probed. Third defect, introduced by the first attempt at L-1 and caught by testing it: probing every tree with a hard `-version` made a stale or truncated tree fail the pipeline even though Qodana would happily use the good one (measured: a tree with only bin/java exits 127 on "libjli.so: cannot open shared object file" and took the whole block down). EACCES is the only failure this guard owns, so the probe now classifies: "permission denied" fails the job, anything else warns. The intermediate attempt asserted `[ -x "$java_bin" ]` instead. That was unsound and its comment overclaimed, which a measurement disproved: on a Docker Desktop bind mount a host-side 0644 file is REPORTED as -rwxr-xr-x inside the container and `[ -x ]` answers "executable", while execve still fails with EACCES. host: -rw-r--r-- /probe/java container: ls -l -> -rwxr-xr-x ; [ -x ] -> executable ; exec -> Permission denied So neither `ls` nor a `-x` test can prove the bit took - only running it can. That also explains a discrepancy in the earlier session's evidence: previously mounted paths report a stale 0755 while freshly created ones report the truth, which is attribute caching in the mount, not a flaw in the reproduction (the exec result was faithful throughout). Verified by extracting the block straight out of this file (so the test runs the committed code, not a paraphrase) and driving all five paths as root under `set -eo pipefail`: good JBR + truncated JBR version prints, truncated one WARNs exit 0 exec refused (EACCES) "still cannot be executed" exit 1 tree exists, no bin/java "restore was incomplete" WARNing exit 0 no tree at all "Qodana will download one" exit 0 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>A user reports the mongodb container being unreachable from the SPC container, with SPC apparently connecting to localhost. Read-only investigation; no fix yet, because the decisive fact - what Spring Boot 4 does with an unparseable spring.data.mongodb.uri - is not established and would decide between two different fixes. Established by reading: the override chain is wired correctly. The s6 init service composes SPC_DATABASE_* into spring.data.mongodb.uri in overrides.properties, and WebService passes it last in --spring.config.location, where later locations win. So precedence is not the bug. Also: nothing connects to Mongo via apiProperties.databaseUri - its only readers are the Swing settings panel and a log line - so the live connection comes from Spring's own property and any theory about SPC's settings file alone does not explain the symptom. Leading hypothesis is the all-or-nothing env check in init-spc-config/run: if any ONE of the five SPC_DATABASE_* vars is unset it writes the degenerate "spring.data.mongodb.uri=mongodb\:", and docker-compose.yml ships the username and password as <PLACEHOLDER>s that a user running Mongo without auth would reasonably delete. WebserviceConfig's sanity check misses it, because "mongodb:" satisfies startsWith("mongodb"). Four bugs found on the way that need no reproduction and are filed for their own commits: WebService.kt:52 overwrites the user's last CLI argument (in the container, the value of --home); SPC_LOG_LEVEL has never worked, because the script writes the literal "{SPC_LOG_LEVEL}" with no $; the two zip-exclude overrides wrap their value in literal braces; and docker-compose.yml disagrees with itself on the database name (serverpackcreator vs serverpackcreatordb, with README.md:282 siding with the latter). Also recorded: the s6 script is a pure function from environment to overrides.properties and can be executed in the built image with a controlled environment, which would have caught three of these silently-failing bugs. That harness is the first thing to build, per the shell-template rule in CLAUDE.md. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>Committed red on purpose. CLAUDE.md requires the guard for silently-failing shell to land in its own commit, failing, before the fix: check out this commit and `docker/tests/init-spc-config-test.sh` reports 3 passed / 7 failed. init-spc-config/run is a pure function from environment to /app/serverpackcreator/overrides.properties, and every one of its bugs produces a plausible-looking file rather than an error - which is why three of them shipped unnoticed. The harness executes the REAL script, unmodified and mounted read-only, inside the same base image the production Dockerfile uses (ghcr.io/linuxserver/baseimage-ubuntu:noble). That image ships `with-contenv` and `lsiown`, so nothing is stubbed and no copy of the script is edited to make it testable - the unit under test is the file that ships. Observed failures, with the script's actual output: spring.data.mongodb.uri=mongodb\: (D1) de.griefed.serverpackcreator.loglevel={SPC_LOG_LEVEL} (D2) ...serverpack.zip.exclude={server.jar,other.jar} (D3) ...serverpack.zip.exclude.enabled={true} (D3) Passing already, and asserted so the fixes cannot regress them: the fully authenticated URI when all five SPC_DATABASE_* are set, and the two unconditional lines (server.tomcat.basedir, de.griefed.serverpackcreator.home). Case 2 encodes the Spring Boot 4 finding that makes D1 a hard failure rather than a fallback: PropertiesMongoConnectionDetails.getConnectionString() hands any non-null spring.data.mongodb.uri straight to com.mongodb.ConnectionString, which rejects anything not prefixed mongodb:// or mongodb+srv://. Measured with javap against spring-boot-mongodb-4.0.2 and mongodb-driver-core-5.6.2. Deliberately not wired into Gradle: the script sits outside every module and needs Docker, so a Gradle -> Docker dependency would cost more than it returns. Run it by hand or from CI. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>The env check was all-or-nothing across five variables: if any one of SPC_DATABASE_{HOST,PORT,DB,USERNAME,PASSWORD} was unset, the good branch never ran and overrides.properties got spring.data.mongodb.uri=mongodb\: Measured against the pinned artifacts, that is fatal rather than degraded. PropertiesMongoConnectionDetails.getConnectionString() in spring-boot-mongodb-4.0.2 does, per javap: if (properties.getUri() != null) return new ConnectionString(properties.getUri()); - verbatim, unvalidated, with no fallback to host/port. ConnectionString in mongodb-driver-core-5.6.2 accepts only MONGODB_PREFIX "mongodb://" or MONGODB_SRV_PREFIX "mongodb+srv://" and otherwise throws "The connection string is invalid. Connection strings must start with either '%s' or '%s'". So SPC did not fall back to localhost here - it failed to start. Who hits it: docker-compose.yml ships SPC_DATABASE_USERNAME=<DB_USERNAME> and _PASSWORD=<DB_PASSWORD> as placeholders. A user running an auth-less MongoDB deletes those two lines, which is entirely reasonable, and silently gets a URI that cannot parse. WebserviceConfig's sanity check does not catch it either, because "mongodb:" satisfies startsWith("mongodb") (WebserviceConfig.kt:93). Host, port and database are now the required trio and the credentials are optional, contributing the user:pass@ segment only when both are present. When the trio is incomplete the property is left unset entirely rather than written broken - a missing property still boots on Spring's default, a malformed one cannot boot at all. docker/tests/init-spc-config-test.sh: 3 passed / 7 failed -> 5 passed / 5 failed. Cases 1 and 2 are now green (authenticated URI unchanged, auth-less URI is parseable); the 5 remaining failures are D2/D3 and are fixed in the next commits. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>The override was written with a brace expression missing its `$`: echo "de.griefed.serverpackcreator.loglevel={SPC_LOG_LEVEL}" so overrides.properties received the literal string `{SPC_LOG_LEVEL}` and the log level was whatever the properties file already said. The variable has never worked, while docker-compose.yml has advertised SPC_LOG_LEVEL=INFO the whole time - a documented knob that silently did nothing, which is exactly the failure mode the new harness exists to catch. docker/tests/init-spc-config-test.sh: 5 passed / 5 failed -> 7 passed / 3 failed. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>Both lines expanded their variable and then braced the result: ...serverpack.zip.exclude={$SPC_SERVERPACK_ZIP_EXCLUDE} ...serverpack.zip.exclude.enabled={$SPC_SERVERPACK_ZIP_EXCLUDE_ENABLED} so overrides.properties got `exclude={server.jar,other.jar}` and `enabled={true}`. Bare values are what the format wants - the shipped docker/root/defaults/serverpackcreator.properties writes the same two keys without braces - so the brace form fed a stray `{`/`}` into the exclusion list and into a boolean. docker/tests/init-spc-config-test.sh: 7 passed / 3 failed -> **10 passed / 0 failed**, so the whole file is now pinned green, three commits after the guard landed red at 3/7. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>Both compose files told SPC to use a database, and the dev file a username, that does not exist after initialisation. init-mongo.js creates exactly one user - from MONGO_INITDB_ROOT_USERNAME / _PASSWORD - inside MONGO_INITDB_DATABASE, with readWrite on that database only. A connection string's path segment doubles as its authSource, so SPC must be handed the same database name, and the same credentials, or authentication fails against a database where that user was never created. SPC_DATABASE_DB MONGO_INITDB_DATABASE docker-compose.yml serverpackcreator serverpackcreatordb mismatch docker-compose-dev.yml serverpackcreator serverpackcreatordb mismatch and in the dev file SPC_DATABASE_USERNAME=serverpackcreator while MONGO_INITDB_ROOT_USERNAME=root, so the credentials named a user that is never created either. README.md:282 already documented serverpackcreatordb, and init-mongo.js agrees with the README - the compose files were the odd ones out. Also renamed docker-compose.yml's placeholders from <DB_USERNAME>/<DB_PASSWORD> to <DB_ROOT_USERNAME>/<DB_ROOT_PASSWORD>, matching README.md:278-279, because they are not free-form: they have to be the root credentials the db service is initialised with. The comment now says both may be omitted for an auth-less MongoDB, which the previous commit made a working configuration instead of a startup failure. This produces an auth error rather than the reported localhost connection, so it is not confirmed to be the user's bug - but it is broken for anyone who copies the shipped compose file verbatim. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>Measured before: $ docker compose -f docker-compose-dev.yml config service "serverpackcreator-mongo-express" depends on undefined service "mongodb": invalid compose project and after: `valid`. (docker-compose.yml validated clean throughout.) mongo-express depended on a service named `mongodb`; there is no such service - the database is `serverpackcreatordb`, which the serverpackcreator service already depends on correctly. Compose validates depends_on against declared service names, so this was not a degraded stack: the dev file could not come up at all, which is also why it could not be used to reproduce the MongoDB report. Found while looking for a way to verify the URI fixes end-to-end. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>Committed red: `:serverpackcreator-app:test --tests "*WebServiceArgumentsTest*"` reports 4 tests completed, 2 failed at this commit, with the drop visible in the failure message: The --home value was dropped from the arguments handed to Spring: [-web, --home, --spring.config.location=optional:file:./overrides.properties] Expected the argument and the config-location ==> expected: <2> but was: <1> The container invokes `-web --home /app/serverpackcreator` (svc-spc/run), so the argument being overwritten is the value of --home. The one-argument case is pinned separately because that is where overwriting the last element and appending produce arrays of the same length but different content, which is how this survived review. Two assertions pass already and are kept as regression guards: the config-location argument is present whatever else is passed, and an empty argument array yields exactly that one argument. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>springArguments overwrote the last element of the argument array with the --spring.config.location argument instead of appending it: temp[temp.lastIndex] = configLocationArgument The container starts ServerPackCreator as `-web --home "/app/serverpackcreator"` (docker/root/.../svc-spc/run), so what got overwritten was the *value* of --home: Spring received `[-web, --home, --spring.config.location=…]` and the home directory argument was gone. Any deployment whose last argument carries a value loses it. Now `args + configLocationArgument`, which also removes the double toList()/toTypedArray() round-trip and the empty-array special case - appending to an empty array already yields the one-element array the old branch built by hand. WebServiceArgumentsTest goes 2 failed -> 4 passed. Full :serverpackcreator-app:test green at 80 tests, so nothing depended on the truncation. Not the reported MongoDB bug: the config-location string is built before this from the already-resolved ApiWrapper, so the paths inside it were always correct. It is a separate defect found while tracing how the override file reaches Spring. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>The open question from the investigation plan - does Spring Boot 4 fall back to host/port when spring.data.mongodb.uri is unparseable, or fail fast - is answered, measured with javap against the pinned spring-boot-mongodb-4.0.2 and mongodb-driver-core-5.6.2 rather than read from the docs: if (properties.getUri() != null) return new ConnectionString(properties.getUri()); Verbatim, unvalidated, no fallback. ConnectionString accepts only mongodb:// or mongodb+srv://. The literal "localhost" lives only in the unreachable else-branch, as does MongoProperties.DEFAULT_URI = "mongodb://localhost/test"; determineUri() returns uri ?: DEFAULT_URI but the connection path never calls it. This reverses the ranking. The degenerate "mongodb:" URI (D1) CANNOT produce a localhost connection - it fails fast before a socket is opened - so it is demoted from leading explanation to proven bug with the wrong symptom, and the previously second-ranked "the property never reaches Spring at all" becomes the only mechanism that can produce the reported wording. The doc now leads with a triage table: the reporter's exception type alone identifies which of the three it is. Six defects are recorded, all verified, and the two Boot-4 traps that make this class of bug hard to see are added to serverpackcreator-app/CLAUDE.md as a landmine: a uri silently overrides host/port/username/password, and a log line naming localhost:27017 means the property was absent everywhere rather than wrong. The user's own bug stays open. Nothing found is confirmed to be theirs, which the doc says plainly - it needs their overrides.properties line and their exception. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>Dependency extraction had no assertions anywhere: the fixture tests state which jars come out clientside, never why, and never what a scan recorded as a dependency. Three rules are pinned here. - Fabric/Quilt: declared dependencies are recorded, the platform (loader, java, minecraft) is not. fabric-api-base is included deliberately - the exclusion regex is an exact match, not a prefix, so a real mod whose id merely starts with "fabric" must survive it. - Quilt: a quilt_loader.depends entry is either a bare string or an object carrying an id. Both forms are read; the fixture only exercises one. - Forge: a mod declares no sideness of its own. Its side is inferred from the "side" it demands of the platform dependency (minecraft/forge/neoforge), and non-platform dependencies are recorded instead of consumed. This is the least obvious rule in the scanners and had no direct test. These are new characterization tests, so unlike the three preceding commits there is no prior revision to observe them red against - they pin behaviour that is already correct. To confirm they discriminate rather than pass vacuously, the Fabric exclusion regex was mutated from an exact match to a prefix: "(fabric|fabricloader|java|minecraft)" -> "(fabric.*|java|minecraft)" fabricDependenciesAreRecordedWithoutThePlatform FAILED which is exactly the silent-loosening the exact-match assertion exists to catch. Reverted immediately; no production file is changed by this commit. Signed-off-by: Griefed <griefed@griefed.de> Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>This commit is deliberately red. The fix follows in the next one, so that the pin can be checked out and watched to fail rather than taken on trust. The scanner-selection `when` has an arm per supported loader and no `else`, and since the modscan rewrite the include-list is built solely from what a scanner returned. A loader string matching no arm therefore leaves nothing scanned and compileModList returns two EMPTY lists - a server pack with no mods and no warning. Before the rewrite the list was seeded with every file and exclusions were removed from it, so the same input returned every jar. Reachable without an embedder doing anything exotic: PackConfig.modloader's setter silently ignores a value it does not recognise, leaving the field at its initial empty string, and that empty string reaches this `when`. An over-full pack is something a user can fix; a silently empty one looks like the tool did nothing at all. unrecognisedModloaderStillYieldsEveryMod FAILED "An unrecognised modloader must fall back to including every mod, not to an empty pack ==> expected: <[alpha.jar, beta.jar, gamma.jar]> but was: <[]>" Signed-off-by: Griefed <griefed@griefed.de> Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>This commit is deliberately red; the fix follows in the next one. Excluding a mod that something else depends on produces a pack that installs and then dies on load, which is worse than shipping one mod too many - so a dependency has to win over a clientside verdict. The rescue could never do that: it additionally required the DISABLED mod to be Sideness.SERVER, but a mod auto-disabled by a scanner is CLIENT by construction, so the protection never reached the population it was written for. Two cases, both against real jars built in a @TempDir: - aClientsideModDependedOnByAServerModIsRescued: servermod (environment "*") depends on clientlib (environment "client"). clientlib must be kept. - theDependencyRescueFollowsAChain: servermod -> midlib -> deeplib, the middle and leaf both clientside. This is what the surrounding `while` loop is for - rescuing one mod puts its own dependencies in play. A single pass would keep deeplib excluded and still look like it had worked. aClientsideModDependedOnByAServerModIsRescued FAILED "A clientside mod that a server mod depends on must be kept; included=[servermod.jar] ==> expected: <true> but was: <false>" theDependencyRescueFollowsAChain FAILED "The whole dependency chain must be rescued, not just its first link ==> expected: <[servermod.jar, midlib.jar, deeplib.jar]> but was: <[servermod.jar]>" Signed-off-by: Griefed <griefed@griefed.de> Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>Behaviour-preserving: the loop could not execute. for (fabric in fabricScan) { if (quiltScan.find { quilt -> quilt.file.name == fabric.file.name } == null) { ... } } Both scanners return exactly one entry per input file whatever the outcome - each scan loop's catch adds a bare ScannedMod - and both are called with the same filesInModsDir, so every fabric.file.name is always present in quiltScan and the `find` never returns null. Confirmed before removal: the log line fires 0 times across the fixture. It became dead when7004f3c88re-keyed the join from modID to file.name. Under the old key it did fire, and that firing is precisely what produced the duplicate entries that commit set out to stop - so what looked like a fallback was the defect's delivery mechanism. The case it appears to handle - a fabric-only jar in a Quilt pack - is covered by the sideness merge above it, pinned in the previous commit by theQuiltArmTakesTheFabricVerdictForAFabricOnlyJar. Also folds `find(...); if (match == null) continue` into an elvis-continue, and states the merge rule in a comment: CLIENT wins, because the scan that could not read a jar falls back to SERVER, so SERVER only carries meaning when it comes from a descriptor actually read. Suites unchanged and green: api 295 tests / 1 skip, app 80. No existing assertion was touched. Signed-off-by: Griefed <griefed@griefed.de> Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>The four-branch `when (exclusionFilter)` was written out three times in compileModList - once for the clientside-mod list and twice for the whitelist, in the `while` guard and again in the `removeIf` it guards. Copies had already drifted apart in formatting. Now one matchesFilter(modName, entry). Also collapses the whitelist `while (any { ... }) { removeIf { ... } }` to a single removeIf. Behaviour-preserving: removeIf visits every element once and removes all matches, and the predicate reads only the mod and the whitelist, so nothing a removal does can make a remaining entry start matching - the guard was false on the second evaluation, always. The comment now says why the dependency rescue below genuinely does need its loop: each rescue adds to serverMods and so puts further dependencies in play. And folds the exclusion search into `clientsideModsList.find { ... }`, dropping the accumulator pair (`foundExclusionMatch` + `exclusionMatch = "N/A"`) whose sentinel could never be read - it was only logged on the branch where a match had been found. Suites unchanged and green: api 295 tests / 1 skip, app 80. No existing assertion was touched. Signed-off-by: Griefed <griefed@griefed.de> Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>The two enums look like duplicates of one idea - they had the same name until the previous commit - so the question "should these be merged into -api?" will be asked again. This answers it once, next to the code. They model different things, and the confidence model depends on the difference: api.modscanning.Sideness SERVER/CLIENT - SPC's own verdict, one value = the whole answer, defaults to SERVER so nothing is dropped from a pack clientside.DeclaredSupport REQUIRED/OPTIONAL/UNSUPPORTED/UNKNOWN - a third party's self-report about ONE side, read as a pair, defaults to UNKNOWN (all of CurseForge) ClientsideVerifier.aggregate folds DeclaredSupport, JarScan (where the API's verdict arrives) and BootResult into a Confidence precisely BECAUSE the platform's claim is unreliable - which is the entire reason the expensive boot-test exists. Merging the enums would collapse the distinction the model is built on, and would push Modrinth's field vocabulary into -api, which is published to Maven and is a plugin-compatibility constraint. Signed-off-by: Griefed <griefed@griefed.de> Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>Measured, not estimated: api 295 (1 skip) table said 295 ok clientside 87 table said 87 ok app 80 table said 76 STALE -> 80 plugin-example 3 table said 3 ok web-frontend 31 (14 files) table said 23 STALE -> 31 grinder 233 (19 skip) table said 233 ok Two of the six had drifted, not one. The web-frontend figure is also repeated in the Current phase paragraph ("suite at 23"), which is corrected with it - that second copy is why the number went stale unnoticed. JVM counts from the JUnit XML under each module's build/test-results/test; web-frontend from `npm test` (vitest run). Signed-off-by: Griefed <griefed@griefed.de> Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>check33a636177f`./gradlew build` compiled and bundled the SPA but never ran a single frontend test. The org.siouan plugin SKIPs checkFrontend when no checkScript is configured, and only assembleScript was set — so the 31 Vitest tests existed, passed, and were reachable only by remembering to run `npm test` by hand. Verified by measurement, per the build-logic convention (buildSrc has no test source set, so this is the standard rather than a pin). BEFORE — `./gradlew :serverpackcreator-web-frontend:check --rerun-tasks`: > Task :serverpackcreator-web-frontend:checkFrontend SKIPPED > Task :serverpackcreator-web-frontend:check UP-TO-DATE 14 actionable tasks: 14 executed vitest mentions in build output: 0 AFTER — same command: > Task :serverpackcreator-web-frontend:checkFrontend [checkFrontend] Running ... [npm] run test Test Files 14 passed (14) Tests 31 passed (31) 15 actionable tasks: 15 executed And in the full `./gradlew build`: checkFrontend executes, 31/31 pass, 97 actionable tasks (was 96), BUILD SUCCESSFUL in 3m22s. A wired task that does not propagate failure would be worse than none, so that was checked too rather than assumed: injecting a failing test into AboutPage.test.ts produced Tests 1 failed | 31 passed (32) FAILURE: Execution failed for task ':serverpackcreator-web-frontend:checkFrontend' The probe was reverted immediately; only the conventions file and CLAUDE.md change here. Signed-off-by: Griefed <griefed@griefed.de> Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>Both pins are RED at this commit — the fix follows in the next one. Forge's scanner is chosen by Minecraft era, and both call-sites make that choice from the minor component alone: ModListCompiler.kt:146 mcVersions[1].toInt() > 12 MetadataScanner.kt:87 minecraftVersion.split(".")[1] > 12 Minecraft has two versioning schemes (1.x.y and the newer YY.x.y), so 26.2's minor is 2, which reads as the 1.2 era and sends a modern pack to ForgeAnnotationScanner — the scanner for 1.12-and-older. This is the exact pattern serverpackcreator-api/CLAUDE.md's versioning-scheme landmine forbids. The failure is silent: the annotation scanner finds no fml_cache_annotation.json in a modern jar, every jar falls back to the never-drop-a-jar default of SERVER, and auto-exclusion quietly stops working on Forge 26.x while logging one ERROR per mod. It fails safe (everything is included), which is why it went unnoticed. Both tests loop over 1.20.1 and 26.2 against a real jar carrying a modern META-INF/mods.toml, and assert the outcome rather than which scanner was picked: ModListCompilerTest the CLIENT-declaring jar must be auto-excluded, the BOTH-declaring one kept MetadataScannerTest CLIENT vs SERVER_OR_BOTH Observed failing for the right reason — the 1.20.1 iteration passes in both, so the fixtures are valid and only the era selection is wrong: ModListCompilerTest Minecraft 26.2: the CLIENT-declaring mods.toml jar must be auto-excluded ==> expected: <[clientonly.jar]> but was: <[]> MetadataScannerTest Minecraft 26.2: a CLIENT-declaring mods.toml must be read as CLIENT ==> expected: <CLIENT> but was: <SERVER_OR_BOTH> The existing autoDiscoveryReachesScannerBranchPerLoader only covers 1.12.2 and 1.16.5, so nothing pinned the newer scheme. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>Behaviour-preserving. Every existing assertion is untouched and green; the only test-file change is none at all. The package had five scanners that each carried their own copy of the same loop, an abstraction nobody outside -api could see, and a loader->scanner dispatch duplicated across a module boundary. Four extractions: 1. ModJarScanner (public) replaces the internal Scanner<T, U>. Both type parameters had exactly one instantiation across all five implementations (List<ScannedMod>, Collection<File>) — unused generality. More to the point, `internal` meant -clientside and -grinder could not see it, which is why MetadataScanner hand-wrote dispatch over concrete types. Removing an internal type is not an API break; adding the public one lets a plugin implement a scanner for the first time. 2. DescriptorScanner owns the walk-the-jars loop and the "one ScannedMod per input jar, whatever happened" contract that all five repeated. Subclasses now implement read(File) for a single jar and may simply throw — which is why scan() is final. That total-result contract is the one thing no scanner may get wrong (a dropped entry is a mod missing from the finished pack), and it can no longer drift between implementations. 3. FabricFamilyScanner absorbs what Fabric and Quilt genuinely share: id and environment reading, differing only in the path to those fields, including the subtle "no environment entry means SERVER" default. Dependencies stay abstract — Fabric declares an object keyed by mod id, Quilt an array of either objects or bare strings, so the block's shape differs, not its path. 4. ModScanner.scannerFor(modloader, minecraftVersion) is now the single dispatch, consumed by both ModListCompiler and MetadataScanner. The era rules and their magic versions live in one place, and QuiltPackScanner holds the two-descriptor merge that only ModListCompiler used to implement — clientside CLAUDE.md's "kept in sync deliberately, it is not shared code" is obsolete. Returning null for an unknown loader keeps both callers' existing handling. Quilt merge equivalence, since the two callers differed: ModListCompiler kept the Quilt entry unless Quilt said SERVER and Fabric said CLIENT; MetadataScanner unioned the two clientside sets. Both yield CLIENT iff either scanner did, so QuiltPackScanner reproduces ModListCompiler's rule exactly and MetadataScanner's answer is unchanged. The difference the union lost — which ScannedMod (and so which id and dependency list) survives — is preserved, and it matters for the downstream dependency-rescue. API compatibility: JsonBasedScanner is published, so it does NOT gain the abstract read() — a plugin subclass compiled against it must keep compiling. It stays a standalone @Deprecated(ReplaceWith("JsonDescriptorScanner")) helper delegating to the same internal readJarJson the new base uses, so the facade cannot drift from its replacement. Every other public member is unchanged: QuiltScanner.dependencyExclusions, ForgeTomlScanner.neoForgeMinecraft/client and ForgeAnnotationScanner.dependencyCheck/dependencyReplace stay public, and ForgeTomlScanner stays open for NeoForgeTomlScanner. Also removes the Qodana UnusedSymbol (JsonBasedScanner's never-read `log`), the same in ForgeTomlScanner once its catch moved to the base, and a dead NullPointerException catch in FabricScanner around a ModDependency construction that cannot throw. Two logging changes, cosmetic and stated rather than hidden: the per-jar failure is now logged from the base, so ForgeAnnotationScanner's variant that passed the exception (stack trace) uses the message form the other four already used; and ModListCompiler's two NeoForge "Scanning using X scanner." debug lines are gone with the branch that emitted them. Code lines, comments and blanks stripped: ModListCompiler.kt 169 -> 131 MetadataScanner.kt 43 -> 21 modscanning package 523 -> 527 (three new files carrying the shared contract) api 296 (1 skip), clientside 88, app 80, grinder 233 — all suites green. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>ModScanner.scannerFor is now the single place a modloader and Minecraft version become the scanner that reads a pack, for both a generation and the clientside engine's metadata signal. Until now it was only covered by outcome — which mod ended up excluded — so nothing named the collaborator that should have been picked. Six tests, asserted on identity because what matters is *which* scanner comes back: - Fabric and LegacyFabric always get the Fabric scanner - Quilt gets the composite, not either scanner alone - the Forge era boundary (1.13) and the NeoForge one (1.20.5) are each read from the whole version, across both the 1.x.y and YY.x.y schemes - an unparseable version falls back to the modern Forge scanner and does not throw — "26" used to raise IndexOutOfBoundsException out of the bare- component parsing, so this pins the runCatching guard as load-bearing - an unrecognised loader yields null, the signal both callers turn into "keep every mod" These are green on arrival; the red-first evidence for the era rule itself is commitf8cb89bff, which failed on 26.2 before the fix. What they add is a pin on the dispatch as such, so a future change to the selection cannot pass merely because the fixture jars happened to be unreadable either way. api 302 (1 skip) — suite green. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>VersionChecker had zero tests, and the next commit restructures the boolean chains Qodana flagged inside it. Version comparison is this project's documented silent-failure category — a wrong branch yields a plausible version, not an error — so it gets pinned before it gets touched. The class is abstract and its only data source is allVersions(), so the entire alpha/beta path runs offline against a canned list: no repository, no network. Seven tests over the protected isUpdateAvailable. Writing them surfaced a quirk, now pinned as characterization rather than "fixed" — isPreReleaseNewer compares only the number after the dot and is blind to the channel, while isUpdateAvailable consults beta before alpha: 3.1.0-alpha.2 -> 3.1.0-beta.3 (offered a beta, because 3 > 2) 3.1.0-alpha.5 -> up_to_date (offered nothing, though alpha.5 and beta.3 are both published: 3 > 5 fails for the beta, 5 > 5 for its own channel) Offering a beta to an alpha user is arguably right, but that is not what decides it — the numeric accident is. Pinned so the restructuring cannot change it silently; whether to change it deliberately is a separate call, and not one this branch makes. app 81 (from 80) — green. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>Qodana RedundantIf, 13 sites across api, app and grinder. Behaviour-preserving; all existing assertions untouched and green, and VersionChecker gained its characterization pin in the previous commit before being touched. Straight `if (c) true else x` -> `c || x` collapses: ConfigurationHandler.checkIconAndProperties, FileUtilities.isLink, InclusionsEditor.canImport, LoaderCache's idle check, ConfigEditorViewModel.hasUnsavedChanges, VersionChecker's alpha/beta checks, MigrationManager.older/newer. Two became `when` instead, because collapsing them would have changed meaning or lost it: - BooleanUtilities.convert: the recognised-false branch and the fallback both yield false, but only the fallback warns. A plain `||` would have fired the "couldn't parse" warning on every valid "false"/"0"/"no" — Qodana's suggestion is naive about the side effect. The `when` keeps the branches distinct and now says why in a comment. - JsonUtilities.getNestedBoolean: kept three-way with the throw. Note for the next reader, added as a comment: toBooleanStrictOrNull() is NOT a replacement here, it is case-sensitive and this accepts "True"/"FALSE". ConfigEditor.checkJava was inverted to a guard clause rather than folded into a `||`, since its other branch is a 25-line JOptionPane `when`. Swept up in the same files: ConfigEditorViewModel.requiredJavaVersion becomes Optional.orElse("?") (both directions already pinned by ConfigEditorViewModelTest), and FileUtilities.isLink's unused `ex` becomes `_` with a note on why the InvalidPathException is intentionally swallowed. api 302 (1 skip), app 81, grinder 233 — green. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>Written against the CURRENT (double-lookup) implementation and committed before the commit that changes it, so they are characterization tests rather than a description of the new code. Both services had zero coverage. RunConfigurationControllerTest and EventControllerTest exist but `mockk()` the services away, so the look-up-or- store loops in them were never executed by any test — and the persistence layer is MongoDB, where a save-on-absent path is not something you want to reason about from the source alone. 13 tests over the two: EventService.submit a known error is replaced by the stored entry; an unknown one is saved and the saved entry kept; a mixed batch keeps input order; no errors and an empty list both leave the error repository untouched RunConfigurationService .createRunConfig start args split on whitespace, mods on commas; known entries reused, unknown ones saved; blank inputs fall back to Aikar's flags and the configured mod-lists; an existing run configuration is returned instead of a duplicate being saved What is pinned is the OUTCOME — which entries the built object holds, and which reach `save`. Deliberately NOT how many times each repository is queried: the lookup count is an implementation detail, and pinning it would both make these red for the next commit and turn any future change of that shape into a failure for no reason. Verified passing against this tree, i.e. against the two-lookup code they characterize. The next commit halves those lookups and must leave all 13 green. app 88 -> 101. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>Behaviour-preserving. All suites green. ReplaceManualRangeWithIndicesCalls (4): `for (i in 0 until x.size)` -> `for (i in x.indices)` in EventService and RunConfigurationService's three loops. All four bodies assign through the index, so `indices` is the fit rather than withIndex(). Swept up while in those same four loops, because it is the same line: each called its repository's finder TWICE per element — once for isPresent, once for get(). Now one lookup reused via orElseGet, halving the queries on every run-configuration save and every event with errors. Same result, and orElseGet keeps save() lazy so it still only runs when nothing was found. ConvertTwoComparisonsToRangeCheck (6): Tetris bounds checks become `x !in 0 until boardWidth`, matching the `0 until boardWidth` already in the file one line below the first of them. MayBeConstant (1): CurseForgePartition.CAP is `const` — it aliases CurseForgeCandidateSource.MAX_INDEX, itself a const. Two findings are NOT applied, deliberately: - UsePropertyAccessSyntax, LarsonScanner:1368. `g2d.renderingHints = ...` does not compile: Graphics2D's getter returns RenderingHints while the setter takes Map, so Kotlin exposes the property read-only. Tried it, the build failed, and the call now carries a comment so the next reader does not repeat it. - DestructuringDeclaration (3), ClientsideReportRenderer x2 and Grinder. All three are `for (verdict in report.perLoader)` over LoaderVerdict, a data class with eight-plus fields. Positional destructuring there costs every speaking name the loop bodies rely on, and componentN is positional — a reordered property would silently rebind every variable rather than fail to compile. That is precisely the silent-failure class this codebase guards against, so the named receiver stays. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>Qodana CanConvertToMultiDollarString (8) and CanUnescapeDollarLiteral (5), which overlap on the same five literals. Kotlin is 2.3.20, so the $$ prefix is stable and needs no opt-in. Behaviour-preserving; every literal must come out byte-identical, and each is verified rather than assumed. Three Spring cron placeholders (DatabaseCleanupSchedule, FileCleanupSchedule, VersionRefreshSchedule): "\${...}" -> $$"${...}". No test loads the scheduling context, so these were verified by measurement — javap on the compiled classes shows the constant-pool entry is still the placeholder with its braces: #106 = Utf8 ${de.griefed.serverpackcreator.spring.schedules.database.cleanup} BootLogClassifier's outOfMemoryMarkers: the `Killed "$JAVA"` alternative loses a layer of backslashes. Already pinned — aKilledServerIsInconclusiveNotCrashed feeds exactly that line and is green, which matters because this regex is what stops host memory pressure from manufacturing a HIGH-confidence clientside verdict. MigrationManager's lambda-suffix regex needed a seam before it could be touched at all: it was written out TWICE, in migration discovery and in version parsing, in two escaping-heavy copies with no coverage — and version parsing is this project's documented silent-failure category. So it is hoisted to one documented internal constant, LAMBDA_SUFFIX, converted there, and pinned by theLambdaSuffixIsStrippedFromMethodNames (the compiler's $0lambda$1, the bare $lambda$, multi-digit, plus the names that must survive untouched). The extraction and its test land together because the constant IS what makes the regex reachable from a test — the same enabling-change carve-out CLAUDE.md records for per-parameter KDoc on single-line constructors. Teeth checked, not assumed: with LAMBDA_SUFFIX broken to "[0-9]*lambda[0-9]*" the pin fails with `expected: <SixDotZeroDotZero> but was: <SixDotZeroDotZero$$1>`, then passes again on restore. app 82, clientside 88 — all suites green. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>The last 7 Qodana High findings. All were a deprecated member being used exactly where it still has to be, so the fix is to say so, not to change the code — `@Suppress("DEPRECATION")` on the three declarations, each with a doc comment giving the reason. ScriptTemplatesConfig.scriptTemplates (2 warnings) Calls the equally-deprecated defaultScriptTemplates() because both belong to the pre-6.0.0 flat-list representation, and this accessor exists to keep answering in THAT representation. Delegating to startScriptTemplates instead would change what it returns, which is the one thing a deprecated facade must not do. MigrationManager.FivePointZeroPointZero (3) and SixPointZeroPointZero (2) Migrating an old installation means touching the representation that old version wrote. Pointing these at the replacement would migrate the wrong setting — for SixPointZeroPointZero the deprecated flat list IS the input it converts into the per-type map. Also moved scriptTemplates' KDoc above its @Deprecated annotation. It sat between the annotation and the declaration, which is legal but leaves the property undocumented as far as dokka is concerned. Verified rather than assumed: `grep -c 'is deprecated. Deprecated as of 6.0.0'` over a --rerun-tasks compile of both modules goes 7 -> 0. The deprecation warnings that remain are third-party Java ones Qodana did not flag (nightconfig valueMap, Jackson fields, java.util.Locale(String)) and are untouched. Full build green. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>Both RED at this commit — the fix follows in the next one. These replace the characterization test that recorded the first defect as a quirk (73e16ba9c), now that the decision to fix it has been made. 1. anAlphaIsOfferedTheBetaOfTheSameVersionWhateverTheNumbers isPreReleaseNewer compares only the number after the dot and is blind to the channel, so what an alpha user is offered depends on a numeric accident: alpha.2 gets beta.3 (3 > 2), alpha.5 gets nothing (3 > 5 fails for the beta, 5 > 5 for its own channel) with both beta.3 and alpha.5 published. expected: <3.1.0-beta.3> but was: <up_to_date> 2. aNewerVersionsPreReleaseWinsOverAHigherNumberedOlderOne latestBeta/latestAlpha keep a candidate only if it is BOTH semantically newer-or-equal AND higher-numbered, so a newer version restarting its count (3.2.0-beta.1 after 3.1.0-beta.3) loses to the older one. Whether that is observable depends on the order the repository returns versions in, which is not guaranteed anywhere. expected: <3.2.0-beta.1> but was: <3.1.0-beta.3> The second pin took two attempts to make honest, which is the point of watching it fail. A newest-first fixture passes, because latestBeta's wrong answer is masked by isUpdateAvailable's fall-through to latestVersion(). And a current version that is already the newest beta passes for the same reason. It only reaches the user when the beta branch itself fires and hands back latestBeta() directly — hence the oldest-first list and a current version old enough (3.1.0-beta.1) to trigger it. The fake's latestVersion() now COMPUTES the newest instead of taking the list head, so a fixture may be given in any order. The real allVersions() comes from a repository API whose ordering is not guaranteed, and a test that silently depends on that ordering cannot catch code that does the same. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>RED at this commit — the fix follows in the next one. Found while investigating why a normal api test run emits 159 scan-failure log lines. 28 of them are ScanningException("No dependencies specified."), raised by ForgeTomlScanner.getMapOfDependencyLists when a mods.toml declares no [[dependencies]] block — which is a perfectly ordinary descriptor, not an error. The exception aborts read() mid-way, so the mod falls back to the unreadable-jar defaults and its successfully-read modId is replaced by the FILENAME: expected: <lonelymod> but was: <lonelymod-1.0.0> The verdict itself is unaffected — a mod declaring no dependencies has no clientside signal, so SERVER is the only possible answer down either path, which is why this never broke a pack. What it does is discard a mod id that had already been parsed, and log an ERROR (with a stack trace, since6026f3640) for an entirely normal mod. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>Turns the pin from the previous commit green. ForgeTomlScanner.getMapOfDependencyLists raised ScanningException("No dependencies specified.") when a mods.toml carried no [[dependencies]] block. That is an ordinary descriptor — a mod is allowed to depend on nothing — and raising aborted read() mid-way, so the mod fell back to the unreadable-jar defaults and lost the modId it had already parsed to the filename. It now returns an empty map. The mod is then read normally: real id, SERVER verdict, empty dependency list. No change to any pack. A mod declaring no dependencies has no clientside signal, so SERVER was the answer down both paths; what changes is that ScannedMod.modID is now the declared id rather than the jar's filename. Nothing can regress from that — the id is joined on only by the dependency rescue, which looks up *disabled* mods, and a mod on this path is always SERVER and therefore never disabled. ScanningException had no other thrower and is deleted along with the stale @Throws on getSidenessesAndDependencies. It was internal, so nothing outside -api could reference it. Measured on a full :serverpackcreator-api:test run: scan-failure log lines 159 -> 131, i.e. exactly the 28 ScanningException lines, removed at the source rather than muted. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>Repositories were declared in 13 places: an allprojects{} block in the root, all six convention plugins, and five module build files. A new module got whatever set its convention plugin happened to carry, and nothing said which list was authoritative. They now live in one dependencyResolutionManagement block, with RepositoriesMode.FAIL_ON_PROJECT_REPOS so a stray project-level repositories{} is a build failure rather than a silent override. Verified: the build is green with that mode on, i.e. nothing in the tree still declares its own. Measured: mavenCentral() declarations 13 -> 2. The two are settings.gradle.kts and buildSrc/build.gradle.kts, which is a separate build and cannot read the root settings. Two removals worth calling out: - mavenLocal() was FIRST in buildSrc's repository list, so any stale artifact in ~/.m2 shadowed the real one and builds stopped being reproducible between machines. Gone. Also gone from buildSrc: the explicit "https://plugins.gradle.org/m2/" mirror, which is what gradlePluginPortal() already resolves to, and google(), an Android repository nothing here uses. - google() and gradlePluginPortal() are likewise gone from the dependency repositories; plugin resolution does not use them, and no dependency in the tree comes from either. Kept, with the reason recorded next to each: jitpack (serves com.github.MCRcortex:nekodetector, published nowhere else), spring milestones and the ej-technologies repository (both needed by -app). ./gradlew build green. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>It sat at the repository root, which is not where Gradle looks, so both settings files needed an explicit versionCatalogs { from(files(...)) } block to find it — the kind of wiring a newcomer has to read twice to understand is doing nothing special. At the conventional path the root build finds it by itself; that block is gone. buildSrc keeps its own, because it is a separate build and does NOT inherit the root catalog. Verified rather than assumed: removing the block fails buildSrc compilation with "Unresolved reference: libs" on all 11 dependency lines (Gradle 8.14.4). The comment now records that, so the next person does not retry the same simplification. Net: 2 explicit wiring blocks -> 1, and the file is where every Gradle user expects it. ./gradlew build green. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>No functional change; every removal is either a verbatim duplicate or a statement that never did anything. Duplicates: - java-conventions configured tasks.test TWICE, ~60 lines apart, which is where the cleanup() call was hiding. Merged into the one block. - spring-conventions applied org.springframework.boot, io.spring.dependency-management, kotlin allopen and kotlin spring in plugins{} and then AGAIN via apply(plugin = ...) directly below. The four apply() lines are gone. - kotlin-conventions and buildSrc/build.gradle.kts each held two byte-identical compileKotlin/compileTestKotlin blocks; both collapse to one withType<KotlinCompile>().configureEach. allWarningsAsErrors = false went with them — it is the default. - kotlin-conventions also set jvmToolchain(21) while java-conventions already pins the toolchain to 21 for the same projects. Dropped; JVM 21 is now stated in two places (the toolchain and the Kotlin jvmTarget) rather than four. Dead: - 35 lines of commented-out cyclonedx configuration in the root, plus its three commented imports and commented plugin line. - `tasks.build { doLast { tasks.dokkaGeneratePublicationJavadoc } }` in -api and the same shape in -app: an expression statement that resolves a task provider and throws it away. It reads as if it triggers the task; the finalizedBy on the following line is what actually does. - install4j's installDir if/else ended with an `else` branch identical to its first condition, so the Linux branch and the fallback were merged. Noise removed from every build: quasar-conventions printed "I am running on: <arch>" at configuration time, and four compile tasks each logged "Configuring <name> with version ..." at lifecycle level. ./gradlew build green. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>java-conventions applied maven-publish and signing to all six modules, giving each a mavenJava publication, three remote repositories, a sources jar and a javadoc jar. CI publishes ONE module: .gitlab-ci.yml runs four `:serverpackcreator-api:publish...` invocations and nothing else. Moved verbatim into a new serverpackcreator.publishing-conventions, applied by -api alone. The file opens with why it exists, so the next reader does not have to reconstruct the "only one module publishes" fact from the CI config. Verified per module, since getting this wrong breaks releases silently: api:publishMavenJavaPublicationToGitHubPackagesRepository EXISTS api:publishMavenJavaPublicationToGitLabRepository EXISTS api:publishMavenJavaPublicationToGitGriefedRepository EXISTS api:publishToSonatype EXISTS app / clientside / grinder / plugin-example gone Three call-sites existed only because publishing was global and are removed with it: -app's `tasks.signMavenJavaPublication` and `tasks.sourcesJar` wiring, and -plugin-example's sourcesJar wiring (a comment there says it existed purely to silence a Gradle 8 warning about a task that is now absent). Non-api modules no longer produce -sources.jar / -javadoc.jar. Checked before removing: neither .gitlab-ci.yml, the GitHub workflows, nor spc.install4j reference either artifact outside -api. Side effect worth having: `signing` no longer runs for five modules where findProperty("signingKey").toString() yielded the literal string "null". ./gradlew build green. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>serverpackcreator-api/build.gradle.kts held fifteen bare `copy { }` calls inside the `processResources` CONFIGURATION block. They executed whenever that task was configured rather than as task actions, so they had no inputs, no outputs, no up-to-date checking and no cacheability — and they wrote into two source trees (src/main/resources and serverpackcreator-help/Writerside/topics). Measured before: a second, fully UP-TO-DATE `:serverpackcreator-api:processResources` still rewrote both destinations. Measured after: both are untouched, and shipRootDocuments itself reports UP-TO-DATE. Now three declared Copy tasks — shipRootDocuments, shipWritersideDocuments, shipWritersideImages — over one shared list of the seven shipped documents, which also removes the fifteen-fold repetition of the same from/into pair. Correction to an earlier claim of mine, since it is in the branch's history: I described these as running on "every Gradle invocation". They did not. Configure-on-demand plus task-configuration-avoidance meant `gradlew help` and even `:serverpackcreator-api:help` left the files alone; it took a build that realized processResources. The defect is real but its scope was narrower than I first said, and the measurement above is what it actually was. Making the copies visible to Gradle immediately exposed a coupling the old approach had hidden: sourcesJar packages src/main/resources, which shipRootDocuments now writes, and Gradle failed the build for an undeclared dependency. That ordering was previously luck. Declared. Verified beyond the build passing: - all seven documents plus img/ (120 files) land in the Writerside topics, with LICENSE renamed to LICENSE.md as before - a real content change to the root README propagates to both destinations, i.e. the tasks are not permanently up-to-date The destinations are unchanged, so ShippedResourceTrackingTest's assertions about the repository's ignore rules still hold. ./gradlew build green. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>Unwinds the three coupled constructs in build.gradle.kts: allprojects { tasks.withType<Test> { jvmArgs(...) } } evaluationDependsOnChildren() project("serverpackcreator-app").tasks.build.get().mustRunAfter( tasks.getByName("generateLicenseReport"), project("serverpackcreator-web-frontend").tasks.build.get()) project.childProjects["serverpackcreator-plugin-example"]?.tasks?.jar?.get() ?.archiveFile?.get()?.asFile?.toPath() // + !! at both use sites They were one knot: `.tasks.build.get()` on another project only resolves if that project is already evaluated, which is exactly what evaluationDependsOnChildren() was there to guarantee. - the two test JVM args move into java-conventions, where every module already gets its test configuration, with a note on why they exist (agent self-attach) - the ordering constraint is declared by -app itself, by task PATH. A string path resolves lazily; reaching into another project's task container does not - the example-plugin jar is consumed as an ARTIFACT. plugin-example exposes a consumable `pluginArtifact` configuration, the root depends on it, and the two copy tasks use that. This removes the nullable chain, both `!!`, and the evaluation-order dependency in one move. Deliberately NOT the legacy `archives` configuration, which Gradle 9 removes - evaluationDependsOnChildren() then has nothing left needing it, and is gone Also removes two more configuration-time filesystem calls: `appPlugins.mkdirs()` ran on every configuration of the root project, and both destination paths were raw `File(...)` relative to the working directory rather than project-relative. Verified, since these tasks feed a pf4j test: - copyPluginsApiUnitTests and copyExamplePluginsToApp still place serverpackcreator-plugin-example-dev.jar in both destinations - ApiPluginsTest PASSES against the jar the new wiring produces, i.e. pf4j still discovers all six extension points. The regenerated jar was then reverted, per the "don't commit rebuilds" rule in serverpackcreator-api/CLAUDE.md - ./gradlew clean build green CORRECTION to my own stated rationale, measured rather than assumed: I said this was a prerequisite for the configuration cache. It is not. `build --dry-run --configuration-cache` reports the SAME 20 problems (13 unique) before and after this commit, and configuration time is unchanged at ~4.95s either way. Those constructs block project ISOLATION, not the configuration cache. The 20 problems come from elsewhere and are listed in the next commit's documentation. What this commit actually buys: no eager cross-project evaluation, no `!!` and no nullable task chain left in the build, a real artifact dependency in place of a path dug out of another project's task container, and the prerequisite for project isolation if that is ever wanted. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>Two entries in the root CLAUDE.md build section. The first states the no-cross-project-configuration rule and names all four constructs that were removed, so none is re-introduced. The second corrects a claim I made and then measured: unwinding allprojects/evaluationDependsOnChildren/cross-project task access was NOT what stood between this build and the configuration cache. Same 20 problems before and after, same ~4.95s configuration time. It buys project isolation, which is a different feature. The real blockers are enumerated so nobody has to rediscover them: one is third-party (:generateLicenseReport holds a Project reference), the rest are ours (the filter{} and doFirst{cleanup()} in java-conventions capture the enclosing script; -app's test.doFirst also captures projectDir). With the third-party one unfixable, the honest ceiling is fewer problems rather than zero — recorded so the next attempt starts with the right expectation. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>Build cleanup, steps 1-4 of the build analysis. No functional change to what is produced; every step is verified by measurement rather than by tests, per the build-logic convention. mavenCentral() declarations 13 -> 2 hardcoded coordinates 62 -> 0 live (2 commented-out lines remain) catalog entries 21 (plugins only) -> 71 (plugins + 50 libraries) modules with publishing 6 -> 1 source-tree writes on an up-to-date run 15 files, two trees -> 0 - Repositories are declared once in settings.gradle.kts with FAIL_ON_PROJECT_REPOS, so a stray project-level block fails the build. buildSrc keeps its own (separate build) and no longer lists mavenLocal(), which was FIRST there and let a stale ~/.m2 artifact shadow the real one. - The version catalog moved to gradle/libs.versions.toml and now holds the libraries too. Verified inert: resolved runtimeClasspath + testRuntimeClasspath diffs are 0 lines for api, clientside, grinder and app. - plugin-example's stray kotlin-test-junit5:2.4.10 was dragging that module's whole Kotlin stack -- kotlin-stdlib included -- to 2.4.10 by conflict resolution, so it compiled with the 2.3.20 compiler against a 2.4.10 stdlib. Aligned to 2.3.21 like every other module; that is the one intended graph change (158 lines). - Publishing and signing apply to -api alone, matching CI. All four :serverpackcreator-api:publish... tasks CI invokes were verified present, and absent everywhere else. - The fifteen configuration-time copy{} calls in -api became three Copy tasks with declared inputs and outputs. Making them visible immediately exposed a real undeclared dependency (sourcesJar packages what shipRootDocuments writes) that had been ordering by luck. Also removes duplicated and dead build logic: a second tasks.test block, four redundant apply(plugin=...) calls, two pairs of byte-identical compile blocks, 35 lines of commented-out cyclonedx config, two no-op doLast statements, and configuration-time println/lifecycle logging on every build.Build cleanup, step 5: no cross-project configuration in the root build. Removes allprojects{}, evaluationDependsOnChildren(), the root's project("serverpackcreator-app").tasks.build.get().mustRunAfter(...), and the childProjects[...] chain that dug the example-plugin jar out of another project's task container behind two !! operators. They were one knot: reaching another project's tasks eagerly only works if that project is already evaluated, which is what evaluationDependsOnChildren() guaranteed. Now the ordering is declared by -app itself using lazy task paths, and the plugin jar is consumed as an artifact through a consumable `pluginArtifact` configuration -- which removed the build's last two !!. Verified where it mattered: the copy tasks feed a pf4j test, so both were run and ApiPluginsTest passes against the jar the new wiring produces (all six extension points discovered). The regenerated jar was reverted per the don't-commit- rebuilds rule. CORRECTION carried in the branch: this was NOT the configuration-cache prerequisite I claimed. Measured -- build --dry-run --configuration-cache reports the same 20 problems (13 unique) before and after, and configuration time is ~4.95s either way. These constructs block project ISOLATION, a different feature. The real blockers are now enumerated in CLAUDE.md: one is third-party (:generateLicenseReport holds a Project reference), the rest are ours (script object references captured by the filter{} and doFirst{cleanup()} in java-conventions, plus -app's test.doFirst capturing projectDir). With the third-party one unfixable, the ceiling is fewer problems, not zero.Six items. All links were checked first and are fine — 16 internal links resolve and the 8 project-owned external ones return 200 — so the staleness was all in content. 1. The commandline-argument table listed 7 of 17 arguments. It now lists all 17, with descriptions taken from Mode.kt's per-entry KDoc so the two cannot drift silently again. The ten that were missing: -config, --destination, -feelinglucky, -withallinconfigdir, --home, -lang, and the four clientside verbs -scan, -clientsidereport, -verifyclientside, -clientsideapply The four clientside ones get a note that they are maintainer tools and that -verifyclientside boots a real server, so nobody runs it expecting a quick answer. Verified after the edit: 17 of 17 documented, none missing. 2. "ServerPackCreator will crash, complaining about JDBC-related things" in the webservice setup was left over from the H2/JPA era. There is no JDBC anywhere in main source — persistence has been MongoDB since Spring Boot 4. Reworded to what that first run is actually for (creating the home-directory) and what goes wrong (no database configured yet), without asserting a specific error text I have not reproduced. 3. The Gradle-Groovy dependency snippet was fenced as ```kotlin while containing Groovy single-quote syntax. Now ```groovy. The Kotlin snippets are untouched and remain compiler-gated by ReadmeExamplesTest, which passes. 4. Dropped `version: '3'` from the docker-compose example. Compose v2 treats the key as obsolete and warns about it on every invocation. 5. Link text read `docker/docker/init-mongo.js` while pointing at the correct `docker/init-mongo.js`. 6. Documented SPC_CONFIGURATION_AIKAR and SPC_LOG_LEVEL, two operator-facing variables present in both shipped compose-files but absent from the table. Everything else was checked and is current: every de.griefed.serverpackcreator.* property and SPC_* variable in the README still exists in the code, the Java 21 requirement holds, the five-modloader list is right, and the API example's symbols all resolve. The generated copies under -api's resources and Writerside's topics are not tracked; a build regenerates both, verified. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>Found while checking a stale README line that told self-hosters to expect a crash "complaining about JDBC-related things". There is no JDBC in this app — persistence has been MongoDB since Spring Boot 4 — but the properties files still carried the old stack's settings. serverpackcreator-app/src/test/resources/application.properties, 12 lines, including the one that matters: spring.data.mongodb.uri=jdbc:h2:mem:testdb A MongoDB URI holding a JDBC URL. ConnectionString accepts only mongodb:// and mongodb+srv://, so this is a hard startup failure the moment Mongo autoconfiguration runs. It never has, purely because WebServiceTest is @SpringBootTest(classes = [WebServiceTest::class]) and therefore boots a context of exactly one class. Dormant, not harmless: the first real @SpringBootTest anyone writes inherits it and fails with a message pointing nowhere near the cause. serverpackcreator-app/src/main/resources/application.properties, 4 lines: spring.transaction.default-timeout and three spring.datasource.tomcat.* pool settings for a datasource that does not exist. Verified dead before removing, rather than assumed: @Transactional in main source 0 files JPA / JDBC / DataSource types in main source 0 files hibernate, tomcat-jdbc or h2 on the runtime classpath 0 hits The unused `testRuntimeOnly(libs.h2)` dependency and its catalog entry go too — no test source references H2 at all. serverpackcreator-app/CLAUDE.md records why these were dormant and why that made them a trap rather than a curiosity. ./gradlew clean build green. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>Remove the JPA/H2/JDBC relics from the Spring properties, and audit both branches. Found while checking the stale README line about "JDBC-related things": the properties files still carried the pre-MongoDB stack's settings, including spring.data.mongodb.uri=jdbc:h2:mem:testdb a MongoDB URI holding a JDBC URL. ConnectionString accepts only mongodb:// and mongodb+srv://, so it is a hard startup failure the moment Mongo autoconfiguration runs. It never has, purely because WebServiceTest boots a context of exactly one class. Dormant, not harmless -- the first real @SpringBootTest anyone writes inherits it and fails somewhere far from the cause. 16 dead properties removed across the two application.properties, plus the unused testRuntimeOnly H2 dependency and its catalog entry. Verified dead by measurement rather than inspection: 0 @Transactional, 0 JPA/JDBC/DataSource types in main source, 0 hits for hibernate, tomcat-jdbc or h2 on the runtime classpath. REFACTOR-AUDIT.md carries the audit of both branches: no HIGH findings, one MEDIUM (this commit removes main-source runtime configuration that no test could have protected -- mitigated by the measurement above, and the real gap is WebServiceTest asserting nothing), three LOW.Behaviour-preserving. No test file is touched and the existing suite is green. WebService.start() built the eight-location --spring.config.location argument inline and handed it straight to Spring Boot, so the composition could not be asserted from anywhere. It is now WebService.configLocationArgument(), a pure function taking the three inputs it actually depends on — the properties file, the overrides file, and the user's home directory. Exactly the extraction springArguments already had, and for the same stated reason: start() boots Spring, so anything welded to it is untestable. The produced string is unchanged. The only shape change is that the user-home locations are built from an injected File rather than from System.getProperty("user.home") read inside the method; File(File, String) and File(String, String) resolve identically, and the next commit pins the full eight-location output so this is guarded rather than argued. Why this matters more than a tidy-up: later locations win, and the last two are overrides.properties — the file the docker image's init-spc-config script composes SPC_DATABASE_* into, and therefore where spring.data.mongodb.uri arrives from in a container. A location silently going missing here is a property-file that is never read, which for the database URI is a hard startup failure (see the landmine in serverpackcreator-app/CLAUDE.md). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>The old WebServiceTest was @SpringBootTest(classes = [WebServiceTest::class]) — a context consisting of exactly one class, itself — with an empty contextLoads() body. It could not fail for any reason involving ServerPackCreator, which is why this module's CLAUDE.md said to replace rather than extend it. Deleted. WebServiceContextTest boots the REAL context (classes = [WebService::class]) and asserts it wires up: every @RestController registered, the four services the controllers delegate to resolvable, and ServerPackCreator's own property-files actually present on the environment — the other end of the config-location chain the previous commit pinned. It needs no database, which I checked rather than assumed: the MongoDB driver connects lazily, so the whole context starts and every injection point resolves with nothing listening on 27017. The driver logs a ConnectionException in the background and startup continues. That makes the wiring half testable offline; exercising an actual query still needs a live Mongo and belongs in an integration test. Teeth verified, since a context test that cannot fail is exactly what was being replaced: removing @Service from EventService fails it with NoSuchBeanDefinitionException: No qualifying bean of type '...EventService' and it passes again on restore. (A first attempt broke a constructor signature instead, which failed compilation before the context could start — not a valid check, so it was redone.) The three schedules are disabled via Spring's CRON_DISABLED ("-") rather than left on their midnight crons. FileCleanupSchedule deletes modpack files whose IDs are absent from the database; a suite that happens to run at 00:30 against an unreachable database should not be the thing that discovers what that does. Also moves the file from package de.griefed.serverpackcreator.web to ...app.web, matching every other test in the module. app 105 -> 108. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>Separate from the configuration-cache commit before it, because it is a separate concern — and, measured, it contributes nothing to cache compatibility. With only the capture removals in place, `build --dry-run --configuration-cache` already reported 7 problems / 1 unique, the same as after this commit. The copies were never the cache's problem; the expand() reading script properties was. What they ARE is the anti-pattern already fixed in -api: three bare `copy { }` calls inside the processResources CONFIGURATION block, so they ran whenever that task was configured — including on runs where processResources was UP-TO-DATE and did nothing — with no inputs, no outputs, no caching, writing into the source tree every time. Measured on a second, up-to-date `:serverpackcreator-plugin-example:processResources`: before README.md and LICENSE rewritten after both untouched Found while measuring: the third copy has been dead. There is no CHANGELOG.md at this module's root, so `copy { from(...CHANGELOG.md) }` matched nothing. `include(...)` behaves identically, so the behaviour is unchanged and this commit does not touch it — but serverpackcreator-plugin-example/src/main/resources/CHANGELOG.md is a TRACKED 14 KB file that nothing generates and nothing updates, and it ships inside the example plugin's jar. Surfaced rather than silently deleted; it wants its own decision, since "delete a tracked file that ships to users" is not a build cleanup. Verified the three documents still reach their destination and the jar: LICENSE, README.md and CHANGELOG.md all present in src/main/resources and all present in the built jar. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>Replaces the last 10 eager task lookups with tasks.named(...). Verified after the change: 0 `tasks.getByName` / `tasks[...]` remain anywhere in the build. serverpackcreator-api/build.gradle.kts 5 (fixMissingResources) build.gradle.kts 2 (clean/copyLicenseReport) dokka-conventions 1 (compileJava, compileTestJava) quasar-conventions 1 (installNode -> installQuasar) publishing-conventions 1 (javadocJar artifact) getByName forces the task to be created during configuration whether or not the build will run it; named() returns a provider and defers that. The quasar case changed shape rather than just its call: `tasks.getByName("installNode").finalizedBy(tasks.getByName("installQuasar"))` realized both tasks to express one wiring; it is now a configuration block on the provider. ./gradlew build green. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>Closes the prerequisite trap BUILD.md documented: without a local JDK 21 the modules could not be built at all, because the foojay toolchain resolver was registered only in buildSrc/settings.gradle.kts. Reproduced first, by pointing the module toolchain at an uninstalled JDK 11: Cannot find a Java installation ... matching {languageVersion=11, ...}. Toolchain download repositories have not been configured. and verified fixed the same way, with --offline so nothing actually downloaded — the error becomes "Some toolchain resolvers had provisioning failures: foojay (... No cached resource ... available for offline mode)", i.e. the resolver is registered and tried. It was not the one-liner I predicted. Three attempts failed first, and the shape that works is worth recording because it looks wrong: - root `plugins { id(...) version "0.8.0" }` while buildSrc also declares it -> "already on the classpath with an unknown version" - root without a version -> "not found in any of the following sources" - registering the resolver class directly via toolchainManagement -> the class is not on the settings-script compilation classpath The working arrangement is BOTH, declared differently: the root **with** the version, buildSrc **without** one. Both are genuinely required — verified separately that buildSrc does NOT inherit the root's toolchain repositories (it fails with "download repositories have not been configured" when the root alone has the resolver). BUILD.md's trap becomes a statement of how it works, with the asymmetry and its two error messages spelled out; the same fact goes into CLAUDE.md's build-layout section so nobody "tidies" one of the two declarations away. The audit's L-2 is marked fixed, noting it cost more than the one line it was scoped at. ./gradlew clean build green. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>RED at this commit — the fix follows in the next one. Reported from a real pack. The Java-24 guard in setupForge only protects users who ALREADY had a suitable Java installed; a pack that installs its own sails straight past it: No suitable Java installation was found on your system. Proceeding to Java installation. Downloading and using Java temurin@25 ... Run Command: java ... -Djava.security.manager=allow -jar server.jar --installer-force --installer ...forge-1.20.1-47.4.22-installer.jar Error occurred during initialization of VM java.lang.Error: A command line option has attempted to allow or enable the Security Manager. Why: JAVA_VERSION starts as the literal "do_not_manually_edit" and is only filled in by getJavaVersion. None of the three installJava call-sites re-read it afterwards, and install_java.sh never sets it either — verified in all three shell templates. So setupForge evaluates [[ "${JAVA_VERSION}" =~ ^[0-9]+$ ]] && [[ ${JAVA_VERSION} -ge 24 ]] against the placeholder, the regex does not match, and the else branch hands SSJ the fatal flag. The pin asserts the FAIL-SAFE property rather than just "re-read the version": an unresolved Java version must never take the branch that passes a flag which is fatal on the JVMs it cannot rule out. Covers all three shapes the variable can carry when nothing resolved it — the placeholder, empty, and a non-numeric value. Observed failing for the right reason, reproducing the reported run command: with JAVA_VERSION='do_not_manually_edit' the security-manager flag was passed ... RESULT=@user_jvm_args.txt -Djava.security.manager=allow -jar server.jar ... Neither the grinder nor ScriptTemplateMatrixIT could have caught this: both pre-bake Java and never take the install path. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>Both guards are RED at this commit, on purpose; the fix follows in the next one. Observed against unmodified code: parallelMapDoesNotLeakAThreadPerInvocation parallelMap leaked 4 thread(s) still alive after 4 invocations expected: <0> but was: <4> parallelMapRunsElementsOnMoreThanOneThread every element ran on one thread (ids=[58]); the default context is not parallel `parallelMap`'s defaulted context is `newSingleThreadContext("parallelMap")`. That factory owns a dedicated thread and requires its creator to `close()` it, but a defaulted parameter has no owner, so every single invocation strands one thread for the life of the JVM. The same default also means a function named `parallelMap` confines all of its elements to one thread. Two deliberate choices in how the guards measure, both of which a more obvious version gets wrong: - the leak guard compares a COUNT, not a set of names. Every leaked thread carries the identical name `parallelMap`, and Kotlin's `List - Set` drops all occurrences of a duplicate, so a name-difference version silently reports zero leaks whenever the baseline is already non-empty — i.e. it stops guarding precisely when an earlier test in the same JVM has already leaked one, and JUnit guarantees no ordering between the two tests here. - the parallelism guard identifies threads by `Thread.threadId()`, never by name. Gradle enables assertions on test tasks, which flips kotlinx.coroutines' `auto` debug mode on, and that appends ` @coroutine#N` to the thread name. A name-based version collects one entry per *coroutine* and PASSES against the single-threaded context — it was written that way first and caught only by watching it fail. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>Defaults `parallelMap`'s context to `Dispatchers.Default` instead of `newSingleThreadContext("parallelMap")`. Turns the previous commit's two guards green. `newSingleThreadContext` hands out a dispatcher that owns a dedicated thread and requires whoever built it to `close()` it. As a *defaulted parameter* it has no owner and nothing ever closed it, so every invocation stranded one thread for the life of the JVM — measured at 4 surviving threads for 4 calls. The same default also confined the whole list to one thread, so a function named `parallelMap` was not parallel: all 8 elements of the parallelism guard ran on thread id 58. This is labelled `fix:` and not `refactor:` because it changes behaviour twice over, and the second one is not merely a repair: - elements now run on the shared pool sized to the available processors rather than one confined thread. Callers relying on that confinement for safety — a lambda mutating shared state without synchronisation was previously serialised by accident — can now race. `parallelMap` is public, published API, so this reaches embedders and plugins even though it has zero call sites in this repo. Recorded in the API-compatibility table. - the `@OptIn(DelicateCoroutinesApi::class, ExperimentalCoroutinesApi::class)` is gone, because the delicate API was `newSingleThreadContext` itself. An opt-in to `DelicateCoroutinesApi` on a *default value* is the tell that the default is wrong, not a formality. The signature is unchanged, so a caller passing its own context is unaffected and nothing loses source compatibility. Also narrows this file's `import kotlinx.coroutines.*` to the four symbols it actually uses, now that removing the opt-in shrank that surface. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>Version bumps only, no build logic. This commit is RED for -app, on purpose; the fix follows in the next one. kotlinLibs 2.3.21 -> 2.4.10 nightConfig 3.8.4 -> 3.9.0 coroutines 1.10.2 -> 1.11.0 jackson 2.22.0 -> 2.22.1 junitPlatform 6.1.0 -> 6.1.3 log4j 2.26.0 -> 2.26.1 mockk 1.14.6 -> 1.14.11 bouncycastle 1.84 -> 1.85 ktorfit 2.7.3 -> 2.7.5 playwright 1.60.0 -> 1.62.0 flatlaf 3.7.1 -> 3.7.2 springBoot 4.0.6 -> 4.1.0 Also folds the separate `jacksonDatabind` version entry into `jackson`; the two had drifted to 2.21.1 and 2.22.0 while naming artifacts from the same release train. Measured at THIS commit: :serverpackcreator-app:dependencyInsight --dependency kotlinx-coroutines-core testRuntimeClasspath 1.10.2 (catalog asks for 1.11.0) :serverpackcreator-app:test 108 tests, 16 failed java.lang.NoSuchMethodError: 'java.lang.Object kotlinx.coroutines.BuildersKt.runBlockingK(kotlin.coroutines.CoroutineContext, kotlin.jvm.functions.Function2)' The bump alone cannot be green, and separating it from its fix is the point. `io.spring.dependency-management` turns Boot's BOM into forced versions that beat every transitive request, and Boot's BOM manages kotlinx-coroutines — so -app is pinned to 1.10.2 while -api compiles against the catalog's 1.11.0, which renamed the Kotlin-facing `runBlocking` to JVM name `runBlockingK`. The result compiles green in every module and dies only when the code runs. Landing this separately is the same discipline as committing a failing test before its fix: it makes the breakage checkable. Squashing it into the build fix would leave a commit whose message claims a before/after measurement that nobody can reproduce from the tree it describes. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>Documentation only; no code changes. Root CLAUDE.md, API compatibility table — two new rows: - `parallelMap`'s new `Dispatchers.Default` default, spelling out that the interesting half for embedders is not the leak but the loss of accidental single-thread confinement, which can newly expose races in an unsynchronised caller lambda. - the coroutines 1.11.0 runtime floor. 1.11.0 renames the Kotlin-facing `runBlocking` to JVM name `runBlockingK`; `javap` confirms it is present in 1.11.0 and absent in 1.10.2, and our compiled `VersionMeta.class` emits it. An embedder that *pins* coroutines to 1.10.x gets `NoSuchMethodError` at runtime with nothing failing to compile. Root CLAUDE.md also gains the build-layout landmine for Boot's BOM — it must be a `platform()`, never io.spring.dependency-management — with the measured outcome (runtimeClasspath 13 -> 0 differing between -api and -app) and the two traps: the BOM coordinate must come from the catalog's `springBoot` and not from SpringBootPlugin.BOM_COORDINATES, and a platform only out-ranks what a module actually requests, so a purely transitive library still needs declaring. Corrects the stale claim that `kotlinLibs` is 2.3.21 — it is 2.4.10, a full minor above the 2.3.20 compiler rather than a patch, and records that the pairing was measured (metadata versions read off the jars with `javap -v`) rather than assumed, and that the measurement binds Gradle only: IntelliJ analyses with its own bundled Kotlin plugin. api test count 302 -> 309 (the table had already drifted; +2 is this branch). serverpackcreator-api/CLAUDE.md gains the reusable landmines: never default a parameter to a thread-owning dispatcher (an `@OptIn(DelicateCoroutinesApi)` on a default value is the tell), and the matched pair of ways to get a thread assertion wrong — identity by name is broken by coroutine-debug decoration, and set-difference over same-named threads silently stops guarding. The second was found by auditing a guard that was already green. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>Behaviour-preserving. Two coordinates that bypassed `gradle/libs.versions.toml` now read from it; no version changes and no classpath moves. - `-api` declared nekodetector as a hardcoded coordinate, `api("com.github.MCRcortex:nekodetector:Version-1.1-pre")`, while the catalog already carried both a `nekodetector` version and a library alias that nothing referenced. Now `api(libs.nekodetector)`. This was the only hardcoded coordinate left in the build and a direct violation of the rule in CLAUDE.md. - `spring-conventions` rebuilt the BOM coordinate as a string from `findVersion("springBoot")`, leaving the `springBootDependencies` alias declared but unused. It now resolves the alias with `findLibrary`, so the catalog entry is consulted rather than duplicated by concatenation. Verified: `./gradlew build` SUCCESSFUL; nekodetector still resolves com.github.MCRcortex:nekodetector:Version-1.1-pre. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>Behaviour-preserving. Adds `[plugins]` with the two aliases a real build script can consume, and removes the three version literals that duplicated the catalog by hand. serverpackcreator-api id("de.comahe.i18n4k") version "0.11.2" serverpackcreator-plugin-example id("de.comahe.i18n4k") version "0.11.2" build.gradle.kts id("io.github.gradle-nexus.publish-plugin") version "2.0.0" all three now `alias(libs.plugins.…)`. The i18n4k literal sat in two modules and had to be kept in step with the catalog's `i18n4k` — which also versions the i18n4k libraries — by a comment rather than by a mechanism. buildSrc is untouched here and still takes its plugin versions from the `[libraries]` entries; moving it to `[plugins]` changes what it resolves, so it is the next commit rather than this one. Verified: `./gradlew build` SUCCESSFUL, twice. Same versions resolve; nothing moves on any classpath. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>Moves buildSrc's plugin dependencies onto the catalog's `[plugins]` entries via the plugin MARKER (`<id>:<id>.gradle.plugin:<version>`), and deletes the ten `[libraries]` entries that superseded. This CHANGES what buildSrc resolves, which is why it is separate from the alias conversion in the previous commit. Precompiled script plugins CANNOT use `alias(libs.plugins.x)` — verified by trying it, which fails at :buildSrc:compilePluginsBlocks with `Unresolved reference: libs`. So the convention plugins keep applying a versionless `id("...")` and the version has to reach them through buildSrc's own compile classpath. Taking that from `[plugins]` removes the last place where one plugin was described by two unlinked strings: the id in the convention plugin, an unrelated implementation coordinate in the catalog. Measured, flattened :buildSrc:compileClasspath, before -> after: 23 -> 31 modules. The nine additions are marker POMs. The one REMOVAL is `org.jetbrains.dokka:javadoc-plugin:2.1.0`, which the `org.jetbrains.dokka-javadoc` marker does not depend on. Checked rather than assumed, because -api's javadoc jar is published to Maven Central and the task reports success either way: from a wiped build/dokka, :serverpackcreator-api:dokkaJavadocJar produces 467 files / 356 HTML pages including real class pages. Unnecessary artifact, not a silent loss. Verified: `./gradlew build` SUCCESSFUL, 91 tasks. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>Prerequisite for moving the Kotlin compiler off 2.3.20, landed separately so both commits stay green. Kover reads `compileKotlinTask` off the Kotlin compilation by reflection (`kotlinx.kover.gradle.plugin.util.DynamicBean.value`). KGP 2.4.10 no longer exposes that property on `KotlinWithJavaCompilation`, so Kover 0.9.1 fails the whole build at task-graph time, before anything compiles: Could not determine the dependencies of task ':serverpackcreator-api:koverGenerateArtifactJvm'. > Could not get unknown property 'compileKotlinTask' for compilation 'main' (target (jvm)) of type KotlinWithJavaCompilation_Decorated Since `kotlin-conventions` applies Kover to every module, that breaks the entire build, not one report. Measured at THIS commit, i.e. still on Kotlin 2.3.20: `./gradlew build` SUCCESSFUL. So 0.9.9 supports both the old and the new compiler, which is what lets it land first. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>Behaviour change: `kotlin`, `kotlinAllOpen` and `kotlinJpa` move 2.3.20 -> 2.4.10, matching `kotlinLibs`, which was already there. The four entries now hold the same value; collapsing them onto one ref is the next commit, so that "the compiler moved" and "the catalog was restructured" are separately bisectable. This is the decision CLAUDE.md deferred when the entries were split. Until now -api was compiled by a 2.3.20 compiler against a 2.4.10 stdlib. That worked, but it is the same shape as the coroutines 1.11.0 failure this branch already fixed: a compiler reading metadata from a newer library fails hard with "binary version of its metadata is X, expected Y", and nothing warns as the gap widens. Measured, because a compiler bump is exactly where silent regressions live: compiler warnings, all 5 modules, main + test, --rerun-tasks before 243 after 243 The ONLY difference is one warning the 2.4.10 compiler rewords in place, at the same ServerPackCreator.kt:164:95: - Right operand of elvis operator (?:) is useless if it is null. + Elvis operator (?:) is redundant if the right operand is always null. So: zero new warnings, zero suppressed ones. ./gradlew build BUILD SUCCESSFUL, 91 tasks — the whole graph: 741 JVM tests, every Kover report, bootJar, both dokka publications, sourcesJar, generateLicenseReport, and the frontend (installFrontend / assembleFrontend / checkFrontend, the last running the Vitest suite via `npm run test`). kapt in -plugin-example, allopen/jpa/spring in -app, dokka 2.1.0 and Kover were the four things most likely to object. Kover did, which is why it was bumped in the preceding commit; the other three build and test clean. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>Documentation only, for the six build commits that precede it. Collected here rather than split across them so the branch has one documentation convention throughout — commits 6 and 7 already used dedicated `docs:` commits. Root CLAUDE.md: - `[plugins]` and the two consumption routes: a real build script uses `alias(libs.plugins.x)`; a precompiled script plugin CANNOT, failing with `Unresolved reference: libs` (verified by trying it), and instead takes its version from the plugin marker buildSrc puts on its own compile classpath. Either route reads the one catalog. - the buildSrc marker conversion's measured effect, including the dropped `org.jetbrains.dokka:javadoc-plugin` and the reason it is harmless — with a standing warning to check the javadoc jar when touching dokka wiring, because -api's is published to Maven Central and the task reports success either way. - `settings.gradle.kts` cannot use the catalog in its own `plugins {}` block, which is why the foojay resolver keeps a literal version. - one `kotlin` entry for everything JetBrains ships from the Kotlin release train, replacing the note that described the old four-entry split as deliberate. That note, and the paragraph justifying the 2.3.20-compiler / 2.4.10-library gap as verified-safe, are both obsolete now that the gap is gone. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>Started as "why is runBlocking(Dispatchers.IO) failing" and turned out to be two unrelated things: a stale IntelliJ project model, and a real runtime breakage nobody had hit yet. Fixes: * parallelMap leaked a dedicated thread on every call and was not parallel — its defaulted context was newSingleThreadContext, which nothing could close. * Spring Boot's BOM, applied through io.spring.dependency-management, forced versions over every transitive request and silently reverted -app's half of every catalog bump. It is now a Gradle platform(), so the catalog wins. This is what broke coroutines: -api compiled against 1.11.0 and emitted the renamed runBlockingK, while -app ran on the BOM's 1.10.2. * mockk had split 1.14.11/1.14.6 between -api and -app. * Kover 0.9.1 could not read KGP 2.4.10. * dokka's HTML publication had an undeclared dependency on the Java compilations — pre-existing, reproduced on develop before the fix. Build: * every plugin id and version now lives in gradle/libs.versions.toml, with a new [plugins] section; the last hardcoded coordinate is gone. * one `kotlin` entry for the whole Kotlin release train, at 2.4.10. This bumps the compiler from 2.3.20; measured at 243 compiler warnings before and after. * springGradle aligned with springBoot at 4.1.0. -api vs -app resolved runtimeClasspath: 13 differing coordinates before, 0 after. Two commits are deliberately red so the breakage they describe is checkable from the commit that causes it:e55ba8947(the parallelMap guards, before their fix) ande55ddfe8e(the catalog bump, before the platform switch). API note: ListUtilities.parallelMap keeps its signature but no longer confines elements to one thread, so an embedder relying on that accidental serialisation can now race. Recorded in the compatibility table in CLAUDE.md.The Boot 4.0.6 -> 4.1.0 bump moved `spring-boot-mongodb` 4.0.2 -> 4.1.0 and `mongodb-driver-core` 5.6.2 -> 5.8.0, leaving this landmine citing versions the build no longer resolves. Since the whole entry rests on a `javap` measurement, stale version numbers make it unverifiable rather than merely untidy. Re-measured at 4.1.0 / 5.8.0, both claims still hold exactly: PropertiesMongoConnectionDetails.getConnectionString() getfield properties / invokevirtual getUri / ifnull 25 new com/mongodb/ConnectionString ... areturn i.e. still `if (uri != null) return new ConnectionString(uri)`, with the host/port/username/password branch unreachable whenever a uri is set. com.mongodb.ConnectionString still declares MONGODB_PREFIX and MONGODB_SRV_PREFIX and accepts only `mongodb://` / `mongodb+srv://`. Documentation only; no behaviour change. Noticed because the driver version is printed in `WebServiceContextTest`'s startup log, which is where the mismatch with this file showed up. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>New `NetworkConfig` settings-group, following the group pattern in serverpackcreator-api/CLAUDE.md, with thin `ApiProperties` facades: de.griefed.serverpackcreator.network.timeout.connect 5000 de.griefed.serverpackcreator.network.timeout.read 15000 de.griefed.serverpackcreator.network.timeout.download.read 60000 Read-timeout bounds a *single* read, not the whole transfer, so the download value is not a transfer budget -- it is separate because installers and mod jars are served by hosts that trickle bytes under load, where a stalled metadata endpoint is simply broken. Negative values fall back (`setConnectTimeout` throws on them, and a properties typo must not crash every call); `0` is honoured as the JDK's "wait forever", i.e. the documented escape hatch to the previous behaviour. Nothing reads these yet -- this commit only makes the values available, so the guard that follows can reference them and be red for the right reason. `NetworkConfig(store)` depends on nothing but the store, and nothing inside `ApiProperties` reads it, so the declaration-order landmine does not apply. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>Both guards land RED against a loopback ServerSocket which accepts the connection and then never writes a byte -- what a black-holing firewall or a stalled upstream looks like from the client side: isReachableGivesUpOnAServerThatAcceptsButNeverResponds FAILED (>15s) downloadFileGivesUpOnAServerThatAcceptsButNeverResponds FAILED (>15s) Both block in sun.net.www.http.HttpClient.parseHTTPHeader, reached from HttpURLConnection.getInputStream -- the read of the status line, with no bound on it. No HTTP call in the codebase sets a connect- or read-timeout, so they inherit the JDK default of "wait forever". Why this matters beyond the two methods: twelve of these sit on the *blocking* GUI startup path, ServerPackCreator.kt:228 -> ApiWrapper.stageTwo() -> VersionMeta.init -> checkManifests(). A host that DROPs rather than REJECTs leaves the splash screen stuck at 20 % indefinitely, recoverable only by killing the process. The fixture stubs the timeout properties explicitly -- it does **not** use a bare `mockk(relaxed = true)`, which answers `0` for an `Int`, and `0` is the JDK's "wait forever". A relaxed mock would reproduce the very defect these guards exist to catch, so they would stay red against fixed code and prove nothing. That is why the settings group landed first: the guard needs the real property names to reference. The guards use a bounded wait on a daemon thread rather than a direct call, because the defect is an *infinite* wait -- calling inline would hang the whole suite instead of failing one test. The 15s threshold is orders of magnitude above the timeouts configured here, so only an unbounded wait can trip it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>Verbatim move of `checkManifest` and both `updateManifest` overloads out of VersionMeta and into `versionmeta/ManifestUpdater`; VersionMeta keeps `checkManifest` as a one-line private facade and stays the class its collaborators use. Behaviour-preserving, deliberately to the letter: the version-counting `when`, its `var countOldFile/countNewFile` accumulators, the LegacyFabric equal-count-but-different-first-version nudge, the SAXException restore-from-jar branch and every log line moved across unchanged. No existing test's assertion, argument or expected value changed -- the whole api/app/clientside suites pass untouched. Two details worth recording: - `JarUtilities.copyFileFromJar` is now handed `ManifestUpdater::class.java` instead of `VersionMeta::class.java`. Identical by construction: it calls `identifierClass.getResourceAsStream("/$fileToCopy")` with an *absolute* resource path, which delegates to the classloader, and both classes share one. - Four imports in VersionMeta became unused with the move (JarUtilities, create, readText, org.w3c.dom.Document) and were dropped. Kotlin does not warn on those, so they would have lingered. Why extract at all: the next commit needs to pin *how many HTTP requests a manifest check costs*, and nothing about that was reachable from a test while the logic lived in VersionMeta -- it resolves its twelve URLs from VersionMetaConfig constants and performs the entire refresh inside its constructor, so there is no seam to point at a local server. This creates one. Measured: api 319 / app 108 / clientside 88, 1 skipped, all green before and after. Compiler warnings in -api: 21 before, 21 after. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>Six guards on ManifestUpdater against a local com.sun.net.httpserver.HttpServer (JDK built-in, no new dependency). Three land RED: aManifestCheckCostsOneRequest FAILED expected 1, was 2 anAbsentManifestIsDownloadedInOneRequest FAILED expected 1, was 2 anUnchangedManifestIsFetchedConditionally FAILED expected not <null> A check costs two requests, not one: a reachability pre-check whose response body is discarded, then the real fetch. Worse than doubling the count -- the pre-check calls disconnect() without draining the body, so the connection cannot be pooled and the real request pays a fresh TCP and TLS handshake. Twelve manifests therefore cost 24 requests and 24 handshakes across seven hosts before the splash screen moves past 20%. And nothing asks to be told only about changes: no If-Modified-Since, so every startup re-downloads all twelve manifests in full (~490 KB) and parses both the old and the new copy just to compare version counts, then discards the result -- which is the common case, because the manifests rarely change. The other three pin the behaviour that must survive the fix, and pass now: refresh when upstream has more versions, ignore upstream when it has fewer, and leave the local file alone otherwise. Note the honest caveat that aNotModifiedResponseLeavesTheLocalManifestAlone passes today for a *different* reason -- equal counts mean no refresh, since there is no 304 to short-circuit on yet. It becomes a guard on the 304 path in the next commit; it is not evidence of anything today. Pinning cost rather than wall-clock deliberately: a timing assertion would be flaky and would not say *why* startup is slow. Request counts do. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>Turns the three guards from the previous commit green. Two changes to ManifestUpdater.checkManifest: 1. Both isReachable pre-checks are gone. Each was a full GET whose body was discarded, followed immediately by the real fetch -- and because it called disconnect() without draining, the connection could not be pooled, so the real request paid a fresh TCP and TLS handshake. Startup went from 24 requests to 12 across seven hosts. The absent-manifest branch now attempts the download and reports its failure, which is one request instead of two and names the actual error rather than "unreachable"; updateManifest returns Boolean to carry that. 2. The check sends If-Modified-Since from the local file's mtime, and returns immediately on 304 -- no body, no parse, no rewrite. Measured against the real upstreams (curl, both plain and conditional), because whether this pays off is a fact about their servers, not about our code: honour If-Modified-Since -> 304/0 bytes launchermeta.mojang.com version_manifest.json 206,986 -> 0 meta.fabricmc.net intermediary 56,270 -> 0 maven.fabricmc.net fabric-loader 9,381 -> 0 maven.fabricmc.net fabric-installer 2,516 -> 0 ignore it, still answer 200 with the full body files.minecraftforge.net, maven.neoforged.net (x2), maven.quiltmc.org (x2), meta.legacyfabric.net (x2), maven.legacyfabric.net So 275,153 of 488,038 bytes per startup, ~56%, plus the 12 requests already saved above. Not the whole set, and deliberately not special-cased per host: a server which ignores the header answers 200 and every line below runs exactly as before, which is what keeps this an optimisation rather than a new policy. Follow-up worth having -- files.minecraftforge.net ignores If-Modified-Since but DOES honour If-None-Match against its ETag (verified: 304/0), another 121,492 bytes, the second-largest manifest. That needs somewhere to persist an ETag per manifest, so it is its own change. Using the local file's mtime as If-Modified-Since is conservative-safe: it can only ever cause a redundant 200 (our write time is >= the server's Last-Modified at the time), never a missed update, and the version-count comparison still gates every replacement. One behaviour is deliberately preserved and guarded in the commit that follows: being offline stays a WARN. Dropping the pre-check moved that case onto the IOException path, which would otherwise have logged twelve ERRORs with stack traces on every networkless launch and buried any genuine failure. Reaching the host and understanding its answer are now caught separately -- connection failure warns (with the exception at DEBUG), an unparseable manifest still errors. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>Definition-of-done paperwork for the two phases on this branch, plus three deferred items. Root CLAUDE.md: four rows in the API behaviour-change table (timeouts on every published HTTP call; why openTimedConnection returns URLConnection and must not be narrowed; the manifest-refresh request/byte reduction and its one visible log change). Refactor-state table: api 309 -> 326, status date to 2026-08-17. serverpackcreator-api/CLAUDE.md: NetworkConfig added to the settings-group notes (now 9 groups), and two landmines -- never open a connection outside WebUtilities.openTimedConnection/openTimedStream, and ManifestUpdater's one-request-per-check rule with the WARN-not-ERROR offline logging. REFACTOR-LOG.md: the narrative, including the four findings from the initial investigation that were **wrong or overweighted** and got corrected before any code was written. Recorded deliberately -- the wrong versions were stated out loud, so the correction belongs in the history rather than being quietly dropped. BACKLOG.md, three new items: B30 If-None-Match for the Forge manifest -- measured, another 121,492 B (57 % of what still transfers) but ~0 ms of startup, because the twelve checks run concurrently and the critical path is LegacyFabric at ~330 ms for 498 bytes. Only matters below ~4 Mbit/s, and only for one host. Deferred, not rejected: the bandwidth is real on a metered connection. B31 take the manifest refresh off the blocking startup path -- the larger prize the same measurement exposed (~392 ms -> ~0). Needs VersionMeta's construction contract weakened, so the grinder and the web version-schedule have to be checked against it first. B32 hasteBinPreChecks reading a whole file to measure its length. One correction not caused by this branch: the app row said 102 tests, the suite runs 108. Pre-existing drift, corrected to the measured number without trying to reconstruct which six were added when. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>Closes audit findings F3 (documentation half) and F4. F4 — `ManifestUpdater` was extracted as a **public** class with a **public** `checkManifest`, in a commit labelled a verbatim move whose message did not mention the widening. `serverpackcreator-api` is published and its public surface is a stated plugin-compatibility constraint, so that added an exported type for what is an implementation detail of `VersionMeta`. Verified before narrowing: the only users are `VersionMeta` (same package) and `ManifestUpdaterTest` (same package, same module), so `internal` costs nothing. The root CLAUDE.md row that described it as "new exported" is corrected to "internal". F3 — the landmine claimed `url.openConnection()`/`openStream()` outside the opener "was every single call site" before 2026-08-17, while two genuinely unbounded network calls were still live. It now: - names both sanctioned routes and why there are two (a settings group cannot reach `WebUtilities` without closing a construction cycle; `-app`'s `VersionChecker` is abstract with a no-argument constructor); - lists the two remaining unrouted sites, `ServerPackCreatorPlugin.kt:68` and `ClassUtilities.kt:60`, as benign because they read a `jar:` URL rather than a socket; - says plainly that it once overstated its own coverage, and tells the next reader to grep rather than trust the list. Two testing traps recorded while there, both learned the hard way on this branch: - the first timeout pin does **not** go red -> green (its `mockk(relaxed = true)` fixture answered 0, the JDK's "wait forever", so it had to be edited); the later pins were written against existing signatures so they do, and are the ones to copy; - a hang guard's bound must not equal the timeout it measures, or it races between "gave up as configured" and "waited forever". Plus a new behaviour-change row for the two calls bounded in the preceding commits. No behaviour change here: narrowing visibility to a type nothing outside the module references, and documentation. `./gradlew build` green. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>Seven guards on ConfigEditorViewModel. Three land RED: aVersionTripleIsProbedOnlyOnce FAILED 20 probes, expected 1 anUnchangedModpackDirectoryIsReadOnce FAILED 20 reads, expected 1 aDirectoryWithoutManifestsFallsBackToItsName FAILED 5 reads, expected 1 Both costs are paid per debounce tick, per open tab, and the debounce is restarted by a document change in *any* field (ConfigEditor.kt:80 -> checkAll() -> TabbedConfigsTab.kt:229). So every time the user pauses typing for half a second, each open config tab sends an HTTP request to the modloader's maven for the installer URL, and re-parses the modpack's launcher manifest into a Jackson tree. Neither input has changed; both answers are pure functions of state that is sitting still. The manifest is the bigger of the two -- a real CurseForge minecraftinstance.json is multi-megabyte, and this repo's own fixture is 2.7 MB. The other four pass now and exist to constrain the fix rather than to demonstrate the defect: - eachDistinctVersionTripleIsProbedOnItsOwn -- the memo must key on all three versions, not collapse to "asked once, answered forever". - aFailedProbeIsRetried -- deliberately asymmetric. A published installer does not vanish, so a success is safe to keep; a failure may only mean the network blinked, and caching it would leave the editor stuck on "server unavailable" until restart. - aChangedManifestIsReadAgain -- a user editing their modpack while the editor is open must see the new name, so the memo has to invalidate on mtime. - aDirectoryWithoutManifestsFallsBackToItsName -- also covers the no-manifest fallback to the directory name. Pinning call counts, not wall-clock: the defect is redundant work, and a count says so exactly where a timing assertion would be flaky and silent about the cause. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>Turns the three guards from the previous commit green by memoizing both answers in ConfigEditorViewModel. Both were recomputed on every debounce tick, per open tab, from inputs that had not changed. The network probe. `serverDownloadable` is an HTTP request to the modloader's maven, and its only caller is the 500 ms debounce restarted by a document change in any field. Now cached per (minecraftVersion, modloader, modloaderVersion). Measured earlier on this branch, that request is ~234 ms against files.minecraftforge.net -- so a user pausing while typing was paying that per tab, repeatedly, to decide whether to show one warning label. **Successes are cached, failures are not:** a published installer does not vanish, but a `false` may only mean the network blinked, and remembering it would leave the editor insisting "server unavailable" until restart. Pinned both ways. The manifest parse. `checkManifests` builds a Jackson tree from the launcher manifest; now re-read only when the fingerprint of the six candidate files changes (existence, size, mtime). Measured against this repo's own CurseForge fixture: fixture 2,715,835 bytes full parse (before) 4.70 ms per tick 6-stat fingerprint 0.021 ms per tick ratio 221x Honest reading of that: the latency saved per tick is modest -- 4.70 ms, not the tens of milliseconds a 2.7 MB document intuitively suggests, because Jackson is fast. The garbage avoided is the real gain, since the old path allocated and discarded a tree of that whole document on every keystroke-pause, per tab. The network probe above is by far the larger of the two wins. The fingerprint reads `ConfigurationHandler.manifestCandidates` rather than listing the paths itself, so it cannot drift from the files actually consulted -- a drifted list would make the memo miss real edits, which is the failure `ManifestCandidatesTest` guards in `-api`. Both caches are `ConcurrentHashMap`-backed: the check walks the open tabs on a `parallelStream`, so several threads ask at once. Measured: api 329 / app 108 / clientside 88, all green. -app warnings 35 -> 35. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>anUnchangedSuggestionListIsParsedOnce FAILED "An unchanged property must not be re-parsed" Red on **identity**: two distinct TreeSet instances come back whose *contents are identical*. That is exactly why identity is the assertion and a value comparison would have guarded nothing. SuggestionProvider re-reads and re-parses its autocomplete property on every document event, on the EDT. For the clientside-mods field that property is the ~550-entry fallback list, so each character typed costs a property read, a split(",") and 550 sorted inserts into a fresh TreeSet before one suggestion can be shown -- and the answer cannot have changed unless the property did. Note what is deliberately *not* asserted: the property read count stays at 20, one per query. Reading it is a map lookup, and keying the memo on the raw value is what lets a saved suggestion-list be picked up with no change-listener. Rebuilding the set was the cost. An earlier draft of this guard demanded one read and was simply wrong -- it pinned a claim the design does not make, and no correct implementation could have satisfied it. Three guards pass already and constrain the fix: eachCallerGetsItsOwnMutableSet -- the load-bearing one. Every production caller mutates the returned set and persists it: ConfigEditor.saveSuggestions adds the current field value, InclusionsEditor.saveSuggestions adds and removeIfs. So caching must cache the *parse* and keep handing out a fresh owned copy; caching the instance would let those mutations corrupt the source and accumulate across calls. aChangedSuggestionListIsPickedUp -- saving suggestions writes the property back, so a memo keyed on its raw value must invalidate immediately. anAbsentPropertyYieldsNoSuggestions -- no suggestions, not the literal "null". Headless: only the suggestion source is exercised, no popup is shown. The provider is built against a real JTextArea because it registers listeners on one, which constructs without a display. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>Turns the previous pin green, unedited. Three changes to SuggestionProvider, all on the per-keystroke path: 1. `parsedSuggestions()` reuses its result until the property changes. The memo is keyed on the raw property value, not a change-listener: saving suggestions writes it back through storeGuiProperty, so comparing the string is both the cheapest check and the one that cannot miss an update. The property read itself stays per-call on purpose -- it is a map lookup; rebuilding a 550-entry sorted set was the cost. `allSuggestions()` still returns a fresh TreeSet, which is load-bearing: every production caller mutates the result and persists it. Caching the *instance* would let those mutations corrupt the source and accumulate. The hot path reads the parsed set directly, since it only reads and needs no copy. 2. showPopup no longer calls updateUI() on the list and the menu. updateUI() re-installs the look-and-feel delegate and exists for a LAF *change*; it ran on every keystroke. Replaced with revalidate/pack/repaint, which is what new content actually needs. 3. The `\W` word-boundary check is a companion constant instead of `"\\W".toRegex()` compiled per keystroke. Deliberately NOT done: a `TreeSet.tailSet(prefix)` prefix-walk instead of the linear startsWith. It looks like the obvious optimisation and it is a correctness trap -- the match is case-INsensitive while the set's ordering is case-sensitive, so matches are not contiguous and tailSet("op") would skip "OptiFine"; making the set case-insensitive instead would silently deduplicate entries differing only in case. A linear scan over a few hundred already-parsed strings is microseconds. Recorded in a comment so nobody "optimises" it into a case-folding bug. GUI-verified: the popup resizes with its content, 56x85 px at 5 matches -> 54x34 px at 2, correctly filtered, first row preselected, positioned at the caret. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>Definition-of-done paperwork for Phase 2. serverpackcreator-app/CLAUDE.md: two new sections. The check timer is a 500 ms debounce on the typing path running for every open tab, so what it does per tick is what matters -- both memos, the success-cached/failure-retried asymmetry, and the rule that the manifest fingerprint must read ConfigurationHandler.manifestCandidates rather than its own copy of the paths. Also records that the timer's ten `launch { }` blocks inside `runBlocking { }` are *not* concurrent (single event-loop dispatcher), which is the only reason the shared errors list is safe -- making them concurrent would introduce a race. Second section covers SuggestionProvider: the parse cache keyed on the raw property value, why allSuggestions() must keep copying, the tailSet landmine (case-insensitive match over case-sensitive ordering, so matches are not contiguous and tailSet("op") skips OptiFine), and revalidate/pack/repaint instead of updateUI(). It also records **how** the popup was GUI-verified, because the technique is reusable: osascript has no Accessibility permission here, so no synthetic clicks or keystrokes. A throwaway JUnit harness drove Swing from inside the test JVM (-app tests are not headless), inserting characters on the EDT and logging each visible JList's row count and preferredSize while screencapture took stills. Evidence: the popup resizes with its content, 56x85 px at 5 matches -> 54x34 px at 2, correctly filtered, first row preselected, positioned at the caret. Focus must be re-asserted before each burst -- the popup only shows while the component isFocusOwner, and a first attempt looked like a failure when it was merely unfocused. Harness deleted. REFACTOR-LOG.md: the narrative, the measured table, and the guard that was wrong first -- it verified the property-read count, a claim the design deliberately does not make. Rewritten to assert reuse by identity, then deliberately re-broken to check it had teeth: it fails with two distinct TreeSet instances whose contents are identical, which is why identity is right and a value comparison would have guarded nothing. Refactor-state table: api 326 -> 329, app 108 -> 118. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>Three guards, all RED: theExclusionFilterIsReadOncePerGeneration 7 reads, expected 1 regexEntriesStillClassifyEveryMod 5 reads, expected 1 aMalformedRegexEntryIsSkippedRatherThanAborting PatternSyntaxException escaped `compileModList` compares every mod against every clientside-list entry. The default list ships ~550 entries, so a 300-mod pack performs on the order of 165,000 comparisons per generation -- and each one re-reads `apiProperties.exclusionFilter`, whose getter calls `PropertyStore.acquire`, which reads `java.util.Properties` (a synchronized `Hashtable`) twice. Two synchronized map lookups per comparison, for a value that cannot change mid-generation. The read counts above match exactly: 1 for the log line plus one per comparison (3 mods x 2 entries, and 4 mods x 1 entry). Note this is the **default** START path, not just REGEX -- the filter default is START (GenerationConfig.kt:815), so the earlier claim that the per-comparison `entry.toRegex()` was the main cost had it backwards: that one only affects users who chose REGEX or EITHER, while this affects everyone. The third guard covers a real defect on the REGEX path: `entry.toRegex()` per comparison means one malformed user entry throws PatternSyntaxException out of compileModList and aborts generation, instead of reporting one unusable entry and applying the rest. Fixing that is a behaviour change, so it lands as `fix:`. Pinned by read count, not wall-clock: the count is the defect and it is deterministic. Separate class from ModListCompilerTest so the existing tests keep their real ApiWrapper graph. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>Turns the three guards from the previous commit green. `FilterMatcher` is built once per compileModList and reused for every comparison; the exclusion-filter setting is read once, and REGEX/EITHER patterns are compiled once per entry instead of once per comparison. **The primary value is the bug fix, not the speed — and the measurement says so.** Under REGEX/EITHER, `entry.toRegex()` ran per comparison, so a single malformed user entry threw PatternSyntaxException straight out of compileModList and aborted generation instead of reporting one unusable entry. Now the compile happens up front, a bad pattern is logged once and skipped, and every other entry still applies. Measured at realistic pack scale (300 mods x 550 default entries = 165,000 comparisons), because the earlier estimate was wrong and should be corrected on the record: 2 synchronized Properties lookups per comparison : 3 ms (default START path) one Pattern.compile per comparison : 20 ms (REGEX/EITHER only) So ~3 ms for most users and ~23 ms for regex users, not the substantial win the plan implied when it called this "the one that affects everyone". `Hashtable.get` is fast and its monitor is uncontended, so 330,000 of them simply do not cost much. The change is still worth keeping -- it is simpler, hoists genuinely invariant work, and fixes the abort -- but it should not be sold as a performance result. That also corrects the previous commit message, which framed the property read as the main cost. It is real and it is now gone; it was just never large. Behaviour changes, both deliberate: a malformed regex entry is skipped rather than fatal, and its error is logged once per generation rather than once per mod. Measured: api 329 -> 332, app 118, all green. Existing ModListCompilerTest untouched. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>Adds a defaulted `openZip: (File) -> ZipFile` constructor parameter, and routes all four `ZipFile(...)` sites through it. Behaviour-preserving: the default is exactly `{ ZipFile(it) }`, and every method still opens the archive the same number of times. It exists so the *next* commit's guard can count those opens. Without it the duplication is invisible -- every method returns the same answer whether it opened the archive once or four times, which is precisely how it survived unnoticed. Defaulted, so it is source-compatible: Kotlin still emits a no-argument constructor, and the one construction site (`ConfigurationHandler`) is unchanged. Its own commit rather than bundled with the guard, because it is production code and the guard is not -- a `test:` commit that ships a seam misrepresents its own diff. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>validatingAnArchiveReadsItOnce 2 opens, expected 1 listingEverythingReadsTheArchiveOnce 2 opens, expected 1 Reading the central directory is the expensive part of inspecting a modpack archive and it scales with the entry count -- measured 79.9 ms for a 10,000-entry archive, and a real export can hold far more. Two sites do it twice over: checkZipArchive opens once for isNotValidZipFile(), then again via getDirectoriesInModpackZipBaseDirectory, when the first open already has every header the check needs. getAllFilesAndDirectories.. delegates to one method for directories and another for files, each opening the archive, where a single pass over the headers partitions both. So ~80 ms wasted per validation and another ~80 ms per full listing, on that 10,000-entry archive. Two guards pass already and constrain the fix: the single pass must agree with the dedicated per-kind methods, and an invalid archive must still be rejected in one read (the error path must not be where a second parse creeps back). Fixture note, found the hard way: zip4j's `addFile` with a path-in-zip writes no explicit **directory** entries, so the first version of these tests saw zero directories and failed for the wrong reason. Built from a real tree with `addFolder` instead. Incidentally that documents why checkZipArchive works without them -- it derives `mods/` from a *file* entry's name, not from a directory entry. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>Turns the two open-count guards green. checkZipArchive validity check and base-directory scan now share one open. Extracted `baseDirectoriesOf(entryNames)` so the scan can work from headers already in hand; getDirectoriesInModpackZipBaseDirectory keeps its published signature and now delegates to it. getAllFilesAndDirectories.. one pass over the headers, partitioned, instead of delegating to the two per-kind methods and paying for two opens. Directories still come first, as they did when they were two calls. Measured on a 10,000-entry archive: one central-directory read is 79.9 ms, so this is ~80 ms saved per validation and ~80 ms per full listing -- and it scales with the archive, which is why it was worth doing properly. One behaviour change on the error path, which the compatibility table records: the two per-kind calls each had their own try/catch, so a failure fetching files still returned the directories and logged twice. It is now one pass, so a failure returns an empty list and logs once. Barely reachable -- both old calls opened the *same* archive -- but an embedder treating a partial list as usable now gets nothing instead. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>regexVariantListsAreNotSharedBetweenReads FAILED "expected: not same" `GenerationConfig.clientsideModsRegex` and `modsWhitelistRegex` have getters that `clear()` and refill one shared `TreeSet` field on every access, then hand it out. Two consequences, and the second is why this is a defect rather than untidiness: - a caller holding an earlier result has it emptied and rewritten underneath them; - the clear-then-refill is not atomic, so a concurrent reader can observe the set part-way through, i.e. a list that is briefly wrong rather than merely stale. Both are published via `ApiProperties.clientsideModsRegex` / `modsWhitelistRegex`, and the GUI reads settings from a `parallelStream` walk over its open tabs, so concurrent access is not hypothetical. Verified first that nothing depends on the current aliasing: the `private set` is never assigned anywhere in `-api` or `-app`, and the two readers (`clientSideMods()`, `ApiProperties.kt:1276`) both immediately `.toList()`. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>anExistingHashIsFoundWithoutScanningTheCollection FAILED findAll() called anUnknownHashReportsNoDuplicate FAILED findAll() called aNullHashReportsNoDuplicate FAILED expected true, was false `existingUploadOf` loads every modpack and compares in memory, on every upload. That is heavier than a row count suggests: `ModPack.serverPacks` is an eager `@DBRef`, its targets eagerly resolve their `RunConfiguration`, and that resolves three more `@DBRef` lists -- so comparing one hash reads a four-collection graph and materialises the ~550 ClientMod documents behind each run-configuration on the default list. The third guard is about semantics rather than cost, and is honestly scoped: with the in-memory scan, `available.sha256 == sha256` is true when *both* are null, so a hash-less upload would be reported as a duplicate of any stored modpack that also has none. **Not reachable from the upload path today** -- `SavedFile.sha256` is a non-null String, so `saveUploadedFile` always passes a real hash. It is guarded because the parameter is nullable, stored documents genuinely can carry a null `sha256` (the no-arg constructor and the non-ZIP sources leave it unset), and Mongo's own `{sha256: null}` query would match those, so the fix has to say no explicitly rather than inherit the right answer. The unused `findAll()` stubs are intentional: without them mockk fails with a missing-answer exception instead of naming the redundant scan. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>Turns the three duplicate-check guards green. `existingUploadOf` calls `modpackRepository.findBySha256` against the newly-indexed field instead of loading the whole collection and comparing in memory. One indexed document lookup replaces a scan that also resolved, per document, the eager `@DBRef` chain ModPack.serverPacks -> ServerPack.runConfiguration -> start-args/clientside-mods/whitelist -- four collections, and the ~550 ClientMod documents behind every run-configuration on the default list, to compare one string. It runs on every upload. Behaviour change, deliberate and narrow: a **null** hash now returns empty instead of matching stored modpacks whose own sha256 is unset. The old `available.sha256 == sha256` comparison called that a duplicate, and a Mongo `{sha256: null}` query would too, so the short-circuit is explicit. Unreachable from the upload path today (`SavedFile.sha256` is non-null), so nothing observable changes for users; it is here because the parameter is nullable and stored documents genuinely carry null. Also unchanged on purpose: first-match-wins is now whichever-document-the-index-returns. Indistinguishable unless two stored modpacks share a hash, which is precisely what this check exists to prevent. Measured: app 120 -> 123, all green. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>`RunConfiguration.startArgs`, `clientMods` and `whitelistedMods` become `MutableList<String>` embedded in the document. `ClientMod`, `WhitelistedMod`, `StartArgument`, their three repositories and the shared `ModRepository` are deleted. The migration that converts existing databases ships **in this same commit**, deliberately: split apart, the intermediate state is an application whose mapped type cannot read its own stored run-configurations, so neither deploying nor bisecting to it is safe. Why the wrappers were pure overhead: each was a `@Document` whose *only* field was its `@MongoId`. A `ClientMod` document is literally `{_id: "OptiFine"}`, so the eager `@DBRef` resolved to the string it was already keyed by. Three collections and four repositories existed to store nothing. What that cost, per created run-configuration: each list was resolved entry by entry, one `findBy` plus a `save` on a miss. With the default clientside list that is ~550 sequential round-trips to build one configuration, and it is on the request path. Now two calls total -- the duplicate lookup and the save -- pinned by `buildingAConfigurationCostsTwoRepositoryCalls`. It also removes an eager join from every read that reaches a RunConfiguration, which is what made `findAll()` on the server packs or modpacks fan out across four collections. **A real bug goes with it.** The duplicate lookup was `…AndStartArgsInAndClientModsInAndWhitelistedModsIn`, and Spring Data's `In` means "contains any of", not "equals" -- so a configuration could be matched, and reused, because it shared a *single* mod with the one being created. It is now `…AndStartArgsAndClientModsAndWhitelistedMods`, an exact array match. **Four tests were deleted rather than adapted**, and that is the honest signal it looks like: `aKnownStartArgumentIsReplacedByTheStoredEntry`, `anUnknownStartArgumentIsSaved`, `aKnownClientModIsReplacedByTheStoredEntry` and `aKnownWhitelistedModIsReplacedByTheStoredEntry` describe resolution against collections that no longer exist. The two mod-list tests also covered comma-splitting, which is preserved as `clientModsAreSplitOnCommas` / `whitelistedModsAreSplitOnCommas` so no coverage is lost. Every surviving assertion is byte-identical apart from dropping the `.map { it.mod }` unwrapping. The migration, in `web/migration/`: - `RunConfigurationListMigration` is the per-document rewrite, and it is **join-free**: a DBRef's `$id` *is* the value, so `{$ref:"clientMod",$id:"OptiFine"}` becomes `"OptiFine"` without reading the referenced collection -- which also means it still works after those collections are dropped. Tested without a database. - `RunConfigurationListMigrationRunner` applies it on `ApplicationReadyEvent` rather than during context startup, so a slow or briefly unreachable database delays the migration instead of preventing the boot. Element-wise, so an interrupted run is *completed* rather than corrupting a half-rewritten document; idempotent, so a restart costs one read and no writes; failures logged and swallowed; the orphaned collections dropped only after a fully successful pass, because losing the referenced ids first would make the data unrecoverable. - `MongoTemplate`, not the repository, necessarily: the mapped type can no longer read the old shape, which is the entire problem. Frontend, same commit because it is one contract: `types/api.ts` declares `string[]`, and the `.map(entry => entry.mod)` unwrapping in `RunConfigurationCard.vue` and `SubmitModPackForm.vue` (both sites) is gone. The Vitest fixtures move to the new shape; **their expectations are untouched** and all 31 pass. Operators should back up before upgrading, as with any in-place data rewrite. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>Seven guards over the component that **mutates a user's persisted data** — the only substantial one on this branch that had no coverage, because its pure transformation was well tested and the untested half was the part that talks to the database. What they pin, each of which can lose data if wrong: everyRewriteHappensBeforeAnyDrop asserted on call *order*, since the end state looks identical either way aFailedRewriteLeavesTheOrphanedCollections a half-rewritten database must keep the ids it still needs anAlreadyMigratedDatabaseIsNotTouched no writes, and crucially no drops aFreshInstallDropsNothing an empty pass is not licence to delete oneFailedDropDoesNotStopTheRest an unused collection left behind is harmless; an aborted migration is not anUnreachableDatabaseDoesNotFailStartup it runs on ApplicationReadyEvent onlyTheDocumentsStillInTheOldShapeAreRewritten **Their teeth were verified rather than assumed**, since they were written after the code -- the lesson this project records twice already. Breaking the production code deliberately: drop before the rewrite -> 4 guards fail, incl. everyRewriteHappensBeforeAnyDrop drop when nothing rewritten -> 2 guards fail, incl. aFreshInstallDropsNothing Test-only, now that the seam it needs landed in the preceding commit. It was originally one commit with that seam -- the same `test:`-ships-production violation being fixed elsewhere on this branch, which is a worse failure when it is one's own work than when it is inherited. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>Closes audit finding X1, raised by the second pass over the remediation itself. `createHasteBinFromString` opened a bare connection and set the two timeouts by hand, because it needs `HttpsURLConnection` for its POST. It was bounded, so never a hang -- but the landmine had just been rewritten to say exactly two ways of applying a timeout exist, and this was a third. It now calls `openTimedConnection` and narrows the result, so `WebUtilities` contains a single `openConnection()`: the shared opener. Also completes REFACTOR-AUDIT.md with the second-pass results and a status table for all 22 findings across the four branches. Second-pass verification on the final tip: - 1,531 tracked source/doc files scanned for NUL bytes: none. (The first attempt used `grep -qU $'\000'`, which degrades to an empty pattern and "found" 539 matches including every PNG. A check that reports everything is broken, not thorough.) - No unbounded network call remains; the two surviving unrouted sites read `jar:` URLs and are named in the landmine as deliberate exceptions. - The new runner guards were verified to have teeth by breaking the code: dropping before the rewrite fails 4 of 7, dropping when nothing was rewritten fails 2. - Stack: 12 / 8 / 8 / 8 commits, no duplicate subjects, build green. Recorded honestly in the report: while rebasing the stack I used the wrong upstream and dropped seven of the web branch's eight commits, recovering them from the reflog. The correct form is `--onto <new-base> <old-base>` with the parent's *pre-rebase* tip. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>Closes iteration-3 audit findings Q1, Q2 and Q3 -- the most substantive of the three re-audits, and the only ones no structural check could have found. All three were written alongside their production code, so none ever had a red state: exactly the population this project's conventions single out as highest risk. Found by mutation -- break the production code, see whether the suite notices: Q1 aFailedProbeIsRetried stubbed a failure, then a success, and asserted `true`. Caching failures as well as successes makes the second call short-circuit to `return true`, satisfying that assertion **without probing at all** -- so the guard could not distinguish "re-probed" from "wrongly remembered", which is the entire asymmetric caching decision it claims to protect. Now also verifies the probe ran twice. Q2 theSinglePassAgreesWithTheDedicatedMethods compared both sides `.sorted()`, throwing away the directories-first order the production code promises in a comment and the commit message repeated. Inverting the partition changed nothing. The `.sorted()` calls are gone, so the order is now pinned rather than merely asserted in prose. Q3 aNullHashReportsNoDuplicate stubbed `findBySha256(null)` to return empty and asserted empty -- verifying the mock, not the code. Removing the short-circuit it exists to guard changed nothing. Now verifies the repository is never consulted, which is the actual point: Mongo's own `{sha256: null}` *would* match documents whose field is unset. **The fixes were mutation-tested rather than trusted.** Re-running the three mutations that previously slipped through: cache failures too -> 1 of 13 fails (was: all passed) partition inverted -> 1 of 7 fails (was: all passed) null short-circuit gone -> 1 of 3 fails (was: all passed) Every other new guard on the branch was checked the same way and does bite: the migration rewrite, the manifest candidate order, a shipped timeout default, the allSuggestions copy, the manifest fingerprint, and the runner's drop ordering. Recorded as a landmine in serverpackcreator-api/CLAUDE.md, with the method and the rule it implies: prefer asserting *that a collaborator was or was not called* over asserting a return value a wrong implementation could also produce. `./gradlew build` green; api 339 (1 skip), app 135, clientside 88, frontend 31. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>Red pin for iteration-4 audit finding R1. Lands failing, on purpose: the fix is the next commit. `fix(app): look an upload's hash up by index instead of scanning every modpack` put `@Indexed` on `ModPack.sha256` and its repository KDoc states the consequence as fact -- "so this is a single indexed lookup rather than a scan". No index is ever created. Spring Data MongoDB stopped creating annotation-declared indexes automatically in 3.0. Verified from the resolved artifacts rather than from memory: javap -c MongoMappingContext (spring-data-mongodb 5.1.0) the no-arg constructor emits `iconst_0; putfield autoIndexCreation:Z` -- false javap -c DataMongoConfiguration.mongoMappingContext (spring-boot-data-mongodb 4.1.0) PropertyMapper.from(properties.isAutoIndexCreation()).to(context::setAutoIndexCreation), and PropertyMapper skips a null source, so an absent property leaves that default spring-configuration-metadata.json in the same jar spring.data.mongodb.auto-index-creation exists with no default value grep across the repo no property, no MongoMappingContext bean, no AbstractMongoClientConfiguration Two guards, because either alone is worthless. The switch half fails now (expected <true> but was <null>). The entity half passes now and is here to stay honest about *what* the switch will create: it runs Spring Data's own MongoPersistentEntityIndexResolver over the mapped type rather than asserting that an annotation is present, so removing `@Indexed` fails it too. Observed red: theShippedConfigurationCreatesDeclaredIndexes FAILED, anIndexOnTheUploadHashIsResolvedFromTheEntity PASSED -- 2 tests completed, 1 failed. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>Correction to the pin in `test(app): pin that the declared upload-hash index is actually created`, which is still red -- and red for the right reason but the wrong file. `getResourceAsStream("/application.properties")` resolves against the *test* classpath, where `serverpackcreator-app/src/test/resources/application.properties` shadows the shipped one. So the guard was asserting about a 720-byte test fixture, not the 1,159-byte file the running app reads, and adding the property to main resources left it failing. Every copy is now enumerated with `getResources` and the test one discarded, with the count asserted so a second shipped copy cannot appear unnoticed. Observed red against the still-unfixed main resource: theShippedConfigurationCreatesDeclaredIndexes FAILED (expected <true> but was <null>), anIndexOnTheUploadHashIsResolvedFromTheEntity PASSED. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>Closes iteration-4 audit finding R5. `RunConfigurationListMigrationRunner.COLLECTION` is the literal `"runConfiguration"`, commented as "the collection Spring Data maps `RunConfiguration` to". It is that today. If it ever stops being -- a rename, or a `@Document("…")` naming the collection explicitly -- `findAll` reads a collection that does not exist, returns nothing, rewrites nothing, drops nothing, logs no failure, and the migration reports success. Persisted data silently stays in the old shape, which the mapped type can no longer read. Nothing existing catches that: all seven guards in `RunConfigurationListMigrationRunnerTest` drive the store through this same constant, so they agree with it however wrong it is. The new guard asks Spring Data instead of repeating its naming rule, via a standalone `MongoMappingContext` -- no database needed. **This one lands green, deliberately, and was verified by mutation instead of by a red state.** It guards a latent risk rather than fixing a present defect, so there is nothing to make it fail today; per the landmine in serverpackcreator-api/CLAUDE.md, teeth were checked by breaking the production code: COLLECTION -> "runConfigurations" new guard FAILED (+1 existing, incidentally) @Document("run_configurations") on entity new guard FAILED, all 7 existing PASSED The second is the realistic scenario, and it is caught by this guard alone -- which is the finding, stated as an experiment. The three `ORPHANED_COLLECTIONS` are deliberately not checked this way: their classes were deleted, which is the point of dropping them, so a literal is all that remains. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>Closes iteration-4 audit finding R3. `fix(app): embed the run-configuration mod lists, and migrate what is stored` changed `RunConfiguration.startArgs` / `clientMods` / `whitelistedMods` from `MutableList<StartArgument>` and friends to `MutableList<String>`, and deleted those three classes. `RunConfigurationController` returns the entity directly under `@RequestMapping("/api/v2/runconfigs")`, so the response body changed from "clientMods": [{"id": 1, "mod": "3dskinlayers-"}] to `"clientMods": ["3dskinlayers-"]` -- on a path carrying an explicit API version. Both published descriptions still documented the old shape: Writerside/api-docs.yaml the three properties `$ref`-ed #/components/schemas/StartArgument, /ClientMod and /WhitelistedMod, and defined all three -- schemas for classes that no longer exist. Now `items: {type: string}`, and the three orphaned definitions are gone (verified referenced from nowhere else; the file still parses as YAML). topics/*.md 30 sample arrays across Run-Configs.md, Server-Packs.md and Modpacks.md rewritten to string elements, keeping the `...` elision markers the samples already used. `RunConfiguration` is embedded in ServerPackView, which is why the other two files were affected. `serverpackcreator-app/CLAUDE.md` claimed "The JSON shape is part of this contract" but listed only `types/api.ts` and the two Vue consumers, so the obligation it stated was met while the documented external contract was not in the list. It now names the help module too, and says plainly that nothing in the build can catch a miss: `serverpackcreator-help` is not a Gradle module and `springdoc` is commented out, so that spec is a hand-maintained snapshot to be treated as source. Scoped honestly: the spec carries older drift of its own -- it types `id` as `integer/int32` where the entities use `@MongoId(FieldType.STRING)` -- which predates this branch and is left alone and recorded rather than silently "fixed" under an unrelated heading. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>Red pin for iteration-5 audit finding S1 -- a regression introduced by iteration 4's own fix, `fix(app): create the indexes the web module declares`. `spring.data.mongodb.auto-index-creation=true` does not merely permit index creation: it makes `MongoTemplate`'s bean creation perform it during context refresh, so the database must be reachable *then*. Measured against an absent MongoDB, one variable changed, same context: auto-index-creation=false context starts, ModPackService resolves auto-index-creation=true "Waiting for server to become available for operation createIndexes with ID 3. Remaining time: 29997 ms", then MongoTimeoutException -> mongoTemplate fails -> refresh CANCELLED, context dead So web mode went from "starts, logs a connection error, works once the database appears" to "hangs 30 s and dies if the database is not up yet". `docker/docker-compose.yml` starts the app alongside its `db` service, so losing that race is the normal first boot. It also contradicts a decision this module already documented, about the migration runner: "applies it on ApplicationReadyEvent, not during context startup, so an unreachable database delays the migration instead of blocking the boot." **Why iteration 4 missed it, which is the more useful half.** `WebServiceContextTest` boots the real context with no database and would have failed in one second -- but `src/test/resources/application.properties` shadows the shipped file, so the setting never reached the context under test. Iteration 4 even worked around that shadowing to *read* the shipped file, and treated it as plumbing rather than as the reason its change was unverified. A guard that reads a shipped file is not a test that runs with it. This pin closes that: the shipped file is read *and* its value is handed to a real context boot, so the combination that broke is the combination asserted. Observed red: theShippedConfigurationDoesNotCreateIndexesDuringRefresh FAILED (auto-index-creation=true), theContextStartsWithoutADatabase PASSED -- the boot half passes because the annotation overrides the property to `false`, which is exactly the measurement above. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>Closes iteration-6 audit finding T1. This branch adds five `@Component`s -- `RunConfigurationListMigration`, `RunConfigurationListMigrationRunner`, `MigrationStore`, `IndexStore` and `DeclaredIndexCreator`. All five are well unit-tested, and every one of those tests **constructs the class directly**, so they pass whether or not Spring ever creates the bean. Nothing asserted registration: `WebServiceContextTest` owns wiring and stopped at the controllers and four services. The failure mode is silent, and unequal in cost: DeclaredIndexCreator inert the index is never created -- iteration 4's R1 again, with its own guard still green RunConfigurationListMigrationRunner inert persisted data is never migrated, and the mapped type cannot read the old shape, so reads fail on real data while the whole suite is green Two guards, in the test that already owns wiring: the five beans resolve, and both deferred jobs listen for `ApplicationReadyEvent` specifically -- asserted on the *beans Spring holds*, not on the classes, because "registered" and "deferred" are the two halves that were missing. Running either during refresh is what made an unreachable database cancel the context earlier in this session. **Lands green, mutation-verified rather than red-first**, per the landmine in serverpackcreator-api/CLAUDE.md -- there is no present defect to fail against (the beans are genuinely wired; `WebService` is `@SpringBootApplication` in the parent package of both): drop @Component from DeclaredIndexCreator both new guards FAILED listener -> ContextRefreshedEvent deferral guard FAILED, naming both events Not a live defect. It is the guard gap that let R1 and S1 through twice in one session, which is the reason to close it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>Closes iteration-6 audit finding T2, and lands the iteration-6 report. `RunConfigurationCard.test.ts` asserted `expect(wrapper.vm.clientMods).toEqual(['optifine'])` -- the value it had just passed in, through a card that assigns `this.clientMods = runConfig.clientMods` unchanged. That holds for any element type, so it could not see the one thing the DBRef-to-embedded change risked: the card consuming the wrong element shape. Mutation, before: revert both render sites to the pre-branch object shape, `clientMods.map(m => m.mod).join(', ')` -- **all 31 frontend tests passed** while the card rendered `undefined, undefined`. Mutation, after: the same mutation **fails** the new guard, which asserts the rendered text contains each list's values and contains neither `undefined` nor `[object Object]`. The old pass-through assertion still passes under it, which is the finding restated as evidence. `startArgs` already had a rendering assertion, which is why the mutation had to touch the other two lists to stay invisible. The test's doc comment was stale in the same direction -- it described the card as flattening "the nested `{argument}` / `{mod}` objects from the backend", which is the shape this branch removed. It now says the lists are plain strings and why that means every one of them needs a rendering assertion. **Scope corrected in the report rather than left overstated:** T2's first draft accused `SubmitModPackForm.test.ts` of the same weakness. It is not -- its `clientMods` is a *derived* join performed by `selectedRunConfiguration`, so the assertion is shape-sensitive, verified by mutating that line and watching the guard fail. Only the card was blind. Frontend suite 31 -> 32, green. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>Closes iteration-4 finding R3's stated caveat, and iteration-7 finding U1. **Pre-existing drift, not caused by the performance work** -- recorded as such when R3 was raised, and fixed now rather than left, because R3 corrected the run-configuration shape in this same file and a spec that still mistypes every id is no more usable than one that mistyped the mod-lists. Every persisted entity keys on `@MongoId(FieldType.STRING)` -- verified per class in `ZipResponse`, `RunConfiguration`, `ServerPack`, `ErrorEntry` and `QueueEvent` -- while the spec described ids as `integer`. Corrected: 10 path parameters {id}, {modPackId}, {runConfigurationId} -> string 10 properties ZipResponse.modPackId/.runConfigId/.serverPackId, RunConfiguration.id, ServerPack.id, ServerPack.fileID (int64, not int32 -- missed by the first pass), ErrorEntry.id, QueueEvent.id/.modPackId/.serverPackId **Deliberately untouched, because they are genuinely numbers:** `ServerPack.size`, `.downloads` and `.confirmedWorking` are `Int` in the entity. **Deliberately untouched, because they cannot be verified:** `ServerPackView.id` and `ModPackView.id`. Those two schemas describe classes that **no longer exist anywhere in the module** -- the same dangling-schema defect R3 fixed for `ClientMod` / `StartArgument` / `WhitelistedMod`, except these predate this branch by an unknown margin. Guessing their id type would be inventing a contract; they are reported instead. Whoever owns the web API should decide whether those schemas and the paths referencing them still describe anything real. Nothing in the build can verify any of this: `serverpackcreator-help` is not a Gradle module and `springdoc` is commented out in `serverpackcreator-app/build.gradle.kts`. The file still parses as YAML, which is the only mechanical check available. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>Closes iteration-7 finding U2 by deciding it rather than half-doing it. The rule earned by iteration 6: assert what a component renders, not the prop you handed it. `RunConfigurationCard`'s old guard compared `wrapper.vm.clientMods` against the payload it had just passed in, through a card that assigns the array unchanged -- so it held for any element type, proven by mutation (the pre-embedding object shape rendered `undefined, undefined` with all 31 tests green). Recorded with the counter-example too: `SubmitModPackForm`'s equivalent assertion *is* shape-sensitive, because its value is derived by `.join(', ')` rather than passed through. `SubmitModPackForm`'s three tooltip render sites stay untested, by the same call already made for the three tables: they sit behind two layers of lazy Quasar rendering (`QBtnDropdown` renders on open, `QTooltip` on show), so reaching them means driving and stubbing both, for display-only duplicates of data already guarded where it is derived. The tempting cheap alternative -- asserting the shape of `runConfigurations` -- would only re-assert the axios mock, which is exactly the defect the entry above is about. Written down so the gap is a decision with a reason rather than an oversight. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>Iteration 7 asked the question the first six did not: does this branch still do what `develop` did? **Answer, by measurement: yes.** develop's unmodified test trees, run against HEAD's production code in a detached worktree -- api 309 (1 skip), clientside 88, app 93 -- **490 guards, zero failures**. Exactly two develop-era files could not compile, both at the branch's two deliberate shape changes: `ConfigEditorViewModelTest` was adapted by passing the two new constructor collaborators as relaxed mocks, with every assertion byte-identical (diffed to prove it) and all 7 guards green; and `RunConfigurationServiceTest`, which asserts the join behaviour the branch removed by design -- its three retired guards are enumerated in the report, and its replacement covers strictly more. Coverage was measured rather than assumed, including the correction that a class-name grep under-reports: `QuiltPackScanner`, the one genuinely algorithmic rewrite, is exercised only *indirectly* -- 30 `quilt` references in `ModScannerSidenessTest` -- and the `FilterMatcher` rewrite is covered across all five exclusion-filter modes, not just the common one. **And what only a real runtime can answer was asked of one.** The actual bootJar in `-web` mode against MongoDB 8.0.5 in Docker, seeded with pre-branch shaped documents: the `sha256` index really exists, the migration converted the legacy document to `["OptiFine","Sodium"]` while leaving the already-embedded one untouched (`Migrated 1 of 2`), all three orphan collections were dropped after the rewrite, and `GET /api/v2/runconfigs/all` returned the documented shape with a *string* id -- confirming the spec correction empirically. With no MongoDB at all, Tomcat and `Started ServerPackCreatorKt` come **first** and the failed index attempt after, which is S1's fix confirmed in production form. Both techniques are now conventions in CLAUDE.md, with the worktree recipe, because they are cheap, repeatable, and were missing. Two findings, filed as B33/B34 rather than fixed -- **neither caused by this branch**: B33 the web app appears to use MongoDB's default `test` database, not the configured one. HIGH and urgent-shaped: reproduced three times, `credential=null`, and a seeded `serverpackcreatordb` left untouched while `test` received everything. `WebserviceConfig.kt` has a zero diff against develop. Filed with full reproduction because it lives in config plumbing this branch never touched, and because it would make this migration a silent no-op on a correctly-configured instance. B34 the same URI does not reach the MongoClient in tests, which explains iteration 5's dead end -- the property demonstrably reaches the environment while the client keeps a 30 s timeout. Both attempts to shorten it are reverted; the ~30 s per no-database context boot stands as a stated cost. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>Red pin. Lands failing, on purpose: the fix is the next commit. Spring Boot **4.0.0 removed** `spring.data.mongodb.uri` -- the key ServerPackCreator writes. Its metadata carries `deprecation.level = "error"`, `replacement = "spring.mongodb.uri"`, and the connection properties moved from `DataMongoProperties` (`@ConfigurationProperties("spring.data.mongodb")`, which no longer has a `uri` at all) to `MongoProperties` (`@ConfigurationProperties("spring.mongodb")`). A removed key does not warn. It is simply not bound, so Boot falls back to `spring.mongodb.uri`'s own default, `mongodb://localhost/test`. Measured with the real bootJar, same URI, only the key differing: spring.data.mongodb.uri hosts=[localhost:27017] credential=null spring.mongodb.uri hosts=[127.0.0.1:27017] credential=MongoCredential{userName='spcuser'…} The host substitution is the tell -- `127.0.0.1` was configured, `localhost` is Boot's literal default. Consequences, all silent: writes land in `test` rather than the configured database, existing data is invisible, authentication is skipped, and every `SPC_DATABASE_*` container variable is ignored because `init-spc-config/run` writes the same dead key. This affects **shipped** versions -- `main` is on spring-boot-starter-web 4.0.3, develop/alpha on catalog springBoot 4.1.0. It is also the answer to the question `claude-docs/DOCKER-MONGO-INVESTIGATION.md` left open ("Still open: what makes the property absent entirely"), and therefore explains the reported symptom that SPC "appears to connect to localhost": from Spring's point of view the property genuinely is absent. Two guards, because one of them would have been useless: theConfiguredDatabaseUriReachesTheDriver registers the URI under `WebserviceConfig.DATABASE_URI_KEY` via @DynamicPropertySource -- keyed by the production constant, never a repeated literal -- and asserts host, credentials and database on Boot's own resolved MongoConnectionDetails. No database needed. Uses 127.0.0.1 deliberately: `localhost` is Boot's fallback, so only a different host proves binding. theKeyIsNotRetiredBySpringBoot reads Boot's own spring-configuration-metadata.json off the classpath and fails if the key SPC writes is absent or carries deprecation level "error". This one generalises: the next Boot release to retire a key we depend on fails here at build time instead of silently redirecting a production database. Observed red: host `[localhost]` vs expected `[127.0.0.1:27017]`, and "is retired by Spring Boot (deprecation level 'error'). Replacement(s): [spring.mongodb.uri]". Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>Closes the finding the previous commit's guards pin, turning both green. `DATABASE_URI_KEY` becomes `spring.mongodb.uri`. Spring Boot 4.0.0 retired `spring.data.mongodb.uri` -- metadata `deprecation.level = "error"`, replacement `spring.mongodb.uri` -- and a retired key is not bound at all, so Boot fell back to its own default `mongodb://localhost/test`. Every configured host, credential and database was ignored, silently, including every `SPC_DATABASE_*` container variable. **Existing installations keep working without anyone editing a file.** The getter reads the live key, falls back to `LEGACY_DATABASE_URI_KEY` when it is absent, and re-writes what it found under the live key -- so Spring never sees the retired name again. That fallback, not a `MigrationManager` step, is what protects people: migrations are skipped entirely on dev, alpha and beta builds, so a migration method alone would miss everyone not on a release. Grouped with it, because the fallback requires it: the URI check was `!startsWith("mongodb")`, which **accepts the degenerate `mongodb:`** a partially-configured container used to produce. With two keys in play a bad value must be rejected the same way whichever key carried it, so the check now enumerates `mongodb://` and `mongodb+srv://`. `mongodb+srv://` is pinned too -- it is what a hosted Atlas cluster hands out, and a prefix check would have kept accepting it by luck. Verified end-to-end against MongoDB 8.0.5, not just by unit test. Same jar, same URI value: before, legacy key hosts=[localhost:27017] credential=null after, live key hosts=[127.0.0.1:27017] credential=MongoCredential{userName='spcuser'…} after, legacy key hosts=[127.0.0.1:27017] file gains spring.mongodb.uri beside the old one and the conclusive one -- a document seeded into a **non-default** database `spc_e2e`, with the app configured through the **legacy** key only, came back from `GET /api/v2/runconfigs/all`. Before this it would have queried `localhost/test` and returned `[]`. Also updated, all of it part of the same contract: `init-spc-config/run` writes the live key (its harness `docker/tests/init-spc-config-test.sh` re-run in the production base image, 10/10 pass), `HELP.md`'s sample block and settings table, `README.md`'s setup step plus an upgrade note, and the test fixtures. **One fixture stays on the legacy key on purpose** -- `serverpackcreator-api/src/test/resources/serverpackcreator.properties`, commented as such -- so the fallback is exercised through the real `ApiProperties` chain rather than by unit tests alone. Two existing assertions in `WebserviceConfigTest` changed, which is the stop-and-flag signal and is deliberate here: they asserted the *written key literal*, and the written key is precisely what this fix changes. Both now assert through `WebserviceConfig.DATABASE_URI_KEY`, and the key's own value is pinned separately by `DatabaseUriPropertyTest` so nothing passes by construction. `./gradlew build` green, counts read from the JUnit XML rather than estimated: api 312 (1 skip), app 110, clientside 88, grinder 233 (19 skip), plugin-example 3. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>`claude-docs/DOCKER-MONGO-INVESTIGATION.md` ended with "Still open: what makes the property absent entirely", and listed three unexcluded candidates: the s6 service not running, `homeDirectory` resolving elsewhere, or SPC rewriting the file. None of them. **The key was retired.** `spring.data.mongodb.uri` is not a property Spring Boot 4 binds -- `deprecation.level = "error"`, replacement `spring.mongodb.uri`, since 4.0.0 -- so `MongoProperties.uri` stayed null and the else-branch that document already dissected produced the literal `localhost`. That closes its triage table's middle row: a `MongoSocketOpenException … localhost:27017` did **not** mean the reporter's `overrides.properties` was missing or their init service had not run. Their file was almost certainly correct. Nothing further is needed from them. The old "still open" candidates are kept under a *Superseded* heading rather than deleted -- they were excluded by evidence, and that reasoning is worth keeping next to the answer. Also recorded: - the compatibility row for `DATABASE_URI_KEY`, whose *value* changed. No signature moved, which is exactly why it belongs there: an embedder that hard-coded the old string now writes a key nothing reads. Reading stays backward-compatible. - **why there is no `MigrationManager` step**, since its absence looks like an omission: migrations run release->release only, so a version-keyed method would miss every dev/alpha/beta user and would need a release number that does not exist yet. The getter's re-write covers every build type on first read. The stale legacy line stays on purpose -- deleting it would strand a downgrade, and Spring ignores it. - a `REFACTOR-LOG.md` entry with the measurements, including how this was found: the performance branch's data migration reported `0 inspected` against a correctly-seeded database, which only made sense once the client's own log line showed `hosts=[localhost:27017]`, `credential=null`. Flagged for the merge: `claude-performance-improvements` carries B33/B34 in its `BACKLOG.md`, which described this defect from the outside. Both are resolved here and should be dropped, not carried forward. `./gradlew build` green. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>Red pin. Lands failing, on purpose. `FALLBACK_DATABASE_URI` is the Kotlin literal `"mongodb\\://user\\:password@localhost\\:27017/serverpackcreatordb"`, so its *value* contains literal backslashes: mongodb\://user\:password@localhost\:27017/serverpackcreatordb `com.mongodb.ConnectionString` accepts only `mongodb://` or `mongodb+srv://`, so that string is not a URI the driver will take -- and it is what every first-time web user starts from, before they have configured anything. The reason their first boot cannot connect is a constant in our source, not anything they did. The backslashes are a category error: escaping belongs to the `.properties` *file format*, and `Properties.store` already applies it on write while `Properties.load` reverses it on read. Carrying it in the value means it gets escaped twice -- which is exactly why a generated home reads `spring.mongodb.uri=mongodb\\\://…`, three backslashes for one colon. Pre-existing: the constant is byte-identical on `develop`. Surfaced now because the previous commit tightened the scheme check from `startsWith("mongodb")` -- which this value satisfies -- to enumerating `mongodb://` / `mongodb+srv://`, which it does not. So the tightening did not break anything; it made a value that was already unusable *visible*. The second assertion pins the cause rather than only the symptom, so a future edit cannot "fix" this by re-adding escaping. Observed red: "The fallback must be a URI the driver accepts, but it was: mongodb\://user\:password@localhost\:27017/serverpackcreatordb". Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>Closes the last open thread from this finding, and explains a prior dead end. Two earlier attempts to shorten the MongoDB driver's server-selection timeout in tests -- once via test resources, once via `@SpringBootTest(properties = …)` -- both appeared to do nothing, and the second was especially confusing because a probe showed the URI *did* reach the environment while the client kept reporting `serverSelectionTimeout='30000 ms'`. Same cause as everything else here: the URI was not reaching the client, so nothing inside it could take effect either. Measured after the fix, against a real MongoDB: mongodb://127.0.0.1:27017/spc_t?serverSelectionTimeoutMS=250 -> serverSelectionTimeout='250 ms' (no parameter) -> serverSelectionTimeout='30000 ms' So a web-context test that runs without a database can now cut ~30s per boot by putting the parameter in its URI. That matters for anything doing Mongo work on `ApplicationReadyEvent`. Verified alongside it, all four configuration paths against MongoDB 8.0.5: fresh install (nothing configured) hosts=[localhost:27017] credential=MongoCredential spring.mongodb.uri hosts=[127.0.0.1:27017] credential=MongoCredential spring.data.mongodb.uri (legacy) hosts=[127.0.0.1:27017] credential=MongoCredential The fresh-install row is the fallback fix confirmed end-to-end: `localhost` is the fallback's own host, and the *credentials* are what disambiguate it from Boot's default `mongodb://localhost/test`, which has none. Before the fix that URI carried literal backslashes and could not be parsed at all. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>Red pin for the migration Griefed confirmed belongs in 9.0.0. Reading `WebserviceConfig.databaseUri` already normalises a legacy-key configuration on every build type, so the migration is not what makes the upgrade *work*. What only a migration can do is **report it**: the rename is otherwise invisible, and an operator whose own tooling, container environment or hand-written `overrides.properties` still writes `spring.data.mongodb.uri` needs to know Spring no longer reads it. That file is not SPC's to fix. Two guards, the second one being the half that keeps the feature honest: upgradingToNineZeroZeroReportsTheRenamedDatabaseProperty a URI stored under the legacy key is carried to the live key, and the change is reported naming the new key. upgradingToNineZeroZeroSaysNothingWhenTheOldKeyWasNeverUsed an installation that never used the old key gets no message and nothing written. A migration that announces itself to everyone is noise, and noise gets ignored. The harness gains `managerWithStore`, which hands the mocked ApiProperties a **real** WebserviceConfig over a real PropertyStore — so the assertion is about stored properties rather than about a stub. Observed red on the first (expected the carried URI, got null); the second passes already, which is what makes it worth keeping rather than writing after the fact. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>`serverpackcreator-help/Writerside/api-docs.yaml` was hand-maintained and had drifted badly: **25 documented paths against 44 real ones**, and two schemas -- `ModPackView` and `ServerPackView` -- described classes that no longer exist anywhere in the module. springdoc is wired back in, as `developmentOnly` so swagger-ui stays out of the shipped jar. The commented-out coordinate that was there pinned 2.2.0, which targets Spring Boot 3 and cannot resolve against Boot 4; **3.1.0** is the Boot 4 line -- verified, its parent POM is `spring-boot-starter-parent:4.1.0`, the same version this project pins, and it depends on Boot-4-only artifacts (`spring-boot-tomcat`, `spring-boot-health`). Version declared in the catalog, per the build conventions, not as a coordinate in the module file. Regenerated by running the real app and fetching `/v3/api-docs.yaml`; the command is recorded beside the dependency. What changed, measured: paths 25 -> 44 19 endpoints were undocumented (all of /stats/downloads/*, the *paginated variants, /modpacks/byserverpack/{id}, /versions/neoforge/{mcver}) schemas 12 -> 16 +AmountPerDate, AmountStatsData, DiskStatsData, ModPack, ModPackDownload, ServerPackDownload; -ModPackView, -ServerPackView **Nothing documented disappeared**, so the old file's drift was pure omission rather than staleness in the other direction -- and the two dangling schemas fell out on their own, which is the argument for regenerating over hand-patching. The generated types are also more precise than a hand-fix would be: ids come out as `['string','null']`, derived from the actual nullable Kotlin types. Every one of the 25 endpoints referenced by `<api-endpoint>` elements in the Writerside topics still resolves in the regenerated file, so no documentation page breaks. springdoc's placeholder header is replaced: `title: OpenAPI definition` / `version: v0` become `ServerPackCreator` / `v2` -- `v2` matching the stable `/api/v2` prefix rather than a build version that would go stale -- and the `servers:` block is dropped, since a published spec should not pin whichever host generated it. The description says the file is generated and points at the command. Root `CLAUDE.md` now states the file is generated, because it was hand-edited for long enough to drift by 19 endpoints. **`excludeGroups` for org.springdoc in the license report, which this change turned out to need.** `developmentOnly` keeps springdoc out of the jar -- verified by listing the bootJar, which contains no springdoc or swagger entry -- but it still reaches `compileClasspath`/`runtimeClasspath`, which is what `licenseReport` reads. So it had added itself to `licenses/LICENSE-AGREEMENT.txt` and the GUI copy, documents about what *ships*, taking the dependency count 44 -> 45 and churning 303 lines in two shipped files. Excluded, both files are byte-identical to develop again and the count is back to 44. **Note for whoever merges `claude-performance-improvements`:** that branch hand-edits this same file for its DBRef->embedded change, and deletes `ClientMod` / `StartArgument` / `WhitelistedMod`. On develop those entities still exist, so this regeneration correctly references them. After the merge, regenerate again rather than resolving the conflict by hand. `./gradlew build` green. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>Seven audit iterations of performance work: network/startup timeouts, manifest conditional GETs, the GUI typing path, generation throughput, and the web module's query shapes -- including flattening RunConfiguration's three @DBRef mod-lists to embedded string arrays, with a join-free migration for persisted data. Iteration 7 established equivalence with develop by measurement rather than assertion: develop's own test trees, run unmodified against this branch's production code, gave 490 pre-existing guards and zero failures (api 309, clientside 88, app 93). The two files that could not compile were the branch's two deliberate shape changes -- one adapted with no assertion edits, one legitimately superseded. The data migration, the declared index and the REST response shape were each verified against a real MongoDB. Two conflicts, both resolved deliberately: claude-docs/BACKLOG.md B33/B34 DROPPED. They described the Spring Boot 4 database-property defect from the outside -- "the web app appears to use MongoDB's default test database" -- and claude-mongo-boot4-property fixed the cause, so carrying them forward would list solved problems as open. B30-B32 kept; B35 kept. api-docs.yaml took the regenerated spec. The branch hand-edited this file for its embedded mod-lists, which was right at the time; it is now generated from the controllers, so it is regenerated in the next commit instead -- the entities this branch changed are exactly what it reads. Everything else auto-merged, and the high-value regions were checked rather than assumed: the compatibility table carries rows from both sides (32 total), both sets of app-module landmines survive (the auto-index-creation trap and the database-property rename), and both REFACTOR-LOG narratives are present. Suite counts in the refactor-state table still read this branch's figures and are corrected, from the JUnit XML, two commits from here -- the merge adds tests from the database work that were not in either side's count.Follow-up to the four merges, doing the two things a merge cannot do for itself. **The OpenAPI spec is regenerated against the merged code**, which is why the merge took the generated file rather than resolving that conflict by hand. `claude-performance-improvements` flattened `RunConfiguration`'s three `@DBRef` mod-lists into embedded string arrays and deleted `ClientMod`, `StartArgument` and `WhitelistedMod`; `claude-openapi-regenerate` had regenerated the spec while those entities still existed. Neither side's file was right for the merge. Regenerating produced the correct combined state on its own: RunConfiguration.startArgs/clientMods/whitelistedMods -> items: {type: string} ClientMod / StartArgument / WhitelistedMod schemas -> gone schemas 16 -> 13, paths 44 (unchanged) All 25 endpoints referenced by `<api-endpoint>` elements in the Writerside topics still resolve, so no documentation page breaks. This is the argument for generating rather than hand-maintaining, made concrete: a hand-merge of two divergent 1,300-line specs would have been guesswork, and the result here was mechanical. **Suite counts corrected from the JUnit XML**, api 339 -> 343 and app 145 -> 149. Neither branch's figures were wrong for that branch; the merge simply adds the database work's tests (`WebserviceConfigTest` +4 in `-api`, `DatabaseUriPropertyTest` and `MigrationManagerTest` +4 in `-app`) to counts neither side had measured together. Recorded here rather than left to drift, which is the defect three separate audits kept finding. Merged `./gradlew build` green: api 343 (1 skip), app 149, clientside 88, grinder 233 (19 skip), plugin-example 3, frontend 32. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>Closes B30. After the conditional-GET work, four of twelve manifests answered `304` to `If-Modified-Since`. `files.minecraftforge.net` **ignores** that header but honours `If-None-Match` against its weak nginx ETag -- and it is the largest manifest still transferred in full on every startup, 121,492 B, 57 % of the 213,885 B that remained. Both headers are now sent. A host honouring either answers `304`; a host honouring neither answers `200` and everything downstream runs exactly as before, so this needs no per-host special-casing. **The ETag is validated, not trusted, and that is the whole safety of the feature.** An ETag describes one exact body. Recorded in a `<manifest>.etag` sidecar together with the manifest's byte length, and offered only when that length still matches what is on disk. Without that check, a manifest replaced by other means -- `ApiWrapper.setup()` re-seeding it from the jar is the real case, a restore or hand-edit are others -- would earn a `304` for content we do not hold and suppress a genuine update **permanently**. It is also recorded **only when the manifest is actually adopted**. The updater declines an upstream manifest with fewer versions than the local copy, and remembering that response's ETag would describe a file we chose not to keep. Both of those are pinned, and both pins were mutation-verified rather than trusted: trust the pairing (drop the length check) aStaleEtagPairingIsNotOffered FAILED remember the ETag when not adopting anEtagIsNotRememberedForAManifestThatWasNotAdopted FAILED Worth stating plainly: three of the four new guards passed *vacuously* before the implementation existed, because no ETag was being sent and `null == null`. The mutations above are what establish they assert anything. The fixtures also seed the manifest first, as `ApiWrapper.setup()` does in production -- the first draft did not, took the absent-manifest download path instead, and could never have observed an ETag at all. **A build-side hazard came with it.** `updateManifests` copies `tests/manifests` into the shipped resources with no filter, so sidecars the suite writes there would have been packaged into the jar and seeded into every user's home -- one machine's HTTP bookkeeping shipped to everyone. Now excluded. Measured: 12 manifests copied either way, 0 sidecars. Scoped honestly: this buys **~0 ms of startup**, as B30 always said. The twelve checks run concurrently, so wall-clock is the slowest host, and Forge is not it. What it buys is bandwidth on a metered or slow connection, where 121,492 B is the dominant cost rather than latency. `./gradlew build` green. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>Closes B31, the last of the deferred performance items. `VersionMeta`'s constructor blocked on checking all twelve manifests before it returned. Since `ApiWrapper.setup()` has already seeded every manifest from the jar, the metas have working data before any request is made -- so that wait bought nothing a moment later, and cost it on every launch. Measured with the same probe on the same machine, constructing a `VersionMeta` over the same home: develop (blocking) 399 / 390 / 835 ms median ~399 this branch 56 / 36 / 47 ms median ~47 ~352 ms off a GUI launch, and considerably more offline, where the old path waited out twelve connect timeouts before showing a window. The remaining ~47 ms is parsing the seeded manifests, which is the irreducible part. **The freshness contract is preserved where it is observable, which is the part B31's entry missed.** The GUI's version dropdowns are `DefaultComboBoxModel`s built once in `ConfigEditor` and **nothing anywhere repopulates them** -- so a naive background refresh would have hidden a freshly released Minecraft version until the next launch, breaking exactly the workflow ServerPackCreator exists for. Two awaits close that: ConfigEditor an `init` block placed *above* the version-list properties, because Kotlin runs initialisers in declaration order and that is the only point the wait can happen before they are built. checkConfiguration the single choke point every validating caller passes through -- CLI, interactive shell, web, embedders -- so a short-lived `--headless` run cannot reject a version upstream published minutes ago. One site instead of four. `awaitManifestRefresh` is idempotent, returns immediately once the refresh has landed, and is bounded (10 s default) so an unreachable host delays a dropdown rather than hanging the UI. The refresh re-parses after checking, because the metas are built from the manifest *files* and a refreshed file is invisible until read again -- the same two steps `update()` performs. Concurrent re-parse while another thread reads a meta is **not** a new hazard: the web backend's `VersionRefreshSchedule` has always called `update()` on a cron while requests read the metas. Failures are logged and swallowed; the seeded manifests remain perfectly usable. The scope is `CoroutineScope(Dispatchers.IO + SupervisorJob())`, deliberately not `GlobalScope`, which this project removed everywhere else. It owns no thread and the one job it runs completes. Pinned by `VersionMetaRefreshTest`: the seeded manifests are usable before any refresh -- the half the whole change rests on -- and the refresh is awaitable and reports completion, twice, so callers may await freely. The timing above is measurement rather than assertion, recorded here because no test can pin it without a network seam in an already fifteen-parameter constructor. Compatibility row added. `./gradlew build` green: api 354 (1 skip), app 149, clientside 88. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>All three deferred performance items have landed, so they leave the backlog and enter the log, per that file's own convention. Only the CI items (B26-B29) remain. The log entry keeps what was learned rather than only what was done: - B31's entry was **incomplete**. It described moving the manifest refresh off the blocking startup path and did not mention that the GUI's version dropdowns are built once and never repopulated -- which is what would have turned a 352 ms saving into "a freshly released Minecraft version needs a restart to appear". Written down so the next reader sees the constraint, not just the win. - B30's ETag is only safe because the pairing is validated. That reasoning, and the mutation results proving the guards bite, matter more than the 121,492 B saved -- and the entry records that three of the four guards passed vacuously before the implementation existed. - B32's characterization tests caught a mistake the change itself introduced: `File.length()` on a directory returns a small number, so the first version of the early return made the check *accept* a directory where it had always rejected one. Measurements carried over verbatim (~399 ms median -> ~47 ms, three runs each, same probe and machine) so nobody has to trust a remembered number. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>None of B26-B29 can be closed from the repository. This makes the next pipeline able to close three of them in one shot instead of two pipelines, and records what was established so nobody re-derives it. **The diagnostic now prints `CI_RUNNER_ID` / `_DESCRIPTION` / `_TAGS`.** Not padding: this file declares **no `tags:` anywhere**, so nothing pins these jobs to one runner. Without knowing which runner produced the output, one pipeline cannot answer B27 -- if any runner lacks the `/var/run/docker.sock` mount that kills dind, then on that runner dind is the only daemon and dropping `.dockerized` breaks the release pipeline. That is the hole in reasoning about this from configuration alone, and it is why `.dockerized` was *not* deleted here. **Two facts verified locally, recorded in B27, eliminating one of its two branches:** - `.gitlab-ci.yml` sets neither `DOCKER_HOST` nor `DOCKER_TLS_CERTDIR` anywhere -- the only mentions in the whole file are the diagnostic echoing them. The CLI therefore uses its default `unix:///var/run/docker.sock`, so "the service is the intended endpoint" is excluded by configuration. - the `docker` alias the service publishes is never used as an endpoint by any job. So on the runner that produced the green 2026-08-04 pipeline, the jobs reach a daemon at the default socket path that the service did not create -- it died trying -- and removing a service nothing connects to cannot remove that daemon. Strong, but scoped to *that* runner. B27 warns that guessing wrong breaks the release pipeline's push jobs, and seven jobs still extend `.dockerized`, so the runner identity gets printed rather than the service deleted on inference. **B29:** nothing committed carries the Qodana count -- `qodana.yaml`, no SARIF, no baseline -- so it lives only in that job's artefact. Recorded, with the image the job uses. **B28** stays not-actionable, now cross-referenced: the useful question about the runner config is whether *every* runner accepting these untagged jobs carries the mount, which is exactly what B27 needs. Also recorded so nobody repeats it: reading the pipeline from here was attempted and failed -- `git.griefed.de`'s API answers `404` unauthenticated, and there is no `glab`, `gh` or token on this machine. `.gitlab-ci.yml` still parses as YAML. No source touched. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>`git.griefed.de` is **Forgejo 16.0.3**, not GitLab -- verified against `/api/v1/version`. `.gitlab-ci.yml` and its 22 jobs are deleted. CI lives in `.forgejo/workflows` and Forgejo is the origin of every release. **This is a cutover, and it had to be one commit.** `.forgejo/workflows` is all-or-nothing: per a Forgejo maintainer on forgejo#9203, "If a project contains a `.forgejo` and a `.github` folder, then the `.github` folder is ignored." Forgejo had been running the `.github` workflows as a fallback -- that is where releases 9.0.0-alpha.2 through .6 came from -- so the moment this directory exists, those stop. Everything Forgejo must do therefore lands here at once; a staged port would have left a window with no releases. Nine workflows: `test.yml` and `docker-test.yml` (the GitLab Build Test / Docker Test role), `qodana.yml`, `release-generate.yml` (semantic-release), `release-build.yml` (assets, the Forgejo release, Maven, Docker, the outward mirror, VirusTotal), `devbuild.yml`, `docs.yml`, `update-readme.yml`. Decisions that are not obvious from the diff: - **`uses:` keeps the `actions/...@<github-sha>` lines the .github workflows used.** Forgejo's docs recommend `https://data.forgejo.org/...`, but these exact references are *proven* to resolve on this instance -- they built the existing alpha releases. Trading that for a mirror whose commit SHAs may differ would swap something known to work for something merely recommended. install4j is the one deliberate exception, fetched from GitHub by full URL. - **semantic-release now does version + changelog + tag only.** `@semantic-release/gitlab` and `gitlabUrl` are gone, `publish` is `false`; the release is created by the tag-triggered workflow, where the assets are. Same two-phase shape GitLab had, so the `releaseRules` that produce your version numbers are untouched. **The tag must be pushed with a real user token** -- Forgejo, like GitHub, does not trigger workflows from pushes made with the automatic per-run token, so an automatic-token push would tag a release nothing ever builds. Called out in the workflow itself. - **`GitGriefed` Maven repository retargeted, name deliberately kept.** It pointed at `/api/v4/projects/63/packages/maven` with a `Private-Token` header: a GitLab path and a GitLab auth scheme, neither of which exists on Forgejo. Now `/api/packages/Griefed/maven` over HTTP Basic. The repository *name* is unchanged so `publishMavenJavaPublicationToGitGriefedRepository` still exists -- verified, all four publish tasks are still generated. GitHub Packages, gitlab.com and OSSRH untouched: the move is off the self-hosted GitLab, not off gitlab.com. - **Release mirroring is explicit API calls.** Forgejo push-mirrors replicate refs but not releases. The mirror job runs last and only on success, so no downstream forge advertises a release Forgejo lacks. - **VirusTotal became a job in `release-build.yml`** instead of a release-triggered workflow: the assets are already there as an artifact, and `crazy-max/ghaction-virustotal` updates a *GitHub* release body, which is the wrong forge now. - **`docker-test.yml` does not push, and that is a change.** The GitLab job used `--push` and tagged every commit's image on ghcr.io *and* Docker Hub, publishing an image per branch push that nobody consumed. Restoring it is two lines, noted in the file. - **Two regressions avoided by reading the code being replaced rather than skimming it.** `update-readme` sent its token in an `Authorization` header *specifically* so it could not leak into logs -- the port keeps that rather than putting the token in the push URL. Qodana's JBR `chmod` existed because GitLab's cache drops the executable bit, which `actions/cache` does not; it survives as documented insurance rather than being blindly copied or silently dropped. GitHub keeps a **smoke test** plus the four issue-driven `clientside-*` workflows. `github_release.yml`, `github-prerelease.yml`, `devbuild.yml`, `update_readme.yml` and `virustotal.yml` are deleted from there. `devbuild` additionally clears the stale GitHub `continuous` *release* while leaving its *tag*, so the mirror recreates it from Forgejo. **B26-B29 dropped, not answered** -- they described GitLab dind and a GitLab-Pages-hosted Qodana report, and that infrastructure is gone. The backlog is now empty. All 13 workflow files parse as YAML. `./gradlew build` green: api 354 (1 skip), app 149, clientside 88, grinder 233 (19 skip). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>Closes iteration-2 audit findings D1 and D2. Both were found by *executing* the workflow steps -- pulling each `run:` block out of the YAML, substituting the expressions and running it against this repository's real CHANGELOG.md and a synthetic build tree -- rather than by reading them. D1 in particular survived two readings. **D1: a final release's notes contained every prerelease's notes as well.** The changelog extraction's start pattern had an *optional* closing bracket, so a request for `8.1.1` also matched `## [8.1.1-beta.2](...)`. That rule ends in `next`, so the exit rule never saw those headings and collection ran straight through them. Measured against the real file: 8.1.1 extracted 5,685 chars true section 2,834 8.1.0 extracted 28,807 chars true section 14,377 8.1.1-beta.2 extracted 673 chars true section 672 (correct - nothing nests under a prerelease) So every full release would have published its changelog followed by the changelogs of the prereleases leading to it, roughly doubling the notes. Prereleases were unaffected -- which is exactly why it would have shipped unnoticed, since the first Forgejo-cut release will be an alpha. The bracket is now required and the version's dots are escaped. Verified by execution against five versions including one absent from the file: every extraction now matches the true section boundaries exactly, and a missing version still falls back to "Release <version>". **D2: a failed asset upload left an incomplete release and a green run.** Five steps calling `curl` in loops or sequence had no `set -e`, so `curl -sf` returning non-zero was ignored and the step exited with the status of its last command -- one asset failing to upload would produce a release missing a file while the run reported success. For a release pipeline that is the wrong failure mode: a loud failure can be re-run, a silently incomplete release gets downloaded. Added to *Create release and upload assets*, all three `mirror` steps, and Discord -- the last one after its missing-webhook guard, so an absent webhook still exits cleanly while a failing post does not pass silently. The three steps written for this migration in devbuild.yml, and both VirusTotal steps, already had `set -eu`; this was inconsistency rather than a considered choice. Also recorded in the audit as verified-by-execution rather than assumed: tag classification accepts the two release shapes and refuses `continuous`, `9.0`, `v9.0.0` and `9.0.0-rc.1`; docs image tags cover all three GitLab jobs' behaviour from one computation; asset collection yields the right 13 files with `-plain.jar` and `output.txt` excluded and `checksum.txt` not listing itself; and both release payloads are valid JSON with the body round-tripping byte-identically through shell quoting. No source touched. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>Closes iteration-3 audit findings E1-E6. All six are about *when* jobs run rather than what they do -- the class neither YAML validation nor executing steps in isolation can see, because each one is a relationship between jobs. **E1, the one that would have done visible damage.** The GitHub mirror POSTed a release with `tag_name` and no `target_commitish`. GitHub creates the tag from `target_commitish` when it does not exist, defaulting to the **default branch** -- and git mirroring is asynchronous, so seconds after the Forgejo release the tag has usually not arrived. First release would have created a `9.0.0` tag on GitHub pointing at main's HEAD: disagreeing with Forgejo, and blocking the real tag once mirroring caught up. Now passes `target_commitish: github.sha`. **E2** is the same root cause from the other side: GitLab's release API answers `404 Tag Not Found` unless the tag exists or a `ref` is given. With the `set -eu` added in iteration 2, that became a hard failure of the mirror on essentially every release. Now passes `ref`. **E3: devbuild had no concurrency guard, and this migration is what made that dangerous.** The GitHub original had none either, but it updated the `continuous` release in place, which overlapping runs survive untidily. This port deletes-then-recreates -- deliberately, so stale assets cannot linger -- which is right for one run and wrong for two: concurrent runs interleave delete and create, and there is a window where the release the download page points at does not exist. The guard is not tidy-up being backfilled; the change raised the stakes and should have brought it along. **E4: a re-run could not repair a partly-failed release.** `release` created unconditionally, so re-running after a `maven` or `docker` failure -- the ordinary repair -- died at release creation under `set -eu`, skipping `mirror` and `virustotal`, the very jobs that needed retrying. It now looks the release up by tag, refreshes its notes and reuses its id, creating only when absent. **E5: two ordering problems, one fix.** `virustotal` PATCHes the Forgejo release body with the scan permalinks while `mirror` read that body in parallel, so the GitHub copy could never carry the section -- a capability the replaced workflow had via `update_release_body`. And `mirror` did not depend on `maven` or `docker`, so GitHub could advertise a release whose artifacts and images did not exist yet, or never would. The approved plan said the mirror "runs last and only on success"; the graph now says so too: prepare -> assets -> {release, maven, docker} -> virustotal -> mirror. **E6:** `update-readme` gets a guard so its schedule and a manual dispatch cannot both push to main. Re-validated against the final state, all six checks green: YAML and every needs/outputs/step-id reference resolve; no bare third-party actions and no `secrets.GITHUB_*` in executable positions; `mirror` depends on every other job; all eight workflows have a concurrency group; no curl or loop step lacks `set -e`; and the changelog extraction still returns exactly the true section for every version tested. No source touched. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>The audit reports had drifted into two files that were never duplicates of each other, so neither could simply be deleted: claude-docs/REFACTOR-AUDIT.md 440 lines, 3 audits from 2026-06-25 (claude-workflow-audit, claude-clientside-verify, and a full-range Phase 0 -> HEAD pass) REFACTOR-AUDIT.md (root) 1802 lines, 15 audits covering the four perf branches, the seven claude-performance-improvements iterations, and the three Forgejo CI iterations The split is an artefact of the audit command writing to a root-level path by default, not a decision. Merged chronologically -- the June audits first, the August ones appended -- so the file reads in the order the work happened. Verified nothing was dropped: both halves diff byte-for-byte against their pre-merge blobs (440 and 1802 lines; 18 audit sections in the 2256-line result). Neither file was ever shipped -- serverpackcreator-api/build.gradle.kts limits shippedDocuments to an explicit seven-file allow-list -- so this is not a user-visible change and no build wiring refers to either path. Kept rather than deleted because the findings themselves are closed but the *evidence* is not reproducible from git log: the mutation-testing results, the before/after measurements the conventions require for build-logic changes, and the "verified clean, do not re-litigate" lists. REFACTOR-LOG.md records what was decided; this records what was measured. CLAUDE.md:298 cited the root path for iteration 7's 490-guard result and now points at claude-docs. REFACTOR-LOG.md:934 needed no change -- it is a sibling in claude-docs, so its bare reference resolves correctly for the first time. The remaining in-file mentions are historical prose about where the file used to live, and stay as written. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>`Bump install4j to 13` broke every task in the build. Not the media task, not the release -- everything, `./gradlew help` included: install4j-gradle-13.1.jar!/META-INF/...kotlin_module Module was compiled with an incompatible version of Kotlin. The binary version of its metadata is 2.3.0, expected version is 2.0.0. install4j-gradle:13.1 is built with Kotlin 2.3. Precompiled script plugins compile with GRADLE'S EMBEDDED Kotlin -- 2.0.x on the wrapper's 8.14.4 -- and buildSrc/build.gradle.kts had the plugin marker on that compile classpath, so :buildSrc:compilePluginsBlocks had to read metadata three minors ahead of itself and refused. The catalog's kotlin = 2.4.10 cannot help: it governs how the modules compile, never how build logic does. The obvious remedies are both bad. Reverting the plugin to 12.0.2 leaves a v12 plugin driving a v13 installation against a spc.install4j now stamped 13.1, which cannot be verified without install4j installed. Upgrading to Gradle 9.x (embedded Kotlin 2.3.10+) is the honest fix for the metadata gap but a major migration against a build with 20 configuration-cache problems and four third-party plugins to re-verify -- and the deprecation warning in this build's own output says it is not Gradle-9 ready today. Neither is necessary. Nothing in buildSrc/src/main/kotlin applies install4j -- verified by grepping every id(...) and kotlin(...) call in the precompiled script plugins -- so the marker existed solely to version the bare id("com.install4j.gradle") in the ROOT build script. That is a real build script, so it takes alias(libs.plugins.install4j) and the marker comes off entirely. The incompatible jar is then never on a classpath the embedded compiler reads. Measured, per the build-logic convention: before ./gradlew help FAILED in 3s at :buildSrc:compilePluginsBlocks after ./gradlew help SUCCESSFUL in 5s after ./gradlew build SUCCESSFUL in 6m 8s api 354 (1 skipped), app 149, clientside 88, grinder 233 (19 skipped), plugin-example 3 -- zero failures The plugin is genuinely applied, not merely absent: `tasks --all` still lists both `install4j` and `media`. install4j stays at 13.1, matching the tool and licence Griefed moved to, and Gradle stays at 8.14.4. CLAUDE.md's marker documentation gains the landmine, and with it a narrower rule: a plugin needs the marker only when a PRECOMPILED SCRIPT PLUGIN applies it. The ones that genuinely do are enumerated there. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>The root CLAUDE.md had reached 40,204 characters, past the ~40,000 floor where Claude Code warns that a single memory file is too large -- and it loads in full in every session, whatever the task. Today's additions (the marker landmine, the ordering mantra) are what pushed it over. Two sections move out into `.claude/rules/`, which loads a file only when the session touches paths its frontmatter names: build-layout.md the 149-line "where build declarations live" section -> **/build.gradle.kts, settings.gradle.kts, buildSrc/**, gradle/*.toml, gradle/wrapper/** ci-workflows.md the Forgejo all-or-nothing CI landmine -> .forgejo/**, .github/**, .releaserc.yml Root CLAUDE.md: 40,204 -> 27,243 chars, ~10,051 -> ~6,810 est. tokens resident per session. Nothing was dropped -- the three files together are 1,798 chars LARGER than the original, that being the frontmatter and the pointers left behind at both cut sites. Heading count is 12 before and after. Both frontmatter blocks parse, and their globs match real files (16 under buildSrc, 8 workflows, 8 build.gradle.kts, the catalog). Two judgement calls worth recording, because both cut against the obvious move: The CI bullet had five lines glued to its tail that were not about CI at all -- the note that api-docs.yaml is GENERATED, not hand-maintained. Moving that behind CI-scoped paths would have stopped it loading when someone edits a controller, which is exactly when it is needed and exactly how it drifted to 25 of 44 endpoints last time. It stays resident, promoted to its own bullet. Nothing was deleted. The obvious candidate was the build-layout section against BUILD.md, but they are complementary: BUILD.md is the contributor tour, this is the landmine set it does not carry (plugin markers, platform(), the retired io.spring.dependency-management). The ten module CLAUDE.md files were scanned too; their layout sections look like derivable class inventories but interleave the rationale inseparably -- dependency direction, which class is the seam, why GrinderApplication cannot move -- and they are lazy-loaded already. THE TRADE, STATED PLAINLY: these are landmines that now load conditionally. If a glob is wrong they silently stop appearing and someone re-adds io.spring.dependency-management or tidies away one of the two foojay declarations, which is what they exist to prevent. The globs above cover every build and workflow file in the tree today; a new build file outside them inherits no warning. Also dedents two lines in serverpackcreator-api/CLAUDE.md to 2 spaces, matching the surrounding bullet continuation -- whitespace collateral from this morning's B25 correction, where a 4-space continuation can render as a nested block instead of prose. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>Three call sites, two APIs, found by configuring the build under Gradle 9.7.1: build.gradle.kts install4j block `properties["install4jHomeDir"]` -> providers.gradleProperty(...) build.gradle.kts examplePlugin `configurations.creating` delegate -> configurations.create("examplePlugin") -plugin-example pluginArtifact same delegate -> configurations.create("pluginArtifact") Both are deprecated in 9.7 and scheduled to FAIL in Gradle 10, so this is prerequisite work for the wrapper bump rather than tidying. The property fix also closes a trap that was one edit away from firing. `properties["x"]` on an ABSENT key returns null, and Kotlin renders that as the STRING "null", which is not blank — so the old `isNotBlank()` guard passed and installDir became a directory literally named `null`. It never fired only because gradle.properties declares `install4jHomeDir=` with an empty value, which made that empty declaration load-bearing and undocumented. Deleting that one line would have broken `media` at release time, where install4j is not part of the dev loop and nobody would have been watching. The provider form has no such edge. The two configuration renames are name-preserving on purpose: `pluginArtifact` is consumed by STRING name from the root build (`project(path = ..., configuration = "pluginArtifact")`), so a delegate-derived name silently becoming something else would break the example-plugin wiring rather than fail to compile. `create(name)` is what the deprecation message itself prescribes. Verified on the CURRENT Gradle (8.14.4) before the bump, since these land first: configuration succeeds, `install4j` and `media` are still registered, and the root build still resolves examplePlugin -> project :serverpackcreator-plugin-example. After the bump, a clean `build --warning-mode all` reports ZERO deprecation warnings naming any of our build scripts. Note on the count: this started as "two deprecations" because I read the tail of a log instead of the whole of it. `build.gradle.kts:69` used the same delegate and was invisible in the last 35 lines. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>docs.yml and qodana.yml both bind-mounted $PWD into a sibling container. Under docker-in-docker that path is resolved by the DIND daemon, whose filesystem holds no copy of the job's workspace, so the sibling is handed a freshly created empty directory instead. Measured 2026-08-22 on a docker:dind + data.forgejo.org/forgejo/runner:13 rig (Docker 29.7.2), running a probe workflow through `forgejo-runner exec`: docker run -v "$PWD:/x" alpine ls -A /x -> no output, exit 0 docker run --volumes-from <job> -w "$PWD" ... -> the file the job wrote, and a file placed by `docker cp`, which is how act injects the workspace Both failures are silent: Writerside would build nothing behind its `|| true`, and Qodana would scan nothing behind its own, which reads as "no problems found". - docs.yml, qodana.yml: `--volumes-from "$(cat /etc/hostname)"` plus `-w "$PWD"`, with the tool arguments pointed at workspace paths rather than mount points. - qodana.yml: assert a sibling can see the workspace before scanning, since the scan's `|| true` would otherwise hide broken propagation. Uses the Qodana image itself, so this adds no new image dependency. - Comments corrected: the docs.yml note claiming the workspace is a bind mount was false under DIND, and the qodana note recording a locally proven command line now says so about the *old* /data-mount form. Requires the runner's container.options to mount a volume at the workspace parent ("--add-host=dind.docker.internal:host-gateway -v /workspace"). Without it the workspace is ordinary container filesystem and nothing propagates -- which the new guard now reports loudly instead of silently. Not verified locally: Qodana's workspace-path argument form (the /data form was the one proven by a local run), and that act never overrides the job container's hostname. Both fail loudly, not silently. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>Every artifact step in this directory failed with ::error::@actions/artifact v2.0.0+, upload-artifact@v4+ and download-artifact@v4+ are not currently supported on GHES. after the step had already found and staged its files -- 143 XMLs in test.yml, 2 files in docs.yml, 7 JARs in devbuild.yml. @actions/artifact v2 calls isGhes(), which treats any hostname that is not GITHUB.COM (or *.ghe.com / *.localhost) as GitHub Enterprise Server and throws. git.griefed.de is neither, so the refusal is unconditional. It is a client-side check, not a missing API on the instance, and no runner or instance setting disables it. Forgejo maintains patched forks for this. Verified 2026-08-22 by fetching the built bundles at the pinned commits and reading them: forgejo/upload-artifact v5 dist/upload/index.js -> function isGhes() { return false; } forgejo/download-artifact v7 dist/index.js -> function isGhes() { return false; } 11 upload and 8 download references across devbuild.yml, docs.yml, qodana.yml, release-build.yml and test.yml now point at those forks, pinned by commit like every other action in this directory. The directory-wide `uses:` note in test.yml records why these two are the exception to "the same actions/...@<github-sha> lines as .github". .github/workflows deliberately keeps the upstream actions: on GitHub the isGhes() check is correct and the forks buy nothing. The forks lag upstream (upload v5 vs v7, download v7 vs v8). Every input this directory passes -- name, path, if-no-files-found, retention-days -- exists in those versions, so no call site needed rewriting beyond the reference itself. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>test.yml uploaded both its artifacts with no `retention-days`, so each push fell back to the instance default. Verified against the Forgejo config cheat sheet: ARTIFACT_RETENTION_DAYS defaults to **90**, and per-artifact overrides are the documented mechanism ("Artifacts can have their own retention periods by setting the `retention-days` option in the `actions/upload-artifact` step"). Deletion is automatic and server-side once an expiry is set: `cron.cleanup_actions` ("Cleanup Expired Actions Assets") defaults to ENABLED true, RUN_AT_START true, SCHEDULE @midnight. No token, no extra job, and nothing for a workflow to sweep. - test-results: 7 days. Small XMLs, and the thing actually read after a failure -- including one noticed the following Monday. - build-artifacts: 1 day, matching devbuild.yml. Whole build trees (the run that surfaced the GHES refusal counted 5708 files) and a convenience copy of what any checkout rebuilds. Nothing downstream consumes it. docker-test.yml, the other test workflow, uploads no artifacts, so it needs nothing. Confirmed on the instance (Forgejo 16.0.3) while writing this: the repo currently holds zero artifacts, because every upload has been failing on the GHES refusal fixed in the preceding commit. So this policy has never been exercised here -- the check on the first green run is that `GET /api/v1/repos/Griefed/ServerPackCreator/ actions/artifacts` shows expires_at at created_at +1d and +7d rather than +90d. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>The embed's url was the run page, so reading a report meant landing on the run, finding the artifact list and downloading from there. It now points straight at the artifact: <server>/<repo>/actions/runs/<run_number>/artifacts/qodana-report Verified against Forgejo's own routing table rather than assumed -- `m.Get("/artifacts/{artifact_name_or_id}", actions.ArtifactsDownloadView)` under `/{owner}/{repo}/actions/runs/{run}`. The artifact *name* is used because it is stable and readable. Deliberately NOT the upload action's `artifact-url` output: that is built as `${serverUrl}/${owner}/${repo}/actions/runs/${github.context.runId}/artifacts/${id}` -- read out of the pinned bundle -- and Forgejo's run URLs use the per-repo index, not the global run id. It is the same reason RUN_URL here has always used `github.run_number`. The artifact id is still consulted, as an existence check: the qodana job now exports `report_artifact_id`, and notify falls back to the run URL when it is empty, which is what happens when the scan dies before writing a report (`if-no-files-found: warn` makes that a warning, not a failure). The embed says which case it is and names the commit, so an old message stays unambiguous. retention-days: 7 on the report, per the same reasoning as test.yml: the link's lifetime is the artifact's, and 90 days of per-push reports is accumulation, not retention. Each message keeps pointing at its own run's artifact, so it goes dead rather than silently showing a newer scan. Both branches were executed, not eyeballed: the step's script was extracted, its workflow expressions substituted and the curl replaced with a dump, giving artifact present -> .../actions/runs/143/artifacts/qodana-report artifact absent -> .../actions/runs/143 and valid JSON in both. The no-webhook guard still exits 0 without posting. This does not render the report in a browser, and nothing available here can: Forgejo has no Pages, Qodana Cloud is out by design, and raw files come back as `text/plain` with `nosniff` (verified against the instance), so a report committed to a branch would show as source with its CSS and JS blocked. The link serves the zip; index.html inside it opens locally. Hosting it was offered and declined. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>The embed now carries `Expires at: <t:EPOCH:f>`, which Discord renders in each reader's own timezone and locale, so the message says when its own link dies. The epoch is Forgejo's `expires_at` for that artifact, read from `/api/v1/repos/{owner}/{repo}/actions/artifacts` and matched on the id the upload step already exports -- not retention-days re-added to "now". Two reasons: the instance is what actually deletes, so it is the thing worth quoting; and it keeps the message from being a second, drifting copy of the retention setting. The listing answers anonymously for this repo (verified against the instance), so no token is involved. `ActionArtifact` exposes `expires_at`, confirmed in the instance's own swagger rather than assumed from GitHub's shape. When the expiry cannot be determined the line is omitted rather than guessed -- no expiry beats a wrong one -- and the message still posts. Executed against fixtures, all five paths: Z-format expires_at -> <t:1787990819:f>, matches the expected epoch offset expires_at (+02:00) -> same epoch, so both RFC3339 spellings parse id absent from the listing -> no expiry line, still posts listing unreachable -> no expiry line, still posts no artifact uploaded -> run URL, no expiry line That fourth case is why `rm -f artifacts.json` precedes the fetch: the first run of the harness passed it only because the redirect truncates the file, and correctness should not rest on that. With the explicit removal the case passes on its own terms. Two shell details worth keeping: the expiry line is joined with $'\n' because an unindented continuation would dedent out of the YAML block scalar and parse as a mapping key, and the parser is a heredoc script rather than a `python3 -c` one-liner because it needs two format attempts and a loop. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>`npx -p <name>` with no version resolves to the newest major on every run, so three of the four packages this step installs were floating. One of them moved and took the release with it: conventional-changelog-conventionalcommits@10 switched to @conventional-changelog/template, whose createLegacyWriterGuard() deliberately plants a bogus `mainTemplate` so that a pre-9 writer throws instead of silently emitting an empty changelog. semantic-release@24 depends on @semantic-release/release-notes-generator@14, which depends on conventional-changelog-writer@^8 -- a legacy writer -- so the guard fired and generateNotes died with Missing helper: "conventional-changelog-conventionalcommits requires conventional-changelog-writer@9 or newer ..." Measured against @semantic-release/release-notes-generator@14.1.1, calling generateNotes directly: preset 8.0.0 renders preset 9.3.1 renders preset 10.4.0 throws the error above Preset 9 is therefore the ceiling, and upgrading semantic-release is not an escape hatch: 25.0.9 still depends on release-notes-generator ^14.1.0, hence still writer 8. release-notes-generator@15, which would bring writer 9, is beta-only. Unpin once that ships and semantic-release depends on it. Rehearsed end to end against a local bare clone of this repository standing in for origin: with the pin, generateNotes completed with zero errors over the real 229 commits since 9.0.0-alpha.5, rendering every custom section title from .releaserc.yml. changelog and git are pinned to their current majors for the same reason, not because either is currently broken. This fixes the crash only. The same run also resolved the wrong last release (8.1.2 instead of 9.0.0-alpha.5) for an unrelated reason -- see claude-docs/RELEASE-TAG-REPAIR.md. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>The 2026-08-22 release-generate.yml run failed twice over: it crashed in generateNotes, and before that it had already picked the wrong last release -- `Found git tag 8.1.2 ... on branch alpha` with 9.0.0-alpha.5 sitting right there. Neither is visible in the workflow YAML, so both go in the rule file that loads when .forgejo/** is touched. Landmine 1: semantic-release stores a release's channel in refs/notes/semantic-release, not in the tag. get-tags.js falls back to `channels = [null]` for a tag with no note, and get-last-release.js keeps a prerelease branch's tags only when their channels match the branch channel -- so on a remote with zero notes every X-alpha.N tag is invisible and only the newest non-prerelease tag survives. The Forgejo remote has 375 tags and no refs/notes/* at all, because a forge migration carries branches and tags but not notes. Also recorded: actions/checkout needs no change, since semantic-release runs fetchNotes itself -- verified against a fresh clone whose only copy of the notes was on the remote. Landmine 2: creating a release through the forge API mints a missing tag at target_commitish. 9.0.0-alpha.1 through .5 still report "target_commitish": "main", and all five tags sit on main's tip (the RELEASE: 8.1.2 commit) instead of on their own RELEASE commits. Comparing all 375 remote tags against local bounds the damage exactly: those five, plus the ghost 9.0.0-alpha.6, plus `continuous` which is supposed to move; the other 368 match byte for byte. release-build.yml is explicitly cleared -- it posts no target_commitish to Forgejo -- and how alpha.6's tag reached that commit is stated as unrecoverable rather than guessed at. RELEASE-TAG-REPAIR.md is the runbook for the honest repair Griefed chose: retarget .1-.5, delete the ghost .6 release and tag, write the channel notes. It is written to be run by hand and it was rehearsed against a local bare clone of this repository standing in for origin: broken (tags forced onto6cd6e9af3, no notes) last=8.1.2 624 commits next=9.0.0-alpha.1 repaired (retargeted, .6 deleted, notes pushed) last=9.0.0-alpha.5 229 commits next=9.0.0-alpha.6 The broken row reproduces the failing CI run down to the commit count, which is what makes this a finding rather than a theory. Also written down: the dry-run log flattens the changelog heading's markdown link and indents the bullets, which looks like a format regression and is only signale rendering multi-line output -- the written string is byte-identical in shape to existing CHANGELOG.md entries under both preset 8 and preset 9. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>`PathsConfig` resolved a source build's home from `File("").absolutePath`, which is unobservable from a test: the JVM resolves an empty path against the working directory it was *launched* with and ignores a later `user.dir` (verified — setting the property mid-process does not move `File("").absolutePath`). Taking it as a constructor parameter with that same value as the default lets a test present a different one. Behaviour-preserving: the default is the expression it replaces, and ApiProperties, the only caller, does not pass it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>Audit iteration 12, L2. The window was `substringAfter("fun main(args: Array<String>) {")` — main's body *plus every declaration below it* — so both `indexOf` calls could match text that is not main: the `pinSpcHomeDirectory` declaration, or a helper's log call. It passed for the right reason only because main happens to precede the helpers in the file; reorder them, or drop main's logging, and it would go green while asserting nothing. That is the failure class this repo has recorded twice already. Now cut by brace-matching from main's opening brace, and the extractor asserts its own boundedness so the window cannot silently run past main again. Teeth checked by mutation, which is the point of the finding: moving both claims back below main's first log statement turns the guard red with the intended message, and reverting turns it green. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>Audit of the two deployment commits, run immediately after they landed. No HIGH; one MEDIUM and three LOW, all fixed here. M1 — PREFIX, SERVICE_USER and SERVICE_HOME are overridable while the unit hardcodes all three, so an override produced a successful install the unit could not start. The installer now parses User=, WorkingDirectory= and ExecStart= out of the unit and reports any disagreement. A warning, not a failure: an operator with their own edited copy is doing nothing wrong. L1 — rm -rf "${PREFIX:?}/lib" was guarded against an *unset* PREFIX, which is not the dangerous case. PREFIX=/ reached `rm -rf /lib`. PREFIX must now be absolute and at least two components deep; / and /usr are both rejected by name, verified. L2 — "one sudo prompt up front" was untrue: the timestamp lasts ~15 minutes and a cold image build plus a Gradle build outlives it. Refreshed before the privileged block, and the comment now describes what happens. L3 — the header said to run it from the repository root, which the script does not care about; repo_root comes from BASH_SOURCE. Verified rather than assumed, in the shell: cp -a merges on a re-run instead of nesting bin/bin, the exec bit survives git as 100755, and the deploy/ gitignore exception holds. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>The runtime image bakes in `USER 1000:1000` and `ContainerSpec.user` defaults to the same literal, but every container bind-mounts a directory the *host* process created. When the host identity is not uid 1000 -- which it stopped being the moment the daemon moved to `User=grinder` -- the container can read the pack and write nothing. Observed live 2026-08-23, with the cause 20 lines above the visible symptom: start.sh: line 568: ./.previousrun: Permission denied Warning: Failed to open the file ./server.jar: Permission denied start.sh: line 206: user_jvm_args.txt: Permission denied Error: could not open `user_jvm_args.txt' Both container paths are affected -- installs via DockerLoaderInstaller and mod boots via ContainerServerRunner -- so every verdict since the systemd migration is INCONCLUSIVE. Red: `ContainerUser` does not exist yet, and ContainerServerRunner takes no containerUser argument. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>Audit iteration 22. P22-L1: `theWorkersGetTheRemainderOfTheWindowRatherThanASecondOne` asserted an absence -- `!contains("awaitStop(SHUTDOWN_GRACE)")` -- which passes for any spelling that is not that exact string. It now asserts what the code must do: take a deadline at entry and hand awaitStop the remainder. P22-M1: the TimeoutStopSec arithmetic hard-coded the batch size of 8 in a test, a unit comment and the README, so the promised single window silently became two at the deployed 10 workers. The guard now reads the real cap, which makes the relationship checkable rather than transcribed. Red: MAX_PARALLEL_STOPS is private to the engine. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>Two guards, both red at this commit. ContainerResourcesTest pins the cores -> CFS-quota arithmetic that SPC_GRINDER_CPUS is going to be, including the two ends nothing else would notice: a quota computed against an assumed period throttles a boot to a fraction of what was asked for (which then reads as a hanging mod, not as a misconfiguration), and a quota under 1ms is refused by the daemon at container-create time rather than at the knob. CpuLimitWiringTest pins the join `main` has to make, the same defect class ReportBindWiringTest exists for: every container-creating collaborator has accepted a ContainerResources since it existed, and `main` passes none -- so the cap is the hardcoded default and no environment can change it. Both call sites are asserted, the mod boot and the loader install. Red as committed: ContainerResourcesTest.kt:38:43 Unresolved reference 'forCpus'. ContainerResourcesTest.kt:55:64 Unresolved reference 'cpuPeriod'. (+6 more) -> compileTestKotlin FAILED Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>Audit iteration 23, H1. forCpus reads its *computed* quota as the uncapped sentinel, so a count small enough to round to 0us returns quota 0 -- which is docker's "no limit", verified in the container's own cgroup as `max 100000`. A request for the smallest possible cap therefore yields none at all, the one direction a hardening knob must not fail, and against a KDoc promising the floor instead. Also pins the two values String.toDouble() accepts and the arithmetic cannot: infinity rounds to Long.MAX_VALUE (a quota so large it means uncapped) and NaN rounds to 0 (uncapped outright). Red as committed: aCapTooSmallToRoundIsStillACapAndNotUncapped expected: <1000> but was: <0> aNonFiniteCountIsRejectedRatherThanRoundedIntoNonsense Expected java.lang.IllegalArgumentException to be thrown, but nothing was thrown. L2 from the same audit: theDefaultIsUnchangedByTheKnobExisting was not a sentence; it asserts the shipped default is exactly two cores, so it now says that. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>Audit iteration 23, M1/M2/M4 — one concern, three surfaces, all the operator's view of the same knob. The startup line now says `cpus=2.0 cores (200000/100000µs)`, or `cpus=uncapped`, through ContainerResources.cpuCapDescription(). It used to print the derived quota, which answers in a unit nobody set, and at `SPC_GRINDER_CPUS=0` printed `cpuQuota=0/100000` -- "zero CPU" for the value that means the opposite. The raw pair rides along because it is what the kernel was given, so it can be compared against a container's own cpu.max when a boot looks throttled. Guard teeth checked by putting the old line back and watching theStartupLineStatesTheCapInTheOperators Unit fail; its matcher is bounded to the closing paren on its own line, since a lazy match to the first `)` stopped inside the call it asserts on. README: `### Capping CPU` had been inserted mid-section, leaving "Keep the host awake" -- a paragraph about suspends -- as the closing advice of the CPU section instead of the sizing one. Order restored, and the sizing opener ("a memory question rather than a CPU one") now points at the new section rather than contradicting it. deploy/install-grinder.sh listed "Three worth a decision rather than a default" and named WORKERS as the throughput lever without its CPU twin. Four now, with the cgroup caveat, because the installer is the operator's first surface and the one no guard test scans. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>SPC_GRINDER_MEMORY_GIB completes the per-container budget: cores and gibibytes now come from the environment through one ContainerResources.forLimits call, on exactly the rules the CPU cap established -- an exact 0 is uncapped, a smaller positive value is raised to the daemon's own floor ("Minimum memory limit allowed is 6MB") rather than refused by it, and negative or non-finite input throws. The default stays 3 GiB, so no existing install changes. It ships with a warning rather than as another throughput lever, because the default is load-bearing in three directions and only the third is obvious: - The packs the grinder builds leave `javaArgs` empty, so nothing passes -Xmx and the JVM derives the server's heap from the cgroup limit. Measured on Temurin 21: --memory=3g -> MaxHeapSize 805306368 (768 MiB, 25%), --memory=1g -> 268435456. Lowering the cap starves boots of heap. - It is the divisor in README §5's worker-sizing formula, so raising it without lowering SPC_GRINDER_WORKERS over-subscribes the host by exactly that factor. - Both failures are OOM kills scored INCONCLUSIVE -- they look like mods that hang, not like a misconfigured host, so the operator gets no signal that they did this. The startup line now reports both caps (`cpus=… memory=3.0 GiB`), the table row and unit comment carry the warning, and the sizing section gained the paragraph explaining why "grind faster" means WORKERS or CPUS and never this. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>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>Closes the 13 NeoForge rows and the legacy-Forge era. `LoaderDescriptors` in -api is now the one home for "which descriptor evidences loader L on Minecraft V", `ModScanner.scannerFor` asks it for the era boundaries it used to own privately, and `JarSelfDeclaration.declaredLoaders` takes a Minecraft version and asks the same object instead of consulting its own flat map. Consequences, each measured: - Before 1.20.5 a lone META-INF/mods.toml names Forge AND NeoForge, because both read it there and its presence distinguishes neither. That is what unblocks botarium, decorative-blocks, agricraft, blue-skies, do-api, emitrades, faster-random, majrusz-library, rebornstorage, refined-storage-addons and you-shall-not-spawn. - From 1.20.5 mods.toml names Forge alone and neoforge.mods.toml names NeoForge, so `bellsandwhistles` (neoforge.mods.toml, ticked Forge) is still refused. - Before 1.13, mcmod.info and META-INF/fml_cache_annotation.json name Forge. Recognition only -- no mcmod.info *scanner*: it carries no sideness field, so a legacy jar still reads descriptorRead=false and is kept. Two modelling points worth keeping. `descriptorsFor` answers the **gate's** question (what evidences a jar was built for a loader), not the scanner's (which one file to parse) -- which is why NeoForge's set carries neoforge.mods.toml at every version, so a NeoForge-only jar cannot pass as a Forge mod on 1.20.1. And `LegacyFabric`'s set is deliberately empty: it reads Fabric's descriptor, so no jar can carry evidence against it, which preserves exactly the exemption the old `descriptorLoaders.values` gave it by omission. Also fixed as a side effect of the consolidation: `neoForgeUsesNeoToml` had no `runCatching` where `forgeUsesToml` did, so `scannerFor("NeoForge", "26")` threw an ArrayIndexOutOfBoundsException out of ModScanner -- and "26" is a legitimate shape under the newer scheme. Both eras now share one `atLeast` helper, so neither can lose the fallback the other has. Pinned separately. `LoaderCompatibility`'s 1.20.1 Forge/NeoForge parity rule is untouched: a jar loading unchanged is a different claim from which file a loader reads, and merging the two dates is what made this gate wrong. clientside 528, api 412 (1 skipped), 0 failures. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>`forgeUsesToml` wrapped its comparison in `runCatching { … }.getOrDefault(true)` and `neoForgeUsesNeoToml` did not, while `SemanticVersionComparator` indexes `versionNumbers[1]` and calls `toInt()` unguarded. So `scannerFor("NeoForge", "26")` threw ArrayIndexOutOfBoundsException, and `""` / `"1.x.y"` threw NumberFormatException, straight out of `ModScanner` -- while the Forge arm answered. `"26"` is not malformed: it is a legitimate shape under the newer `YY.x[.y]` scheme this codebase supports. The blast radius was the published module rather than only the grinder: `ModListCompiler` does not wrap its `scannerFor` call, so this aborted a generation. `anUnparseableMinecraftVersionFallsBackToTheModernForgeScanner` covered only Forge, so nothing noticed. Already fixed in "fix(clientside): read a jar's loader at the descriptor era it was built in", where both eras moved onto one `atLeast` helper -- so this lands green and is mutation-verified instead: dropping the `runCatching` from that helper fails 2 of the 7 guards here. Worth recording: the first draft of this guard failed against *correct* code. `assertDoesNotThrow({ … }, message)` resolves to JUnit's `Executable` overload in Kotlin and returns `kotlin.Unit`, so the assertion compared a scanner against Unit. The explicit type argument picks the value-returning overload, and the comment says so. api 412 tests, 0 failures (1 skipped). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>Griefed's report, closed. The column titled "Filename" carried a derived stem, not a filename: `FilenameStemDeriver.deriveStem` was run over the sampled file, so it read `iris-fabric-` where `iris-fabric-1.7.5+mc1.21.1.jar` was wanted. Measured over 400 live rows -- not one value ended in `.jar`, and 270 (67%) were byte-identical to `NamePattern`, so the column was redundant two thirds of the time and never once answered the question it is named for. `LoaderVerdict.sampleFile` already held the right value ("the file-name the jar-scan ran against"). `Grinder.grind`'s hand-written 18-field copy simply never carried it -- the same mapping claude-docs/ANALYSIS-AUDIT.md flagged on 2026-09-05 as asserted only five fields deep. `GrindVerdict.filenamePattern` is now `fileName` and is fed from `sampleFile`; the derived stem is gone from `LoaderVerdict` entirely, since nothing rendered it afterwards and a correct unit no caller reaches is a defect this repository keeps rediscovering. The real name serves the stem's documented purpose strictly better: it keeps the loader token a rename history erases *and* the version that identifies the build. `RecordedVerdictMappingTest` is the guard that should have caught this. It puts a distinct sentinel in "every field the mapping copies" -- and sentinelled the derived stem, which round-tripped fine, while `sampleFile` stayed `null` in the fixture. It now sentinels `sampleFile`. `theFilenamePatternIsNotWhatGetsPublished` becomes `theSampledFilenameIsNotWhatGetsPublished`, and matters more than before: publishing a stem would have stopped excluding the builds it misses, while publishing a full filename would narrow a user's fallback list to one build of one loader. /as-properties still serves `suggestedEntry` alone. Feed schema: `filenamePattern` -> `fileName`. The plugin reads and renames with it; its table never displayed the field and its exclusion logic never used it, so an older deployed plugin shows that column empty rather than breaking. The query parameter stays `filename`, so bookmarks and filters keep working. The README's column list omitted `Filename` outright and now names it, with the two patterns' difference spelled out. clientside 528, grinder 514 (29 skipped), plugin-grinder 73, 0 failures. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>unlessclause satisfies a requirement c1ac29d6d8Quilt lets a dependency name an alternative, and Quilt Loader treats the requirement as met when that id is present. Read from the live jars on 2026-09-10, four of the five refused Quilt rows declare exactly this: { "id": "quilt_resource_loader", "versions": "*", "unless": "fabric-resource-loader-v0" } geophilic, terralith, trek and true-ending all ship it. `QuiltScanner` reads `id` and `versions` and drops `unless`, so the requirement looks hard, `quilt_resource_loader` resolves to QSL, and QSL publishes nothing past Minecraft 1.21 -- measured: `qsl` for Quilt 1.21.1 returns 0 versions while `fabric-api` for 1.21.1 returns 36. Mods that run everywhere are refused everywhere. `fabric-resource-loader-v0` is a Fabric API module, which `KnownModIds` already resolves to `fabric-api` by shape, so the alternative is not merely expressible -- it is already resolvable. Pinned end-to-end through real staging rather than through the scanner, because `ModDependency` has no field for an alternative yet and a new field cannot go red, only fail to compile. Red: the pack stages the candidate alone. The counterweight passes already: a requirement with no `unless` still refuses. That is `shatterbyte-lib`/`notenoughrecipebook`, whose OctoLib-QUILT jar hard-requires `quilt_base` and genuinely targets a Quilt+QSL pairing that does not exist for 1.21.1 -- UNVERIFIABLE is correct there and must stay so. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>unlessclause when staging a dependency 1443d9f464Closes the four remaining Quilt rows. `ModDependency` gains a defaulted `unlessProvided: List<String>`, `QuiltScanner.readUnless` fills it from a `depends` entry's `unless`, and staging honours it two ways: - `stageableRequirements` drops a requirement whose alternative is already in the pack, alongside the optional/bundled/environment/already-resolved cases. - `alternativeFor` plans the alternative when the primary could not be staged, through the same `planManifestDependency` the primary went through -- so it inherits the whole mapping ladder, the version constraint and the confidence rule. First alternative that stages wins; if none does, the original refusal is handed back untouched so it still names the id the descriptor asked for. Reached only from an `Unsatisfied` primary, which is the order the descriptor implies -- `unless` names a substitute, not a preference -- and only for a plan that would refuse. An `Unmapped` primary never refuses, so spending resolves on its alternatives would buy nothing. Measured: geophilic, terralith, trek and true-ending all declare `{"id": "quilt_resource_loader", "versions": "*", "unless": "fabric-resource-loader-v0"}`, QSL publishes nothing past Minecraft 1.21 (`qsl` for Quilt 1.21.1 -> 0 versions) and `fabric-api` for 1.21.1 -> 36. The alternative was already resolvable: `fabric-resource-loader-v0` is a Fabric API module and `KnownModIds` maps it by shape. `unlessProvided` is a **list** because `unless` takes every shape `depends` does -- a bare id, an object carrying one, or an array of either -- and only ids are kept: a consumer asking "what would satisfy this" needs the id, while enforcing a range on the substitute is the loader's business. Mutation-verified: making `readUnless` return empty fails `anUnlessAlternativeSatisfiesTheRequirement` and nothing else. -api addition is source-compatible (`ModDependency` is not a data class, so its equality is identity and a new defaulted parameter breaks no caller). Owes a row in claude-docs/API-BEHAVIOUR-CHANGES.md: a Quilt jar's scan now reports what its `unless` clauses name. api 413 (1 skipped), clientside 530, grinder 514 (29 skipped), plugin-grinder 73, 0 failures. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>Found while reading the UNVERIFIABLE rows, both at the platform boundary: - Modrinth: a dependency entry may carry a `version_id` and a **null** `project_id` -- an author pinning one exact build. `filesOf` reads only `project_id`, so such an entry vanishes from `requiredDependencies` and `relatedDependencies` alike, with no log, and `askLinkedProjects` cannot recover it either because it reads the same list. - CurseForge: `asText()` on a JSON-null `modId` returns the literal `"null"` -- the documented `textOrNull` hazard, and the one place it was still live. The red output shows it exactly: `expected <[306612]> but was <[null, 306612]>`. Red as committed, all four for the missing implementation: the pinned dependency resolves to nothing (`expected <[P7dR8mSH]> but was <[]>`), no `/version/{id}` request is made at all, and the CurseForge list carries the fabricated ref. The memoisation guard is in from the start because an un-memoised lookup is a request per version of the project -- the cost shape this module already paid for once with CurseForge's paging. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>nullone 37e2d27975Two platform-boundary defects found while reading the UNVERIFIABLE rows, plus the stale docs beside them. **A version-pinned Modrinth dependency was dropped in silence.** An entry may carry a `version_id` and a null `project_id`; `filesOf` read only `project_id`, so it vanished from `requiredDependencies` *and* `relatedDependencies` -- never staged, and invisible to `askLinkedProjects`, which reads the same list. The loader then refused the pack and the candidate wore the verdict. `projectBehind` reads either shape and resolves a pin with one GET of `/version/{id}`, **memoised**, because `resolve` walks a project's whole version list and a pin is normally repeated by every version of it. It fails toward dropping, exactly as before, rather than recording a ref that names nothing. The pinned *build* is deliberately not honoured: `pickDependencyFile` chooses among a project's files by loader, Minecraft version and obtainability, and a pin would override all three to satisfy a constraint the loader does not enforce. **A JSON-null CurseForge `modId` became the literal ref `"null"`**, resolved to nothing, and was reported as an unmet dependency named `null` -- the documented `textOrNull` hazard, and the one place it was still live. Docs corrected in the same pass: `NeoForgeTomlScanner`'s own KDoc said the boundary was Minecraft 1.16.5 while the dispatch constant one file over says 1.20.5, and it now points at `LoaderDescriptors.neoForgeUsesNeoToml` rather than restating a literal. `serverpackcreator-api/module.md`'s modscanning section named `Scanner`, `JsonBasedScanner` and `ScanningException`, none of which exist any more, and omitted `LoaderDescriptors`, `ModJarScanner`, `QuiltPackScanner`, `FabricFamilyScanner` and `ScannedMod`. api 413/0, clientside 554/0. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>unlessalternative counts when it is bundled, not only provided e775fbd42fCharacterization, all green when written -- the code was right, the guards were absent -- so each is **mutation-verified** rather than trusted, and each mutation failed exactly its own guard and nothing else in the 566-test suite. - `UnlessClauseShapesTest` (-api, new): every shape Quilt's `unless` takes -- bare string, object with `id`, array mixing both, a clause holding only unusable entries, no clause, and a bare-string *dependency* that cannot carry one. The field shipped with one assertion anywhere, in a `-clientside` integration test writing the bare-string form, so two of the three shapes this **published** parser handles were unexercised. Parsing is the case this repo requires a test for first, because a wrong branch yields a plausible value rather than an error. - `curseForgeReleaseTypesBecomeChannels` + `aCurseForgeReleaseBeatsANewerMinecraftBeta`: CurseForge's half of the release-channel rule had no assertion at all -- `fromCurseForge` has one call site and the existing CF fixtures set `releaseType:1` incidentally. Mutation: forcing it to RELEASE fails both. - `theChannelPreferenceNeverOverridesLoaderAvailability`: every existing channel guard passes `{ true }` for availability, so nothing held the channel filter *inside* the gate. Mutation: hoisting it above the gate fails this one, and would otherwise have made a project whose only release targets an unsupported Minecraft unverifiable. - Three `loaderToVerifyUnder` guards: the platform-tagged preference, the alphabetical tie-break (asserted from both set orders, since the point is that it does not depend on iteration order), and nothing-bootable yielding no choice. Mutation: dropping the tagged preference fails the first. - `anEntryWithNoRefForAPlatformFallsBackToThatPlatformsGuess` + `aForkIsNeverOfferedTwice`: the reason both new registry entries carry a deliberately-`null` CurseForge ref -- without the fall-through the entry would have *removed* that platform's existing guess. - `anUnmappableIdCrossesForExactlyOneExtraResolve`: `Unmapped` is where most unresolvable manifest ids land, so it is the state that decides what the cross-platform fallback spends of an API key's quota, and the first cut of that feature left it out entirely. One extra resolve per id, home platform first. - `anUnreadableMinecraftVersionStillAnswersTheModernDescriptors` + `anUnknownLoaderEvidencesNothing`: the pre-boot **gate** asks `descriptorsFor`, not `scannerFor`, so pinning only the dispatch left the more consequential caller uncovered. api 421/0, clientside 566/0. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>Red. Reproduces the reported CurseForge/aether row: the Forge verdict named `aether-1.12.2-v1.5.4.1.jar` while its DEPENDENCY_FAILURE detail described `aether-1.20.1-1.5.2-neoforge.jar`, the jar staging had actually selected. Measured against the live CurseForge API on 2026-09-11 and reproduced here with the same shape: - the 1.20.1 build is tagged ['NeoForge', '1.20.1', 'Forge'] and pickBootableCandidate orders newest-Minecraft-first inside a release channel, so the Forge boot staged it; its META-INF/mods.toml declares modId = "curios", mandatory = true, versionRange = "[5.3.1+1.20.1,)" - the 1.12.2 build was re-uploaded in 2025, so it is the newest-*uploaded* Forge-tagged file, which is what `loaderFiles.firstOrNull()` returns; its mcmod.info declares `"dependencies": []` and CurseForge lists curios against it only as relationType 1 (EmbeddedLibrary), which CurseForgePlatform correctly ignores Both halves were true of different files, so a reader checking the row against the platform page correctly concluded the dependency resolution had gone wrong. What had gone wrong was the attribution. Observed failure, against the shipped manifest's oldest and newest Forge-capable releases: expected: <themod-26.2-1.5.2.jar> but was: <themod-1.1-v1.5.4.1.jar> with the log line `Not booting themod on Forge: Could not download themod-26.2-1.5.2.jar.` beside `Could not download themod-1.1-v1.5.4.1.jar for Forge; jar-scan unavailable.` -- the two selections disagreeing in one run. Both guards are executed rather than asserted against a re-implementation of the selection rules: the pick is observed through the file staging asks the downloader for. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>Turns the previous commit's pin green. Two halves, because the file a caller *chooses* and the file that *runs* are routinely different: - `ClientsideVerifier.verdictFor` samples the combination staging would pick (`BootCandidateSelector.pickBootableCandidate`) instead of `loaderFiles.firstOrNull()`, which is the platform's newest *upload* -- a different file for any project that re-published an old build. - `BootOutcome.bootedFile`, stamped from the staged pack beside its sibling `bootedLoader` in the one place that knows what was booted, and preferred over the metadata pick. Only the boot can answer this: staging re-selects on a loader or Minecraft range the jar declares, and the crash re-checks boot other builds entirely. Also fixes a silent mis-scan found on the way: `scanSample` chose its Minecraft version with `minecraftVersions.maxOrNull()` -- a *lexicographic* maximum, so a file tagged `1.9` and `1.20.1` was scanned as `1.9` and got `scannerFor`'s answer for the wrong era. The version now comes from the same pick, ordered by `BootCandidateSelector.minecraftComparator`. Behaviour change for an embedder: `LoaderVerdict.sampleFile` (the grinder's `Filename` column, and `/verdicts.json`'s `fileName`) can now differ from the file it named before -- it names the staged build rather than the newest- uploaded one, which is the point. Suite: 570 tests, 0 failed, 0 skipped -- 568 pre-existing assertions unchanged, plus this pin's two. Grinder and app suites green. No new compiler warnings in -clientside. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>Sideness is a property of a build, and builds differ far more across Minecraft eras than across loaders of one era. Grinding once per loader therefore spent most of its boots re-asking one era's question and never asked the older eras at all: measured 2026-09-11 over the 200 most-downloaded Modrinth mods, 3.06 boots per project covering a mean of 1.6 distinct lines, and CurseForge/aether's 1.12.2 build -- a wholly separate codebase -- was never booted under any loader. This is the selection half of moving the axis. Two halves rather than one list, because each covers the other's blind spot: a bare count never reaches 1.12.2 for a project publishing for sixteen lines (JEI does), and a bare list goes stale in silence, since a new Minecraft release is simply never ground until somebody edits an environment variable. Measured cost of the shipped defaults (newest 2 + 1.21,1.20,1.12) on the same 200 projects: 3.83 boots/project against today's 3.06, i.e. 1.25x. "Every line" would be 7.38, or 2.41x, which would break the sizing rule that SPC_GRINDER_REVERIFY_TTL_DAYS must outlast a full sweep. The boundary for a red pin does not exist -- nothing to compile a guard against before the unit does -- so per this repo's convention the mutations that reproduce the red are quoted instead, both run and observed: - `.sortedWith { l, r -> minecraftComparator.compare(r, l) }` -> `.sortedDescending()` (a string compare) newestIsOrderedNumerically: expected <[1.20]> but was <[1.9]> - `newestCount.coerceAtLeast(1)` -> `newestCount` aPolicyThatWouldSelectNothingStillKeepsTheNewestLine: expected <[1.21]> but was <[]> That floor is not defensive tidiness. A candidate that records no verdict is indistinguishable from one the engine failed on: nothing is stored, so the freshness check keeps answering "never seen" and the project is re-selected every sweep forever -- the same shape as SPC_GRINDER_WORKERS=0, a knob that parsed fine and was unusable. Nothing calls this yet. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>The other half of the selection change. Each line MinecraftLinePolicy selects gets exactly one target, under the first loader of LOADER_PRIORITY (NeoForge, Forge, Fabric, Quilt, LegacyFabric) that line has a bootable build for. A line no loader can boot is dropped rather than reported -- nothing ran, so a verdict about it would be a verdict about our own selection. Two compositions needed care, and both are pinned: - **A file's versions are narrowed to the line, not just its files.** One published file is routinely tagged across lines, and pickBootableCandidate takes the newest version it is *shown*. Handing it the whole set lets a 1.20 line boot at 1.21, which is the one thing a per-line axis exists to stop. - **A stated loader has to win across loaders, not only within one.** The untagged-file fallback fires inside pickBootableCandidate and an untagged file matches every loader, so a single pass down the priority order hands one to NeoForge while Forge has a file its author actually tagged -- a jar staged for a loader that will ignore it, which can boot cleanly and publish a false CLEAR. Hence `untaggedFallback` on pickBootableCandidate (defaulting to today's behaviour, so no existing caller changes) and two passes. `everySupportedModloaderHasAPriority` asserts the order against SupportedModloaders.names rather than a copy: a loader SPC supports but the order omits would be silently never ground, the same shape as a verdict missing from the grinder's rank. No red pin was possible -- nothing to compile a guard against before the unit exists -- so the mutations were run and observed instead: - two passes -> one pass with untaggedFallback = true aTaggedFileBeatsAnUntaggedOneEvenForALowerPriorityLoader: expected <(Forge, tagged-forge.jar)> but was <(LegacyFabric, untagged.jar)> - filesWithin narrowing -> a plain `any { line }` filter aFileTaggedAcrossLinesBootsAtTheLinesOwnVersion: ... but was <[1.21 Forge mod-wide.jar @ 1.21.1, 1.20 Forge mod-wide.jar @ 1.21.1]> - LOADER_PRIORITY reversed aetherIsGroundOncePerMinecraftLineUnderOneLoaderEach: ... but was <[1.21 Fabric ..., 1.20 Fabric ..., 1.12 Forge ...]> The fixture is CurseForge/aether's real shape, read from the live API on 2026-09-11 -- including `aether-1.20.1-1.5.2-neoforge.jar` being tagged ['NeoForge', '1.20.1', 'Forge'], one file for two loaders. Under the loader axis that project costs three boots of which two land on 1.21.1; under this one it costs three that ask three different questions. Suite: 591 tests, 0 failed, 0 skipped. Nothing calls this yet. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>Strangler-Fig step: `verify(project, target, …)` and `prepareBootPack(project, target, …)` sit beside the loader entry points, which are untouched and still select for themselves. Nothing calls the new ones yet. Three parts: - `BootOutcome.minecraftVersion`, stamped from the staged pack beside `bootedLoader` and `bootedFile`. No layer carried the booted Minecraft version at all before this -- the only place it reached disk was `BootLogStore.attemptKey` -- so a row could not say which era its evidence came from. - `prepareBootPack` splits into select-then-stage and stage-a-chosen-target over a shared `prepareChosen`, so a caller supplying its own combination still gets the loader- and Minecraft-contradiction retries. - the newest-build crash re-check takes a `restageOnLoaderVersion` closure rather than calling `prepareBootPack(project, loader, …)` itself. For a target that difference is the whole point: re-selecting would answer a crash on one Minecraft line with a boot on another, which is a different mod's worth of code. Selection is deliberately **not** repeated inside `prepareBootPack(project, target)`: `pickGrindTargets` is handed `bootableCombination` to choose with, so a target already satisfies that gate, and asking twice would be a second silent predicate free to disagree with the first. An unbootable combination still refuses honestly one step later, naming its own version -- pinned. `bootableCombination()` becomes public for the same reason: the caller doing the selecting needs the gate the boot will apply, and a second copy of it is how the metadata scanners drifted. Mutation, run and observed (the boundary for a red pin does not exist -- the entry point is what is being added): `prepareBootPack(project, target)` -> `prepareBootPack(project, target.loader)` aTargetIsBootedAsChosenRatherThanReSelected: expected: <[themod-1.1.jar]> but was: <[themod-26.2.jar]> `theLoaderEntryPointStillSelectsTheNewestItself` asserts the old entry point in the same fixture, which is what makes that guard mean something: the two genuinely diverge there, so honouring the target is a decision and not an accident of the fixture. Suite: 594 tests, 0 failed, 0 skipped. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>The axis moves off the modloader. `ClientsideVerifier.report` asks `pickGrindTargets` for one target per Minecraft line rather than mapping over `project.loaders`, and `LoaderVerdict`/`GrindVerdict` carry the line and the exact version the pack was staged at. What a reader sees, on the reported project: CurseForge/aether went from Fabric + Forge + NeoForge -- two of which were about Minecraft 1.21.1, while its 1.12.2 build was never booted at all -- to 1.21/NeoForge, 1.20/NeoForge and 1.12/Forge. Three boots that ask three questions instead of three that ask one and a half. **Why this could not be split by module.** The scratch directory is *named* in -clientside and *addressed* from -grinder, and it had to gain the Minecraft line in the same change: AttemptDirectory.nameFor(platform, slug, loader) -> nameFor(platform, slug, loader, minecraftLine) One loader now owns several of a project's rows -- NeoForge on 1.21 and on 1.20 -- so the old name would have the second target wipe the first's pack and console mid-run. That is the `creativecore` failure exactly: two runs sharing a directory produced SURVIVED and CRASHED for the identical build. `ownerOf` cuts `SUFFIX_PARTS` trailing segments to match, and a half-applied rename would silently re-scope the reaper. Three behaviour changes worth stating plainly: - **`loaderDisprovingTheCrash` asks for another *row*, not another loader.** Two rows of one project now routinely share a loader and differ by era, and a clean 1.20 boot disproves a 1.21 crash for exactly the reason a clean NeoForge boot disproved a Forge crash -- they publish the same entry, which is `startsWith`-matched and would strip the build proven to boot. - **`suggestedEntry` is deliberately unchanged**: still the stem over the loader's whole history, because that is what `/as-properties` publishes. Narrowing it to a line would publish a pattern missing the builds it was never shown, and it is also what lets two lines of one loader disprove each other. - **`BootLogStore` addresses a tuple by line too**, so `pruneExcept` no longer deletes another line's kept consoles. Every log already on disk carries the old three-part owner and is therefore unreachable from a row; the budget is what reclaims it. `adoptLegacy`'s pre-per-attempt consoles record no Minecraft version anywhere, so they are adopted, readable and listed, and attributable to no line -- pinned as such, so nobody later "fixes" it by guessing one. Mutations, run and observed: - targets `.distinctBy { it.loader }` (i.e. back to one row per loader) oneVerdictPerMinecraftLineNotOnePerLoader: expected <[(1.21, NeoForge), (1.20, NeoForge), (1.12, Forge)]> but was <[(1.21, NeoForge), (1.12, Forge)]> - `other !== verdict` -> `other.loader != verdict.loader` aCleanBootOnAnotherMinecraftLineOfTheSameLoaderDisprovesTheCrash: ... was <null> - `ownerOf` cutting one part instead of SUFFIX_PARTS anAttemptDirectoryNamesTheCandidateThatOwnsIt: expected <Modrinth-creativecore> but was <Modrinth-creativecore-Fabric> Test changes: the ~20 staging tests that build a mods directory through `nameFor` are reference-only -- one argument added, no expectation touched. Four are not, and each is the deliberate behaviour change rather than an accident: `SampledFileMatchesTheBootedFileTest` now asserts a row *per line* instead of a single row, the two log-column fixtures needed a row identity to look their logs up by, and the `adoptLegacy` guard swapped one reachability claim for the honest one above. Suites: clientside 601 / 0 failed, grinder 516 / 0 failed / 29 skipped (gated ITs), app 149 / 0 failed. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>Red, and the first failure is a defect the previous commit introduced: with the axis moved to the Minecraft version-line but `verdictKey` still built from the loader, two rows of one project that share a loader collide and one silently overwrites the other. CurseForge/aether is NeoForge on both 1.21 and 1.20, so the store would report one era's evidence as the whole project's. Observed: twoMinecraftLinesOfOneLoaderAreTwoRows expected: <[1.20, 1.21]> but was: <[1.20]> aLineThatChangesLoaderKeepsOneRow expected: <[(NeoForge, after)]> but was: <[(Forge, before), (NeoForge, after)]> aLineRowSupersedesThatProjectsLegacyLoaderRows expected: <[(aether, 1.21), (jei, null)]> but was: <[(aether, null), (aether, 1.21), (aether, null), (jei, null)]> theJsonStoreKeysOnTheLineToo expected <[1.20, 1.21]> but was <[1.20]> theJsonStoreSupersedesLegacyRowsAcrossAReopen expected <[1.21]> but was <[1.21, legacy]> Two of the seven pass already, and say why rather than pretending otherwise: `reGrindingOneLineReplacesOnlyThatLine` and `anUntouchedProjectKeepsItsLegacyRows` use fixtures whose loaders differ too, so the loader key happens to give the same answer. They are kept because they pin the contract, not because they currently discriminate. The migration half is pinned as deliberately as the key: the deployed store holds tens of thousands of loader-keyed rows, and they have to go **per project as it is re-ground** -- never on a schedule, and never before a replacement exists. `anUntouchedProjectKeepsItsLegacyRows` is the counterweight that stops the sweep growing to cover projects nothing has re-ground. Both stores are asserted, because they once drifted apart over a key scheme before -- the NUL-separator fix -- and the JSON one is asserted across a reopen, which is where the deployed store actually lives. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>Turns the previous commit's pin green, and closes the collision the axis change opened: `CurseForge/aether` is NeoForge on both 1.21 and 1.20, and with the loader in the key the second row overwrote the first. verdictKey(platform, slug, loader, projectId) -> verdictKey(platform, slug, minecraftLine, projectId) `loader` stays a recorded field and a report column -- it is what produced the evidence -- it just stops being the row's identity. Keeping it is also what leaves `/verdicts.json`, `/export.csv` and `GrinderAuditIT` (which hard-fails on a missing `Loader` header) working unchanged. **`supersededLegacyKey` could not be extended, and the reason generalises.** It computes the superseded key from fields the *new* verdict still carries, which works for the one-to-one `slug:` -> `id:` hop and cannot work here: three loader rows collapse into one line row, and the line row can name only the loader it happened to pick. `supersededLoaderKeys` removes by **prefix** instead -- every key of that project whose last part is not `mc:`-marked -- which is why the line is spelled into the key rather than merely concatenated. The migration is per project, as it is re-ground, and deliberately nothing else: a sweep at load time or on a timer would discard evidence before a replacement exists, and the deployed store holds tens of thousands of rows. A legacy verdict recording itself supersedes nothing, or a build predating the change would delete its own project's neighbours. Mutations, run and observed: - `+ "mc:" + minecraftLine` -> `+ minecraftLine`, and the supersession's null guard inverted: anUntouchedProjectKeepsItsLegacyRows expected <2> but was <1> aLineRowSupersedesThatProjectsLegacyLoaderRows ... but was <[(aether, null), (aether, 1.21), (jei, null)]> theJsonStoreSupersedesLegacyRowsAcrossAReopen expected <[1.21]> but was <[1.21, legacy]> Two existing tests asserted the old axis and are **restated**, not deleted -- the stop-and-flag signal, firing on the change it exists for: `distinctLoadersOfOneProjectCoexist` becomes `twoLoadersOfOneLineAreOneRow` (a line is ground under exactly one loader, so two such verdicts describe one era twice), and `recordsOneVerdictPerLoaderFromTheReport` becomes `recordsOneVerdictPerMinecraftLineFromTheReport`. The on-disk row order gains the line so a diff of the store stays readable. Grinder suite: 516 tests, 0 failed, 29 skipped. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>A row's identity became the Minecraft version-line, and until now nothing on `/`, `/export.csv` or `/verdicts.json` said which era a verdict was about -- `jei` simply appeared five times with no way to tell the 1.12 finding from the 26.2 one. Two `VerdictField` entries, so the header, the CSV cell, the query key, the filter kind and the sort key all come from one declaration as every other column does: `Minecraft` (the line, a CHOICE, because "what does this mod do on 1.12?" is a small closed set) and `MinecraftVersion` (the exact build, beside it for the same reason `Filename` sits beside `Name-pattern` -- one is the row's identity, the other is what reproduces the boot). The sort key is **numeric**, not the cell text. A line sorted as text puts `1.9` above `1.20`, which is the mistake `BootCandidateSelector.minecraftComparator` exists to prevent one layer down, and a table cannot report it -- it just looks like an odd order. The default ordering's tie-break is now newest-era-first inside a project, which is both the order the grind produces and the one a reader wants; the loader stays the last tie-break, because a legacy row carries no line and two of them would otherwise be ordered arbitrarily. Mutations, run and observed: - numeric sortKey -> the bare cell text theLineSortsNumericallyRatherThanAlphabetically: expected <[1.9, 1.12, 1.20, 26.2]> but was <[1.12, 1.20, 1.9, 26.2]> - the era tie-break removed aProjectsRowsLeadWithItsNewestEra: expected <[26.2, 1.20, 1.12]> but was <[1.12, 26.2, 1.20]> `Loader` stays exactly where it was, which is what keeps `GrinderAuditIT` -- it hard-fails on a missing `Loader` header -- and the plugin's feed reading unchanged. Four fixtures move because a column was added: two hard-coded CSV header strings, the renderer's ordered sentinel list, and the server test's header prefix. A legacy row renders a blank rather than a guessed era: `1.20` would claim something nobody recorded and `UNKNOWN` reads as though we looked. Grinder suite: 522 tests, 0 failed, 29 skipped. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>The safeguard the axis change owed. A project used to be ground under every loader it publishes for, so a wrong crash routinely met a clean boot from a sibling loader *in the same run* and `loaderDisprovingTheCrash` threw it out for free -- that is the `iron-chests` story, Forge CRASHED beside NeoForge SURVIVED, same entry. One loader per Minecraft line means nobody boots that sibling unless something asks. Two halves now do: - **`shouldRecheckAgainstOtherVersions` also arms on a *decisive* crash**, not only on one the metadata contradicts. A decisive rung reaches CONFIRMED, which strips the mod from every server pack built against the fallback list, and that is worth one boot whatever the metadata says. In practice this arm reaches `OPERATOR_RULE` alone -- the other decisive rungs all prove client-only and are excluded above -- which is exactly right: a hand-written rule is the one decisive signal nothing else cross-checks. It matters especially for CurseForge, which publishes no sideness at all, so the metadata gate almost never opened there. - **`pickRecheckCandidates` spends its first attempt on the crashing era's other loader.** Since every *other* Minecraft line is now a first-class verdict the report reconciles against for free, spending the budget there re-buys evidence the run produces anyway. The diverse ladder still runs for the rest of the budget, so a project with one loader and one line samples exactly as deeply as before. That is also a straight improvement to the case the old ordering was written for. `creativecore` crashed on Fabric / Minecraft 26.2 while NeoForge booted a server; the diverse sample reached NeoForge on 1.21.1, two eras away, and the first pick is now NeoForge / 26.2 -- the very boot that contradicted the crash, in one attempt instead of two spent on neighbouring Fabric versions. Mutations, run and observed: - the `decisive` arm removed aCrashThatIsAboutToBePublishedIsReCheckedEvenIfTheMetadataAgrees: expected <true> but was <false> - the sibling-loader pick removed aCrashIsReCheckedOnItsOwnErasOtherLoaderFirst: ... but was <[(CreativeCore_FABRIC_..._mc26.1.2.jar, Fabric, 26.1.2), (CreativeCore_NEOFORGE_..._mc1.21.1.jar, NeoForge, 1.21.1)]> `aCrashIsReCheckedOnAnotherLoaderRatherThanTwiceOnItsOwn` is renamed and its expectation restated -- the stop-and-flag signal firing on the change it exists for, and in the direction its own narrative argues for. `aCrashThatCannotBePublishedIsStillNotReChecked` is the counterweight: the bare exit code means only "nothing recognised why", reaches INCONCLUSIVE, strips nothing, and still buys no boots. Clientside suite: 610 tests, 0 failed, 0 skipped. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>Which Minecraft version-lines a project is ground on becomes operator configuration rather than a constant, and it is the biggest lever the daemon has on what a sweep costs -- it multiplies the boots per project. SPC_GRINDER_MINECRAFT_LINES_NEWEST 2 the project's own newest N SPC_GRINDER_MINECRAFT_LINE_ANCHORS 1.21,1.20,1.12 older eras, when published Two knobs rather than one because each covers the other's blind spot: a bare count never reaches 1.12.2 for a project publishing for sixteen lines (JEI does), and a bare list goes stale in silence -- a new Minecraft release would simply never be ground until somebody edited an environment variable. Measured over the 200 most-downloaded Modrinth mods, 2026-09-11: newest 2, no anchors 2.00 boots/project 0.65x newest 2 + 1.21,1.20,1.12 (the default) 3.83 1.25x newest 2 + 1.21,1.20,1.16,1.12 4.35 1.42x every line the project publishes 7.38 2.41x against the old per-loader axis's 3.06. That table is in README §5 beside the knobs, because the number an operator needs is the one that decides whether SPC_GRINDER_REVERIFY_TTL_DAYS still outlasts a sweep. An empty anchor list is honoured as "only the newest N" rather than coerced to the default: it is a legitimate choice, unlike an unparseable number. The newest-count floor of one stays in `MinecraftLinePolicy`, not here, so every caller gets it. The three documentation guards did their job on the first run -- README, systemd unit, and `everyVariableReadIsDeclaredAsAKnob`, the last because the reader was wrapped across lines and its regex alphabet matches `intIn("NAME"` on one. Worth recording: the guard that looks like bookkeeping is the one that catches a knob nobody can find. Grinder suite: 522 tests, 0 failed, 29 skipped. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>Every place that described a verdict as per-modloader now describes it as per Minecraft version-line, plus the landmines the change created and the narrative behind it. Corrected rather than merely appended to, because a stale claim is worse than none: - the store key sentence (README §6, grinder CLAUDE.md) said platform + slug + loader - the staging paths said `<slug>-<Loader>` -- which was already wrong before this, the real name having carried the platform since 2026-08-23 - "loaders are assessed in sorted order (Fabric, Forge, NeoForge, Quilt)" - the `(platform, slug, loader)` scratch-ownership landmine, and "cut only the loader suffix when parsing" - `loaderDisprovingTheCrash` comparing loaders rather than rows - the plugin tab's column list New: README §5 *What gets ground* (the operator-facing half, with the measured cost table), an axis section in both module `CLAUDE.md` files, and four lessons in the root file. `claude-docs/API-BEHAVIOUR-CHANGES.md` is deliberately untouched: it records changes to the **published** `-api`, and every module this touched is unpublished. The wire contract that does have an outside consumer -- the grinder plugin reading `/verdicts.json` -- is documented where that feed is, in `grinder/report/CLAUDE.md` and the plugin's own context file. The log entry also records a defect in one of this branch's own commit messages: `fix(clientside): re-check a publishable crash…` claims 610 clientside tests where the measured figure is 603. Written from recall instead of from `build/test-results/test/*.xml` -- the exact failure "cite names, not snapshots" exists to prevent, in the one place that convention allows a number. Counts in the root table re-derived from a full run rather than carried forward: api 421 (1 skip), clientside 603, app 149, plugin-grinder 75, grinder 529 (29 skip). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>The consequence the axis change left behind, closed rather than documented away. The artifact owner gained the Minecraft version-line, so every console, server log and crash report already on disk is filed under a three-part owner that `namesFor` -- which rebuilds a four-part prefix -- can never find. Those are the consoles behind verdicts that are **still published**, and a CONFIRMED exclusion has to stay auditable; left alone they would be reclaimed by SPC_GRINDER_BOOT_LOG_BUDGET_MIB while the verdicts they evidence kept serving. **The line is not guessed.** The attempt segment beside the owner already records what was booted -- `<loader>_<loaderVersion>_mc<version>` -- so the part the owner is missing is sitting in the same file name. `migrateOwnerNames()` reads it back and renames, at startup beside `adoptLegacy`. Conservative in three places, each pinned: - an owner whose last part already looks like a version-line is left alone, so the pass is idempotent; - an attempt segment carrying no `_mc` -- `LEGACY_ATTEMPT`, from before per-attempt naming -- records no version at all and is left alone rather than filed under a guessed era; - a failed rename, or one whose target exists, is skipped with a warning: this runs at startup and must never stop a daemon that has verdicts to serve. Mutations, run and observed: - the already-migrated check removed aSecondMigrationMovesNothing: expected <0> but was <1> - an absent version defaulted to "1.20" anArtifactRecordingNoMinecraftVersionIsNotMoved: expected <0> but was <1> `BootCandidateSelector.minecraftLine` becomes public for this. A second copy of that rule in -grinder is exactly the duplication this repository has paid for three times; the alternative was re-deriving a version-line from a string in another module. The migration fixture is `iron-chests`, deliberately: its slug contains the `-` that makes an owner unsplittable, which is why the rename appends rather than rebuilding the name from parts. Grinder 532 / 0 failed / 29 skipped, clientside 603 / 0 failed. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>Reference-only, once the behaviour was settled: LoaderVerdict -> GrindTargetVerdict ClientsideReport.perLoader -> perTarget loaderDisprovingTheCrash -> targetDisprovingTheCrash supersededByLoader -> supersededByTarget loaderVerdict(...) -> targetVerdict(...) (test fixture) A collection called `perLoader` holding one entry per Minecraft era is the kind of stale name this repository treats as a defect, and the guard it fronts really does now look for another *row* (`other !== verdict`) rather than another loader. `refactor:` is honest here under the conventions' own carve-out: every hunk in the test tree is a receiver or a type name, and no assertion, argument or expected value changed -- verified by filtering the diff for anything that is not one of the five renames, which comes back empty. `GrindVerdict.loader` and `GrindTargetVerdict.loader` are untouched: the loader is still recorded, still a report column, and still what produced the evidence. It just stopped being the row's identity. `claude-docs/ANALYSIS-AUDIT.md` and `REFACTOR-AUDIT.md` are deliberately left spelling the old names, as are every `REFACTOR-LOG.md` entry before today's: they record what was true when they were written, and rewriting a historical log to match today's symbols is how a record stops being one. Today's entry says so. Suites green: clientside, grinder and app. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>First of the two structural dependency-failure fixes, from reading all 40 DEPENDENCY_FAILURE consoles on the public grinder (2026-09-11). Five of them are this: **a jar-in-jar library is on the classpath exactly like a staged one, so its demands bind exactly like a staged one's** -- and nothing read them. `BundledJars` reported only what a nested jar *provides*. Two live failures, each verified by opening the published jar: - `Modrinth/highlight` declares `depends: { "resourcefullib": "*" }` and ships META-INF/jars/resourcefullib-fabric-26.2-5.0.3.jar, so the requirement was rightly dropped -- the library is already inside. That bundled jar then declares `depends: { "fabric-api": "*" }`, which nothing read, so Fabric API was never staged. The boot died with "Resourceful Lib requires any version of fabric-api, which is missing" and the INCONCLUSIVE was charged to `highlight`, whose stagedDependencies was empty. - `quilted-fabric-api-11.0.0-alpha.3+0.102.0-1.21.jar` bundles `qsl_base-10.0.0-alpha.1+1.21.jar`, which pins `minecraft [1.21, 1.21]` -- exactly, not a line. Staged into a Minecraft 1.21.1 pack it refused the whole pack; QFAPI's own top-level descriptor says nothing that would predict it. Four rows: notenoughrecipebook and shatterbyte-lib, on both platforms. `BundledJars.requirementsIn` and `minecraftDemandsIn` are separate because the consequence is: an unmet mod dependency is *staged*, while a bundled jar built for another Minecraft can only be answered by dropping the jar that carries it. `minecraft` is therefore excluded from the first and is the whole of the second. Both keep the class's existing restraint. Only jars the descriptor *declares* count -- a stray file under META-INF/jars/ is not on the classpath. A range that is not a plain string (Quilt permits an object, Fabric an array of alternatives) yields no opinion rather than a guess. An unreadable jar demands nothing. And the loader's own ids are left in, because `stageableRequirements` is the one place that decides what the environment provides. Pinned at both levels, because a correct unit no caller reaches is this module's most-repeated failure. Mutations, run and observed: - the staging wiring removed aLibraryDemandedOnlyByABundledJarIsStillStaged: expected <[hightlight-26.2-4.2.0.jar, fabric-api-0.160.0.jar]> but was <[hightlight-26.2-4.2.0.jar]> - the nested Minecraft pin removed aDependencyBundlingAJarThatExcludesThePacksMinecraftIsDemoted: expected <[some-lib-1.0.0.jar, some-mod-1.0.0.jar]> but was <[some-lib-2.0.0.jar, some-mod-1.0.0.jar]> Clientside suite: 612 tests, 0 failed, 0 skipped. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>Second structural dependency-failure fix, and the biggest single group: **12 of the public grinder's 40 DEPENDENCY_FAILURE rows** are `fabric-language-kotlin` demanding `fabricloader [0.19.5, ∞)` against the `0.19.3` quilt-loader 0.30.1 provides. Read from the published jars: quilt-loader 0.30.1 provides fabricloader 0.19.3 quilt-loader 0.31.0-beta.4 provides fabricloader 0.19.5 The demand was invisible at three layers, and all three had to move: 1. **The scanner strips it.** `FabricScanner.dependencyExclusions` drops `(fabricloader|java|minecraft)` and Quilt's does the same -- correctly, since those are the platform rather than mods to stage, and reporting them would have `ModListCompiler` try to rescue a loader into a pack. So `BundledJars.demandsOn` reads them back off the staged jar, narrowed to the ids the loader could actually describe: it can only add a comparison that can be made, never a duplicate of what the scanner already reports. 2. **Nothing knew what a loader provides.** New `loaderProvides` seam on BootVerifier, defaulting to knowing nothing -- which is the old behaviour, and `aLoaderNothingCanDescribeDemotesNothing` pins that a gap in our knowledge never demotes anything. The grinder implements it by reading the cached install layer's own loader jar, because a table of loader->provides pairs would be a snapshot going stale with every release. 3. **DependencyBacktrack skipped it.** A requirement naming something not staged is skipped by design; with the pair in hand `fabricloader` is present, the conflict is real, and the demanding jar is demoted to a build the installed loader can satisfy. Also, the half that made this hard to see at all: **every one of 16 of 16 Quilt boots printed `Quilt Loader 0.30.1` while its verdict reported `Quilt 0.31.0-beta.4`** -- the build staging chose. `BootLoaderVersion` now reads the build the console announces and states the disagreement in the detail, so a row stops claiming a build it never ran. Every pattern in it is verbatim from a kept console. The newest-build re-check is deliberately **not** armed off it: if the installer keeps producing 0.30.1 that would cost a boot per row and fix nothing, and why 0.30.1 is installed for a tuple labelled 0.31.0-beta.4 needs the daemon's install cache to answer. Mutations, run and observed: - the loader's provides removed from the judge's staged set aDependencyDemandingMoreThanTheLoaderProvidesIsDroppedToAnOlderBuild: expected <[YetAnotherConfigLib-3.4.2.jar, Zoomify-2.13.3.jar]> but was <[Zoomify-2.13.3.jar, yet_another_config_lib_v3-3.6.6.jar]> Suites: clientside 621 / 0 failed, grinder 532 / 0 failed / 29 skipped. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>`pickUntagged`'s safety argument is that untagged CurseForge files are pre-1.13, from before the platform had a modloader facet, so "only Forge is reachable and untagged *means* Forge". Measured against the live API on 2026-09-11, that is false: `TerraBlender (Forge)` publishes TerraBlender-forge-26.2-26.2.0.0.2.jar gameVersions=['26.2'] untagged, for Minecraft 26.2, in 2026. So it matched a **Fabric** boot and a **NeoForge** boot alike, `biomes-o-plenty` was staged the Forge build of its own dependency on both, neither loader could see it, and `terrablender` came out `[MISSING]` in two published rows. How it got there is worth recording, because the platform metadata was fine: only BoP's newest 4 Fabric and 5 NeoForge files declare a required TerraBlender ref at all -- 26 NeoForge and 13 Fabric files declare none. The booted (older) file therefore had nothing on the platform route, its manifest id `terrablender` fell through to the slug guess, and slug `terrablender` is the **Forge** project (563928), whose files are untagged. The fallback now applies only below Minecraft 1.13. Above it an untagged file is genuinely unknown and a refusal naming the real gap beats a jar the loader will ignore; below it the fallback stays, which is what keeps `mtlib` -- all 15 of its files untagged, all 1.12.2 -- gradeable at all. **The candidate's own fallback is deliberately untouched.** `pickBootableCandidate` keeps it at every version, because `refuseForSelfDeclaration` reads the downloaded jar's descriptor before the boot and refuses one carrying another loader's. A dependency gets no such guard, which is the whole asymmetry. Mutation, run and observed: the version gate removed anUntaggedDependencyIsNotPickedWhereThePlatformTagsLoaders: expected <null> but was <ModFile(fileName=TerraBlender-forge-26.2-26.2.0.0.2.jar, …)> Clientside suite: 623 tests, 0 failed, 0 skipped. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>Seven of the public grinder's 40 DEPENDENCY_FAILURE rows name a library that exists on both platforms under a slug the mod id does not spell, so the optimistic slug guess found nothing, the boot went ahead without the library, and the loader refused the pack: obscure_api aquamirae -> obscure-api farmersdelight nethers-delight -> farmers-delight (CF 398521) refinedstorage refined-storage-addons x2 -> refined-storage (CF 243076) kotlinforforge slice-and-dice, via kubejs -> kotlin-for-forge (CF 351264) rhino create-enchantment-industry -> rhino (CF 416294) wover betternether -> worldweaver (CF 1037172) **A search was tested and rejected before adding these**, which is the part worth keeping: CurseForge answers `farmersdelight` with "Dirty Bowls Delight", `refinedstorage` with "RSExtendedCrafting" and `rhino` with "TS Modify", while Modrinth answers `kotlinforforge` and `obscure_api` with nothing at all. A text search would have staged somebody else's mod into the pack -- the exact trap `modIdForSlug`'s exact-match rule exists to avoid. The table is the right instrument here precisely because it cannot guess. Verified, not assumed, as this table's own rule demands. Every Modrinth ref was checked by downloading that project's newest jar and reading the id out of its descriptor (`obscure-api` declares `obscure_api`, `worldweaver` declares `wover`, and so on); every CurseForge id by its published file names carrying the id -- `kotlinforforge-5.12.0-all.jar`, `refinedstorage-neoforge-2.0.9.jar`, `worldweaver-26.101.2.jar`. That is the same standard the QSL entry above was added under. CurseForge is left unmapped for `obscure_api` alone: it is published there as "Obscure API [Forge Edition]", which implies a sibling edition a single ref would send every Fabric boot to. Same reason `tacz` carries no numeric id, and the opposite of inventing one -- pinned, so the gap reads as deliberate. Suites: clientside 630, grinder 532 (29 skipped), app 149, plugin-grinder 75 -- 0 failed. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>Every row traced to its kept console and its cause verified against the live platform APIs, grouped by cause rather than listed. 28 were ours and are fixed across the six preceding commits; the rest are recorded with reasons so they are not re-opened -- three upstream-unsatisfiable, two Sinytra Connector, four never dependency failures at all, one closed by the per-line axis. Four lessons that outlive the incident: - a demand can be invisible at several layers at once, and fixing one changes nothing (the fabricloader case needed three edits before one row moved); - a documented safety argument is a claim about the world, and the world changes -- "untagged means Forge" was true when written and is now false; - test the tempting fix before building it: a name search for the unresolved ids returns other people's mods, and that negative result is worth more than the table that replaced it; - what we asked for is not always what ran, and the report should say so -- the same defect class as the aether row, one layer down. Also records the one question the report cannot answer: why a tuple labelled quilt-loader 0.31.0-beta.4 holds 0.30.1, which needs the daemon's install cache, and why the newest-build re-check is deliberately left unarmed until it is. Clientside count in the root table re-derived from a full run: 630. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>Red. Measured across all 4475 rows of the public grinder on 2026-09-12: **27 rows across 16 projects** are published as clientside while their own boot reached the ready line and their metadata claims server support -- `agricraft`, `galosphere`, `zombie-awareness`, `immersive-lanterns`, `joy-of-painting` among them. Those go to /as-properties, so each is stripped from every server pack built against the list. `CurseForge/agricraft` is the clearest, read from its kept console: [ERROR] [RuntimeDistCleaner/DISTXFORM]: Attempted to load class net/minecraft/client/gui/Gui for invalid dist DEDICATED_SERVER [ERROR] [FMLModContainer/LOADING]: Failed to register automatic subscribers. ModID: agricraft, class com.agri... One `@SubscribeEvent` class in the NeoForge build touches a GUI class. Its Fabric and Forge builds each boot a dedicated server to the ready line, the platform declares SERVER and the jar scans SERVER_OR_BOTH -- and all three rows publish. A crop-breeding mod. The inference propagation rests on -- *a mod's features do not change with the loader* -- is invalid exactly when the reaching is one build's bug, and a sibling's clean boot **on a mod that claims the server** is what says so. That is the same contradiction `shouldRecheckAgainstOtherVersions` already treats as "one of these two signals must be wrong". Observed: aCleanBootOnAModClaimingTheServerIsNotOverruled: expected: not equal but was: <CONFIRMED> Two counterweights are committed green beside it, because the gate must be narrow or it destroys the case propagation exists for: `sodium` declares `server_side: unsupported` so the gate never opens for it, and a survival *borrowed* from a third loader does not open it either -- the same `bootedLoader == loader` landmine `targetDisprovingTheCrash` already guards. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>Turns the previous commit's pin green, and fixes the audit that could not see the problem. **The publication half.** `propagateClientOnlyProof` no longer confers CONFIRMED on a row whose own boot reached the ready line and whose mod is declared `SERVER`. Measured on the live store 2026-09-12: **22 rows across 12 projects** -- agricraft, galosphere, modonomicon, zombie-awareness, immersive-lanterns, joy-of-painting, toadlib and more -- were published as clientside on a sibling build's crash while booting dedicated servers themselves. The gate is narrow in three directions, and each matters: - the claim is `Declaration.SERVER` (platform **and** jar agreeing), never `declaresServerSupport`, which accepts `JarScan.SERVER_OR_BOTH` -- and SERVER_OR_BOTH is also what a scan that read *nothing* returns. The weak reading matches 27 rows, this one 22, and the five it drops are CONTRADICTORY, where by this module's own rule neither source is evidence; - `sodium` declares `client_side: required`, so the gate never opens for it and its Fabric entry is still excluded -- the case propagation was written for; - the survival must be the row's **own**, the `bootedLoader == loader` landmine `targetDisprovingTheCrash` already guards. The cost is accepted and is the cheaper direction: a mod whose metadata wrongly claims the server and boots cleanly stops inheriting -- `controlify` is one -- so it ships unused into a server pack. A false positive strips a working mod out of every pack built against the list. **The audit half, which is why this went unnoticed.** An inherited proof lived only in the detail's prose, so a row's `decidedBy` stayed its own boot rung and `GrinderAuditIT` -- which re-derives evidence from the kept consoles -- read **86 of 140** published rows as resting on none. The guard built to catch wrong publications was failing wholesale on a design working as intended, and an audit that cries wolf gets ignored. `inheritedProofFrom`/`inheritedProofRule` are now fields, carried to `GrindVerdict` and shown as the `Inherited proof` column, and the audit grades such a row at the sibling that owns the evidence instead. The audit had a second break this branch introduced: it built the old three-part `platform-name-loader` tuple, which matches no console now that the owner carries the Minecraft line -- so it would have *assume-skipped* with "no kept console belongs to a published CONFIRMED". A green run that graded nothing is the worst outcome an audit has. Mutation, run and observed: the gate removed aCleanBootOnAModClaimingTheServerIsNotOverruled: expected: not equal but was: <CONFIRMED> Suites: clientside 633 / 0 failed, grinder 532 / 0 failed / 29 skipped. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>`com.mojang.blaze3d` was not in the client-only marker, which matched only `net.minecraft.client`. Measured on the public grinder 2026-09-12: `Modrinth/vulkanmod` -- a Vulkan *renderer*, metadata CONTRADICTORY -- crashed with Caused by: java.lang.NoClassDefFoundError: com/mojang/blaze3d/systems/RenderSystem and was filed INCONCLUSIVE off the bare exit code, publishing nothing. A lost true positive, in the one direction this engine cannot afford: finding exactly that contradiction is what the container is paid for. Safe to trust over the exit code for the same reason its neighbours are -- a dedicated server ships no rendering layer, so no environment failure can fabricate it -- and it belongs in `client-only-class` rather than beside it because it proves the same thing about the *mod*, not about one build. One row in the current store, and the marker is what generalises: this is the first of the three decisive markers to be extended since they were written, and it was found by reading the 66 EXIT_CODE consoles rather than by guessing at patterns. Mutation, run and observed: with the alternation removed, `reachingMojangsRenderingLayerIsClientOnlyEvidence` fails on both spellings. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>The whole store measured rather than sampled -- 4475 rows -- and the buckets worth acting on read one by one. Records the four fixes and, more usefully, why none of them was visible from the code alone. - **A guard that cannot fail is worse than no guard, and it fails silently in two ways**: by matching nothing (GrinderAuditIT's pre-axis three-part tuple, which assume-skips to green) and by matching everything (86 false alarms from inherited proofs). Whenever a naming scheme or a verdict path moves, ask what the audit now matches. - **Evidence must be a field.** The propagation was correct and its reasoning was recorded -- in prose, which is not queryable, so the one mechanism that checks publications could not see it. - **A predicate correct for one question can be wrong for another.** `declaresServerSupport` arms the crash re-check, where accepting an unread jar scan is conservative; as a gate on *publication* the same leniency opens on most of the catalogue. - **"Deliberately ours" can be a mis-blame rather than a decision.** The recorded residue said closing DROPPED_BY_BACKTRACK needed a second exclusion channel; it needed re-reading the sentence. - **Reading 66 consoles produced one rule, and disproved a hypothesis.** The EXIT_CODE bucket is mostly genuine runtime version mismatches, correctly INCONCLUSIVE; knowing that is worth as much as the marker it did yield. Also closes the "known residue" note in the clientside module context, which is no longer true, and re-derives the root table's clientside count from a full run: 635. Suites: api 421 (1 skip), clientside 635, grinder 532 (29 skip), app 149, plugin-grinder 75 -- 1812 tests, 0 failed. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>`Iceberg-1.20.1-forge-1.1.25.jar` declares `[[dependencies.iceberg]] modId="forge" versionRange="[47.2,)"`, and Modrinth ticks it `forge, neoforge`, so LOADER_PRIORITY took NeoForge for the 1.20 line. NeoForge's 1.20.1 fork froze at 47.1.106 and registers under the mod id `forge`, so the console read "Mod iceberg requires forge 47.2 or above" and the line published INCONCLUSIVE -- while Forge 1.20.1 is at 47.4.23 and satisfies it outright. The descriptor gate could not see it: `mods.toml` names Forge *and* NeoForge on 1.20.1, so the requested loader was declared and nothing reopened the choice. `contradictingLoaders` now also asks whether each declared loader's newest build can satisfy what the jar demands of it, and names the reachable ones so `reselectOnLoaderContradiction` re-stages under Forge. `latestVersion`, not `preferredVersion`: the question is whether the ecosystem contains a build the jar accepts, and a cache preference for an older build must never condemn a loader. Fails toward accept throughout, and never fires when nothing is reachable -- there would be nothing to re-select to, and throwing the candidate away is the expensive outcome, not the safe one. The range is read here rather than through `ForgeTomlScanner`, which consumes the platform entry for sideness and discards its `versionRange`; that also keeps a `-clientside` gate from putting a requirement on the published `-api`. **No red pin was possible** -- the guard needs a `contradictingLoaders` overload that did not exist, so it could not compile against the unfixed code. The mutation that reproduces the red is, in `contradictingLoaders`: - if (loader in declared && loader !in reachable && reachable.isNotEmpty()) { + if (false && loader in declared && loader !in reachable && reachable.isNotEmpty()) { which fails `aLoaderThatCannotReachTheDemandedBuildNamesTheOneThatCan` with `expected: <[Forge]> but was: <[]>` and leaves the two accepting guards green. One signature change, enumerated rather than worked around: `BootVerifier.refuseForSelfDeclaration` gained a defaulted `loaderVersionFor`, which moved the trailing-lambda position, so `LoaderReselectionTest`'s one call names `minecraftConstraint` explicitly. No assertion, argument or expected value changed. Verified against the live files: `Iceberg-1.20.1-forge-1.1.25.jar` refuses NeoForge with "declares NeoForge '[47.2,)', but the newest build for Minecraft 1.20.1 is 47.1.106" and accepts Forge, while `aether-1.20.1-neoforge.jar` -- which demands `[47.1.0,)`, a range 47.1.106 does satisfy -- is refused by neither. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>`serverpackcreator.dokka-conventions` sets `dokkaSourceSets.includes` to `projectDir.resolve("module.md")`, a File which Dokka opens unconditionally. This module applied the convention without the file, so every Dokka task in it failed: > /…/serverpackcreator-plugin-grinder/module.md (No such file or directory) Nothing in the normal loop notices, which is why it shipped: only `-api` has `build { finalizedBy(dokkaGeneratePublicationJavadoc) }`, so `./gradlew build` exercises no other module's Dokka. It surfaced in the release pipeline's `Publish Maven` job, which runs `dokkaJavadocJar` with no project path and therefore in every project — Forgejo run 472, tag 9.0.0-alpha.8. The Maven publish never ran, and `mirror` and `news` were skipped behind it. Measured, this module only: before :serverpackcreator-plugin-grinder:dokkaGeneratePublicationJavadoc FAILED :serverpackcreator-plugin-grinder:dokkaGeneratePublicationHtml FAILED after both BUILD SUCCESSFUL; javadoc jar 179 entries / 96 HTML pages The `# Package` sections cover the three packages the module actually has, and the prose is derived from this module's own CLAUDE.md rather than restated from the signatures. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>`dokkaJavadocJar` packs BOTH publications' output directories, and both write an `index.html`. With Gradle's default duplicates strategy that is a hard failure — "Entry index.html is a duplicate but no duplicate handling strategy has been set" — the moment both directories are populated. CI has never hit it because the task depends on the Javadoc publication alone and `build/dokka` is empty in a fresh checkout; the release generates the HTML in the `assets` job, on a different runner. Locally it is a live failure: any earlier `dokkaGenerateHtml` leaves the directory behind, and every subsequent `dokkaJavadocJar` in that module dies. EXCLUDE, not INCLUDE: the Javadoc tree is added first, so its `index.html` wins and the jar keeps the entry point a `-javadoc.jar` is expected to have, rather than carrying two entries under one name. `sourcesJar` in publishing-conventions uses INCLUDE for its own collision, where either copy is equivalent; here they are not. Measured (build/dokka populated in every module): before :serverpackcreator-plugin-grinder:dokkaJavadocJar FAILED, duplicate index.html after ./gradlew dokkaJavadocJar BUILD SUCCESSFUL in 33s, all six modules api 473 entries / 362 HTML — unchanged in content: -api has no colliding name, so EXCLUDE is a no-op for the one published jar. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>The convention names `module.md` as a File in `dokkaSourceSets.includes`, and Dokka opens it unconditionally — so applying this plugin without the file makes every Dokka task in that module fail. The failure is far from the mistake: no module except `-api` runs a Dokka task during `./gradlew build`, so the gap sits undetected until something fans a Dokka task out over every project, which in this repo is the release pipeline and nothing else. That is exactly how `serverpackcreator-plugin-grinder` reached a release (Forgejo run 472, tag 9.0.0-alpha.8) and took the Maven publish down with it. A configuration-time `require` costs one file-existence check per project and moves the failure to the next `./gradlew` anybody runs, naming the module and the path. Cheaper than the alternative of running every module's Dokka in `build`, which would put ~30s of documentation generation in the local loop to guard a missing file. Measured, with module.md moved aside: before (parent commit) ./gradlew help BUILD SUCCESSFUL — nothing notices after ./gradlew help BUILD FAILED in 16s, "…:serverpackcreator-plugin-grinder applies serverpackcreator.dokka-conventions but has no module.md…" restored ./gradlew help BUILD SUCCESSFUL in 6s Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>The module was modelled on `serverpackcreator-plugin-example`, which documents every one of these — but with the restatement this project's conventions warn against ("Get the title of this tab. @return The title of this tab."). These say what is specific to this plugin instead: - `GrinderTabExtension` / `GrinderPreGenExtension` — the eight and five metadata members SPC shows the user and writes to `plugins.log`. The class doc now says that collectively, and `extensionId` carries the one fact that is not a label: SPC keys per-extension configuration on it, so it is an identity and must stay stable across releases. `getTab` notes that the `pluginConfig` it is handed is the *same* instance the pre-gen extension receives, which is the mechanism the whole feature rests on. - `VerdictTableModel`'s four `AbstractTableModel` overrides — `getValueAt` gets the two facts a reader needs (it runs per visible cell per repaint, and an absent reading renders empty because a cell reading "null" looks like a value); the other three say that `COLUMNS` is the single edit a new column needs. - Both `companion object`s, which held documented constants behind an undocumented container. Measured, this module: `Undocumented: 19` before, `Undocumented: 0` after (`:serverpackcreator-plugin-grinder:dokkaGeneratePublicationJavadoc --rerun`), and no new unresolved links. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>Creating `alpha` or `beta` started `test`, `qodana`, `docker-test`, `docs` and `release-generate` against a branch that is byte-for-byte `main` — which `main` had already tested. The cause is not `create`: Forgejo's `pushUpdates` takes the `IsNewRef()` branch and calls `notify_service.CreateRef` **and then** `notify_service.PushCommits`, so a new ref fires `push` too, with the branch's last ten commits in the payload though not one of them is new. `release-generate` is the reason this is worth fixing rather than tolerating. It is the workflow that runs semantic-release, i.e. the one that can **mint a tag**, and it was doing so on a branch nobody had pushed work to yet — next to the git-notes landmine already recorded in this repo's CI rules, where a remote without `refs/notes/semantic-release` makes every `X-alpha.N` tag invisible and restarts the counter at `.1`. The guard keys on `github.event.before`, the all-zero object id on a new ref: if: ${{ github.event_name != 'push' || !(startsWith(github.event.before, '0000000000000000') && (github.ref_name == 'alpha' || github.ref_name == 'beta')) }} Six copies, because there is no workflow-level `if:` and `qodana`'s `notify` needs its own — `always()` runs it even when the job it `needs` was skipped. `docs`' `help-image` needs no copy: it `needs: writerside` with no `if`, so it skips on its own. `release-generate`'s existing `RELEASE:` clause is ANDed, not replaced. Scoped to `alpha`/`beta` deliberately. An unscoped zero-SHA guard is shorter and would skip the first push of *every* branch, including a feature branch whose first push carries real work. **`github.event.created` is not an option here, and fails silently.** Forgejo's PushPayload has no `created`/`deleted`/`forced` — `Ref, Before, After, CompareURL, Commits, TotalCommits, HeadCommit, Repo, Pusher, Sender`. The GitHub idiom `if: github.event.created == false` therefore evaluates true on every event: the job always runs and the guard does nothing at all. `startsWith` is used rather than `== '0000…'` for the same class of reason — an all-zero string and an absent field both coerce to the number 0. Measured, not reasoned: Forgejo's own act fork (code.forgejo.org/forgejo/act v1.37.0, pkg/exprparser) evaluated all six guards over nine contexts. creation of alpha / beta ........................... false (skip) creation of develop / claude-x ..................... true normal push to alpha / develop ..................... true workflow_dispatch / schedule / pull_request ........ true release-generate, RELEASE: commit on a normal push . false (clause intact) github.event.created == false, all nine contexts ... true (inert) Not yet seen on a live branch creation — the next `alpha` cut is the check, and those five workflows should report skipped. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>Turns the two pins in the parent commit green. Both made a declared range narrow nothing, so the newest build won and the loader refused the pack. **A qualifier component is a version component.** `readableVersion` required every dot-separated component to be an integer, so `0.5.1.e` and `0.9.4c` were treated as prose — which *accepts*, by the rule that a parser gap must never manufacture a refusal. Now a component is readable when it is a number, a number with a qualifier glued on (`4c`), or a bare qualifier (`e`) — but **only away from the leading position**, which is untouched. That boundary is the prose guard: widening it is how `Balm 26.2.0.7` comes to read as `0.2` and refuse almost every range. A qualifier reads as `0`, so `0.5.1.e` and `0.5.1.f` compare equal; ordering Maven qualifiers properly is a separate problem, and for a narrowing preference "both are inside `[0.5.1.e,0.5.2)`" is the answer that matters. **A published version that leads with the Minecraft version is not the mod's version.** `VersionOfFile.of` strips a leading component that is literally one of the file's own `minecraftVersions`, optionally spelled `mc<version>`, plus any loader name between the two — `1.21.4-NeoForge-5.4.0` reads as `5.4.0`. The file's own declared versions are the evidence, so nothing is guessed, and a Minecraft version in the *suffix* is left alone because no range reads it and Fabric API publishes `0.92.2+1.20.1` by the thousand. It never returns an empty string: a file published under nothing but its Minecraft version has no mod version to read, and `""` compares as `0.0.0`. **An existing guard caught a crash this introduced, and its assertion did not change.** `1..2` and `1.` split to an empty component, on which `first()` throws and `all { isLetter() }` is vacuously true — the latter would have read `1..2` as `[1, 0, 2]` and refused `>=99.0`, the exact inversion the file forbids. Empty is explicitly unreadable now. Found by `UnreadableStagedVersionTest.theEdgesOfReadabilityAllAccept`, which is byte for byte what it was. Measured: clientside 649 green (646 pre-existing + the 3 pins), api 421 green, grinder 532 green. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>malilib publishes `0.10.0-dev.23` and `0.10.0-dev.23.nomixin` for Forge 1.12.2, and Modrinth returns the `nomixin` one first because it is newer *by date*. `pickForLoader` takes the first match, so every mod depending on malilib was staged the build that declares the Mixin tweaker without carrying Mixin — `ClassNotFoundException: org.spongepowered.asm.launch.MixinTweaker`, and the server never launched. Measured on the public grinder 2026-09-13: `litematica`, `minihud`, `tweakeroo` and `zume`, all Forge 1.12, all four staged `malilib-forge-1.12.2-0.10.0-dev.23.nomixin.jar`, all four published INCONCLUSIVE / DEPENDENCY_FAILURE. Confirmed against the live Modrinth API: the plain `0.10.0-dev.23` build is published right beside it. Run red before committing: exactly one of the four fails — `aPlainBuildWinsOverItsOwnVariant`, `expected: <0.10.0-dev.23> but was: <0.10.0-dev.23.nomixin>`. The other three are green already and pin the boundaries this must not cross: a variant is still picked when it is all there is, a newer version is not a variant of an older one, and build metadata (`1.6.1+1.21.1`, which Fabric API publishes by the thousand) is not a variant either — demoting that would invert the catalogue. The first run of this guard failed for the wrong reason: the fixture spelled `loaders = setOf("forge")` where the selector compares canonical names, so all four returned `null` and the red said nothing about variants. It now builds the set through `LoaderNames.canonicalLoaders`. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>Five defects behind the public grinder's `DEPENDENCY_FAILURE` rows, each pinned red before its fix. All of them were measured from the daemon's own published consoles rather than reasoned about: 4,410 verdicts pulled, 46 of them decided by `DEPENDENCY_FAILURE`, every console downloaded and classified by what the loader actually said. **A declared range narrowed nothing, twice over.** `readableVersion` required every dot-separated component to be an integer, so a Forge-style qualifier — `0.9.4c`, `0.5.1.e` — made the whole version prose, and prose *accepts*. And a version leading with the MINECRAFT version was compared as though it were the mod's: Create publishes `mc1.20.1-6.0.8` and `1.20.1-6.0.6` within one loader/Minecraft pair, so the set was judged inconsistently — one unreadable and accepting, the other read as "version 1.20". `VersionOfFile` strips a leading component that is literally one of the file's own `minecraftVersions`, so nothing is guessed, and a Minecraft version in the *suffix* is left alone because Fabric API publishes `0.92.2+1.20.1` by the thousand. **A build variant is not a newer version.** malilib ships `0.10.0-dev.23` and `0.10.0-dev.23.nomixin`; Modrinth returns the variant first because it is newer by date, and `nomixin` declares the Mixin tweaker while carrying no Mixin. Four 1.12 mods — litematica, minihud, tweakeroo, zume — were each staged it and never launched. A digitless trailing segment is the discriminator, which is what keeps this off build metadata. **A dependency the loader itself refuses as client-only is a finding, not an excuse.** Fabric says *"which is disabled for this environment (client/server only)"*, and `Incompatible mods found` sits on the line above — so the dependency-failure rung matched first and the boot's strongest evidence was discarded. `voxy` and `cull-less-leaves` each paid for a container to publish INCONCLUSIVE. The new rung sits above that excuse, and two existing guards had to be answered to put it in the publishing set. **`botanypots` is `botany-pots`**, which no slug guess reaches. Verified on a real local grinder, not only in the selector. `Modrinth/rctmod`, Forge 1.20.1, in a container: public grinder staged CobblemonTrainers-forge-0.9.4c+1.20.1.jar Mod ID: 'cobblemontrainers', Expected range: '[1.1.11,)' decidedBy=DEPENDENCY_FAILURE local, fixed staged CobblemonTrainers-1.1.11+1.5.2-forge.jar "CobblemonTrainers Forge initialized" decidedBy=TIMED_OUT The remaining TIMED_OUT is the host: Docker on that machine had 1.92 GiB against a documented ~3 GB per worker. It is a fair-run guard, so the grinder says "no fair run" rather than blaming the mod — which is the behaviour those rungs exist for. **Two existing guards caught real mistakes and neither assertion was weakened.** `UnreadableStagedVersionTest.theEdgesOfReadabilityAllAccept` found a crash the qualifier change introduced on the empty component from `1..2`, where `first()` throws and `all { isLetter() }` is vacuously true — the latter would have read `1..2` as `[1, 0, 2]` and refused `>=99.0`, the exact inversion that file forbids. `BootDecisionTest.theDecisiveSetIsSmallAndExplicit` did what it was written to do: an addition to the set that may publish a mod has to be argued, not appear. **Roughly 14 of the 46 rows are a redeploy, not a fix.** The daemon predates `a23781751` (2026-09-11 22:31 UTC): current `develop` resolves `wover` to `worldweaver` and picks the exactly-matching build for all three lines, while the daemon published nine rows with it `[MISSING]`. `/status` carries no build identifier, which is why establishing that took a behavioural probe. **Sixteen rows stay open, deliberately.** Loader-version-too-old (8), loader-absent (4), Minecraft-version-wrong-in-line (2) are one coherent piece of work — which Minecraft version and loader build a line is ground under, given what the jars demand — and worth scoping rather than bolting on. `sewingkit` and `betterquesting` (2) are CurseForge-only and need a key to verify a numeric id; inventing one sends every lookup to whatever project holds it, which is the call `tacz` and `obscure_api` already make. Measured: clientside 659, api 421, grinder 532 — all green. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>`LoaderProvidedIds` reads a `provides` block out of an installed loader jar, which Quilt and Fabric publish and Forge and NeoForge do not — so for those two the map came back empty, and `DependencyBacktrack` treated a demand on `forge` or `neoforge` as naming something absent, which it skips by design. The loader itself disagrees, in as many words: Mod ID: 'forge', Requested by: 'iceberg', Expected range: '[47.2,)', Actual version: '47.1.106' Measured on the public grinder 2026-09-13: `advancement-plaques` and `item-highlighter` (on both platforms) were each staged `Iceberg-1.20.1-forge-1.1.25.jar`, which demands `forge [47.2,)`, into a NeoForge `47.1.106` pack — the 1.20.1 fork froze there — and all three were published INCONCLUSIVE. With the pair in hand the judge can demote Iceberg and backtrack to a build that fits, exactly as it already does for `fabricloader` on Quilt. Run red before committing: 664 tests, 3 failed — `{forge=47.1.106}`, `{neoforge=21.1.250}` and `{forge=47.4.23}` all against `{}`. Two cases are green already and pin the boundary rather than the change: Fabric and Quilt stay empty, because their real `provides` block differs per build (quilt-loader 0.30.1 provides `fabricloader 0.19.3`, 0.31.0-beta.4 provides `0.19.5`) and inventing an answer here would shadow the true reading; and a blank loader build provides nothing, since a map claiming a version we do not have is worse than no map. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>`LoaderProvidedIds` reads a `provides` block out of the installed loader jar, and its own doc says Forge and NeoForge "are not looked for at all: they publish no `provides` block". Correct about the block, wrong about the consequence — the loader does provide that id, and says so in the console that refused the pack: Mod ID: 'forge', Requested by: 'iceberg', Expected range: '[47.2,)', Actual version: '47.1.106' With the map empty for those two, `DependencyBacktrack` saw the requirement name something absent and skipped it by design. `Iceberg-1.20.1-forge-1.1.25.jar` then sailed into a NeoForge `47.1.106` pack — the 1.20.1 fork froze there — and the *candidate* wore the verdict. Three published rows on 2026-09-13: `advancement-plaques` and `item-highlighter` on both platforms. With the pair in hand the judge demotes Iceberg and backtracks to a build that fits, exactly as it already does for `fabricloader` on Quilt. `platformProvides` lives in `JarSelfDeclaration` because `platformIdsFor` already owns the fact it needs — NeoForge answers to `forge` on Minecraft 1.20.1, where it runs Forge builds, and to `neoforge` everywhere after. Fabric and Quilt stay empty on purpose: their real `provides` block differs per build, so the answer has to be read off the install and a derived one would shadow it. The real reading wins on a collision for the same reason. **The wiring is not independently pinned, and the commit says so.** `DependencyBacktrackStagingTest` builds its packs from `fabric.mod.json` descriptors, so a Forge-family case needs a TOML fixture that harness cannot write. The knowledge is pinned by `PlatformProvidesTest`; the mutation that reproduces the red on the use of it is quoted in the fix commit. **Everything else in this class was probed and does not reproduce on develop.** Against real platform data: `the-undergarden` and `productivebees` now pick Minecraft 1.21.1 where the daemon booted 1.21 — a version those files do not declare at all — and `additional-structures` and `ct-overhaul-village` now pick NeoForge where the daemon booted Forge. Those rows are the redeploy already recorded against `a23781751`, not open defects. Measured: clientside 666 green, grinder 532 green. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>