...nice things! #675

Merged
Griefed merged 1309 commits from alpha into beta 2026-09-15 19:33:53 +02:00
Owner
No description provided.
Griefed added 1195 commits 2026-09-13 20:25:06 +02:00
Expand CLAUDE.md with module map, build/test commands, branching model,
API compatibility policy, testing conventions, definition of done, and a
living refactor-state section recording the test/coverage baseline
(API: 75 tests, 75.4% line coverage; app: 5 tests, 1.1%; plugin-example:
zero tests; frontend: no test infrastructure).

Add Kover 0.9.1 coverage reporting to all Kotlin modules via the
kotlin-conventions plugin so refactor progress is measurable.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
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 VersionsControllerTest pinning request-mappings and response-shapes
of all version endpoints, using a standalone MockMvc with the real
VersionMeta backed by cached manifests — no Spring context, no MongoDB.
This establishes the test pattern for the remaining web controllers.

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>
PropertyStore owns properties-file loading (blank-value filtering, file
tracking), typed accessors with define-if-absent semantics, custom
user-properties under the custom.property.-prefix, override-loading and
saving to all tracked files. Covered by 10 unit tests written first.

ApiProperties delegates all storage-primitives to the store while its
public surface and load-ordering stay unchanged — internalProps is now a
reference to store.properties, so every internal call site keeps working
as before. This is the shared engine the upcoming config-group
extractions (paths, generation settings, web settings) build on.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
First cohesive settings-group on top of PropertyStore: MongoDB
database-URI with legacy-value migration and scheme-normalization, plus
the three webservice cron-schedules, with property-keys as named
constants. Covered by 4 unit tests written first.

ApiProperties exposes the group as webserviceConfig and keeps thin
facade-properties (databaseUri, webservice*Schedule,
defaultWebserviceDatabase) so the public API stays source-compatible.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Move the server pack-generation settings into api.settings.GenerationConfig:
the fallback clientside-mod list and whitelist (512 lines of data moved
verbatim) with their regex-variants, directory in-/exclusions with the
include-wins-over-exclude rule, pre/post-install cleanup-files,
ZIP-archive exclusions, the clientside-mod exclusion-filter, six
generation-flags including the legacy auto-discovery key-migration, and
Aikar's flags. Property-keys are named companion-constants. Covered by
11 unit tests written first.

ApiProperties keeps thin facade-properties for source-compatibility and
shrinks from 3,007 to 2,126 lines. updateFallback() stays (network +
save orchestration), script-templates stay until the paths-group exists
(they depend on serverFilesDirectory). Removes the dead
addDirectoryToExclude which had no callers.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Move the home-directory and every path derived from it into
api.settings.PathsConfig: homeDirectory with its Preferences-based
resolution (dev-environment, user-home, stored preference), all derived
directories (configs, logs, manifests, work/temp, modpacks,
server_files, plugins), the twelve version-manifest files, the six
default script-templates, the server-packs directory override, and the
Tomcat base- and logs-directories. The Preferences-node is
constructor-injected, so the 8 new unit tests run against a scratch-node
and never touch the developer-machine's real preferences.

Pinned quirk: a deviating Tomcat base-directory is reset to the
home-directory on read, making home the only effective base-directory.

ApiProperties keeps thin facade-properties for source-compatibility and
shrinks from 2,126 to 1,754 lines (originally 3,007). The public
getPreference/storePreference stay on ApiProperties, since the GUI uses
them for general preferences beyond paths.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Move the start- and java-install-script templates into
api.settings.ScriptTemplatesConfig: the per-type template-maps stored
under prefixed property-keys, their defaults inside the
server_files-directory (via PathsConfig), and the deprecated list-based
template-handling with custom-file discovery. Covered by 4 unit tests
written first, running against a scratch Preferences-node.

ApiProperties keeps facade-properties (deprecations preserved) and
shrinks from 1,754 to 1,624 lines.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Move the Java-installation handling into api.settings.JavaConfig: the
Java used for modloader-server installs with path-validation
(file/link/symlink resolution, java -version test, result-caching) and
system-fallback acquisition, the per-version java-paths map stored under
version-suffixed property-keys, the per-version Optional-accessors, and
the SPC_JAVA_SPC script-autoupdate flag. Covered by 5 unit tests written
first, using the running JVM's binary as a known-valid installation.

ApiProperties keeps facade-properties and shrinks from 1,624 to 1,440
lines (originally 3,007).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
UpdateConfig owns the fallback-list update-URL, the pre-release
version-check flag, old-version tracking for migrations, and
updateFallback itself — persistence is delegated through an injected
save-callback, and the changed mod-lists flow through GenerationConfig.
I18nConfig owns language-parsing and i18n4k-propagation including
changeLocale. LoggingConfig owns the uppercased log-level and applies
changes via an injected callback; the log4j-XML machinery stays in
ApiProperties, which is log4j's ConfigurationFactory via @Plugin —
moving it would risk plugin-discovery.

Webservice fallback-schedules move into WebserviceConfig.
fallbackArtemisQueueMaxDiskUsage is deprecated: dead since the move to
MongoDB, no consumer exists.

8 new unit tests (updateFallback exercised via a file://-URL fixture).
ApiProperties finishes Phase 1b at 1,372 lines, down from 3,007 — what
remains is orchestration, jar/OS-info, version-info, preferences, the
log4j-factory and source-compatible facades over the eight
settings-groups.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Move two cohesive clusters out of ConfigurationHandler:
ModpackZipInspector owns ZIP-content listing (base-directories,
directories, files) plus the modpack-validity check of ZIP-archives and
the unzip-destination incrementation. ModpackManifestParser owns
deriving PackConfig-values from the manifests of CurseForge, Modrinth,
ATLauncher, GDLauncher and MultiMC/Prism, the manifest-dispatch
(checkManifests), pack-name acquisition and modloader-name
normalization.

Removes dead code: checkManifests contained two identical, hence
unreachable, mmcPrismPack-branches in the same when-expression.

ConfigurationHandler keeps facade-methods for source-compatibility and
shrinks from 1,564 to 1,062 lines. Behavior stays pinned by the Phase 1a
characterization tests; 2 new tests cover the previously untested
ZIP-listing methods.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Extract ModloaderValidator (supported-loader and loader-version checks
against the version-meta), InclusionsValidator (existing sources, valid
destinations, in-/exclusion-filter regexes, lazy-mode) and
ModpackDirectoryValidator (existence, type, overrides-detection).
ConfigurationHandler keeps facade-methods and is now a thin orchestrator
plus pre-processing and reporting, at 897 lines — down from 1,564 at the
start of Phase 1c.

Fix bug in isZip: a server.properties found in the extracted modpack was
assigned to serverIconPath instead of serverPropertiesPath, so the icon
got overwritten and the properties-file was never picked up.

4 direct validator tests added; deeper behavior remains pinned by the
Phase 1a characterization tests through the facades.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Extract three pipeline-collaborators into api.serverpack:
ModListCompiler walks the mods-directory and excludes clientside-only
mods via the scanners and user-lists while honoring the whitelist.
ServerPackFileGatherer resolves inclusion-specifications to
source-destination-pairs, applies in-/exclusion-filters, delegates the
mods-directory to the compiler and performs the copy.
ServerPackProvisioner provides icon, properties, start-scripts with
variables.txt and HOW-TO-RUN.md, the ZIP-archive, the improved
Fabric-launcher, installer-availability checks and install-cleanups.

ServerPackHandler shrinks from 1,466 to 490 lines and is now the
generation-orchestrator: run() composes gather, icon/properties,
manifest, scripts, zip and security-scan, plus plugin-hooks and
event-listeners. Facades keep the public surface source-compatible.

Verified by the five end-to-end generation tests (Forge, Fabric, Quilt,
NeoForge, LegacyFabric) and the Phase 1a characterization tests, all
exercising the new pipeline-classes through the facades.

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>
CommandlineParserTest (10 tests) pins the argument-to-mode mapping, the
priority ordering and the file/locale parsing. Only the deterministic
branches are pinned — each mode-argument returns before the
GraphicsEnvironment.isHeadless()-dependent GUI/failsafe checks, so the
tests do not depend on whether the test-JVM has a display. The --home
Preferences side-effect is pinned with save/restore of the real node.

MigrationManagerTest (6 tests) pins migrate()'s version-decision logic
through a mockk-mocked ApiProperties: first-run stores the current
version, dev/pre-release/non-release versions skip migrations, and a
release-upgrade with no matching methods records the new version. The
version-ranges never match a real migration-method (highest is 6.0.0),
so migrate() never touches the filesystem.

App suite: 39 -> 55 tests. No production code changed — this is the
safety net for the Phase 2 app restructuring.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Assessment: the Spring web backend is already MVC-layered (controllers
delegate to services, nothing over 254 lines, scheduling isolated), so no
restructuring is warranted there — the Phase 1a controller-tests already
pin it. The substantive Phase 2 target is the GUI.

First GUI view-model: extract the editor's unsaved-changes dirty-check
out of the Swing-coupled ConfigEditor into
ConfigEditorViewModel.hasUnsavedChanges(current, lastSaved) — a
display-independent 15-field PackConfig comparison. ConfigEditor's
compareSettings() shrinks to a 5-line view that just shows/hides the
warning-icon. 5 unit tests pin the behavior.

Characterization finding (pinned, not changed): InclusionSpecification
has no value-equality (plain class with reference equals), so the
dirty-check over-reports — the warning-icon effectively stays on whenever
inclusions are present, even right after a load. Giving
InclusionSpecification equals/hashCode is an API-surface behavior change
and is left for a deliberate separate change.

App suite: 55 -> 60 tests.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
InclusionSpecification gains equals/hashCode over its four fields
(source, destination, inclusion/exclusion-filter) so two inclusions
describing the same files compare equal. Manual override rather than a
data-class conversion, to keep the public API surface stable for plugins.

Verified safe before changing: no hash-based collections of inclusions
exist anywhere in api or app (no HashSet/TreeSet/toSet/distinct), so
adding hashCode introduces no keying side-effects. Exactly two sites
change behavior, both toward correctness:
- the ConfigEditor dirty-check now reports accurately instead of leaving
  the warning-icon on whenever inclusions are present;
- ConfigurationHandler.isZip's newCopyDirs.contains(entry) dedup, which
  previously never matched under reference-equality and could append
  duplicate inclusions after ZIP-extraction, now dedupes by value.
Every other inclusion call-site keys off .source directly and is
unaffected.

4 new InclusionSpecification equality tests; the editor's quirk-test is
flipped to pin the corrected by-value comparison. API and app suites and
the plugin-example all green.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
ConfigEditorViewModel now takes VersionMeta and owns
requiredJavaVersion(minecraftVersion) — the Minecraft-to-required-Java
derivation with the "?"-fallback; ConfigEditor.acquireRequiredJavaVersion()
is a one-line facade. 2 tests via a mockk-mocked
VersionMeta->minecraft->getServer->javaVersion chain.

This rounds out the ConfigEditor view-model for now: the two genuinely
display-independent pieces (dirty-check, Java-version) are extracted and
unit-tested. The remaining ConfigEditor bulk is legitimately view code
whose domain logic already lives in the well-tested API.

App suite: 60 -> 62 tests.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The ### ServerPackCreator ### section ignores the runtime home-directory
output dirs (configs, modpacks, plugins, server-packs, server_files) but
the patterns are unanchored, so they also match identically-named SOURCE
directories repo-wide — the app's gui/window/configs/ and the API's
api/plugins/ packages. Existing files in those dirs stayed tracked
(predating the rule), but NEW files were silently ignored: the Phase 2b
ConfigEditorViewModel and its test were never committed, leaving develop
unable to compile from a clean checkout because ConfigEditor references
the missing class.

Add negations re-including the Kotlin source directories under
src/main/kotlin and src/test/kotlin. Verified that generated
test-resource output (e.g. the 129 server packs under
src/test/resources/server-packs/) stays correctly ignored.

Commits the two recovered files. No code change — purely repairs git
tracking of already-written, already-tested Phase 2b work.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The example plugin already uses current API idiom (ApiWrapper.api() to
register listeners is the intended plugin idiom; no deprecated calls), so
no production changes were needed.

Fix the test layout: the empty AddonTests.kt sat in src/test/java.
Replace it with a real ConfigurationCheckTest (3 tests) in the correct
src/test/kotlin, which doubles as documentation-by-example of unit-testing
a ConfigCheckExtension — the unused versionMeta/apiProperties/utilities
params are relaxed mockks. Adds io.mockk:mockk:1.14.6 to the
plugin-example test dependencies. Plugin-example suite: 0 -> 3 tests.

Integration coverage already exists in the API's ApiPluginsTest, which
loads the plugin jar via pf4j and asserts all six extension points are
discovered. Rebuilt the jar against the refactored API and confirmed it
still loads — the compatibility policy held and the Phase 1 plugin-hook
refactoring preserved extension wiring. A hook-firing-during-generation
test was not added: the example hooks only println (brittle to assert)
and the discovery + Phase 1d generation tests already cover it.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Behavior-preserving store cleanup, split out from the test-infra commit:
- remove the dead `doubleCount` getter (a Pinia-template leftover referencing a
  non-existent `counter`);
- `refresh()` now returns its promise so callers/tests can await it (the sole
  caller, SubmitModPackForm, doesn't await — behavior unchanged).

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>
Split the monolithic root CLAUDE.md into a current-state root file plus one
lazy-loaded CLAUDE.md per module (api/app/plugin-example/web-frontend), and
move the blow-by-blow refactor narrative into REFACTOR-LOG.md. Personal working
preferences move to user-level ~/.claude/CLAUDE.md so they don't ship in the
public shared file. Fix REFACTOR-LOG.md path references (file lives at repo
root, not docs/) and a broken blockquote indentation.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The settings store's refresh() error path called this.$q.notify, but
stores/index.js registers no Pinia plugins, so $q was undefined — error
handling was latently broken. Make refresh() pure data-fetching: it now
returns/rejects its promise and the store stays UI-agnostic (MVC). The sole
caller, SubmitModPackForm.setup(), gets $q via useQuasar() and notifies in a
.catch(). Add a store test pinning that refresh() rejects on a failed request.

Co-Authored-By: Claude Opus 4.8 <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>
Convert the seven non-component support modules to .ts, following Quasar's
official TS scaffold while preserving runtime behavior:
- boot/axios.ts: keep the singleton instances/exports; add a ComponentCustomProperties
  augmentation so Options-API `this.$settings` etc. are typed.
- boot/i18n.ts, router/index.ts, router/routes.ts (RouteRecordRaw[]), stores/index.ts,
  i18n/index.ts, i18n/en-US/index.ts — straight typed ports.
- Update the three importers that hard-coded a `.js` extension on boot/axios
  (setting-store, RunConfigurationCard, the store test + its vi.mock path).
- Drop the stray `baseUrl` from the root tsconfig: it rebased the .quasar path
  aliases (`#q-app/wrappers`, `src/i18n`, …) one dir too high. The canonical
  Quasar root tsconfig extends-only; paths resolve relative to .quasar.

Verified green: vue-tsc 0 errors, eslint clean, vitest 3/3.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Port setting-store.js to .ts with an explicit SettingsState interface so the
empty-array defaults type as string[] (not never[]) under strict mode. Runtime
behavior and the refresh() contract are unchanged; the existing store test
(still JS, extensionless import) stays green.

Verified: vue-tsc 0 errors, eslint clean, vitest 3/3.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Add lang="ts" to the leaf/display SFCs and resolve strict-mode/template typing:
- Simple prop/setup components (App, AboutItem, DrawerLink, IndexItem,
  NotAvailableCard) needed only the lang attribute.
- Cards (ErrorsCard, ModPackCard, ServerPackCard, RunConfigurationCard): type
  copyToClipboard(text: string); annotate the runConfig .map() callbacks;
  QScrollArea thumb/bar style opacity as a string (CSSStyleDeclaration type).
- Tables (HistoryTable, ModpacksTable, ServerPacksTable): type columns as
  QTableColumn[] so the align literals don't widen to string; add the inert
  required `field` to the slot-rendered 'download' columns; type
  voteServerPack(id: string, decision: string).
- New src/types/api.ts with ErrorItem, the one backend shape a template reads
  directly (ErrorsCard's errors prop) — others stay any-from-axios.

Verified: vue-tsc 0 errors, eslint clean, vitest 3/3.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Add lang="ts" to MainLayout and all seven pages, resolving strict typing:
- MainLayout drawerClick(e: Event); SubmissionPage scroll-area opacity as strings.
- Download pages (ModPackDownload, ServerPackDownload): type copyToClipboard
  and downloadWithAxios params; coerce this.$route.params.id (string|string[]|
  undefined under strict) with String(...) at the axios.get call sites; drop the
  invalid `this.` prefix from in-template state writes (count/canceled) — the
  same reactive state, the idiomatic template scope, and no longer a possibly-
  undefined `this`. Remove a stray debug console.log in ModPackDownload.current().

Verified: vue-tsc 0 errors, eslint clean, vitest 3/3.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
QFile's @rejected emits an ARRAY of rejected entries, so the previous
`rejectedEntry.name` was always undefined and the toast read 'undefined is not
a ZIP-file'. Read `rejectedEntries[0].file.name` so the offending file is named.

Isolated from the SubmitModPackForm TypeScript conversion (the conversion can't
type the parameter as QRejectedEntry[] without this fix, which is why it surfaced).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The last and most involved SFC. Behavior-preserving (the onRejected behavior
fix is the preceding commit):
- type the version arrays, file (File | null), and id refs; type every method
  parameter and the response .map/.forEach callbacks (ModPack/RunConfiguration).
- replace the four `ref(new Map)` values with typed Records: they were only ever
  used as string-keyed dictionaries (bracket access) and the version maps get
  overwritten by plain JSON objects. Read forge/neoForge entries once so strict-
  mode narrowing holds.
- drop the invalid `this.` prefix from in-template dictionary access and
  optional-chain it (noUncheckedIndexedAccess stays on).
- add ModPack/RunConfiguration to src/types/api.ts.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Update the root and web-frontend CLAUDE.md: the SPA is now fully TypeScript,
document the Quasar TS setup (extends-only tsconfig, strictness via
quasar.config, kept noUncheckedIndexedAccess, typescript-eslint, src/types/api.ts),
update the Vitest note for extensionless boot/store imports, and set the next
step to 4d (component test harness).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Component tests can now mount real Quasar-backed SFCs:
- vitest.config.js adds the @quasar/vite-plugin (already present transitively)
  so SFCs get Quasar's on-demand component auto-import — without it, <q-*> render
  as unresolved custom elements. The plugin also resolves Quasar to its client
  build, avoiding the SSR/server build whose install() throws in happy-dom.
- test/install-quasar.ts installs Quasar globally for Vue Test Utils (provides the
  $q instance + directives) via setupFiles.
- First component test: test/components/AboutItem.test.ts mounts AboutItem and
  asserts it renders a target=_blank anchor to its link prop.

vitest 4/4 (3 store + 1 component); vue-tsc and eslint clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Pins ErrorsCard rendering one .q-item per error (with id + message), and an
empty list for no errors — exercising the harness with prop-driven DOM beyond
the AboutItem smoke test. vitest 6/6.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Addresses audit findings H1 and M1 (forward-fix): the form was migrated to TS
with a bugfix folded in and no tests pinning its behavior. Add behavior tests
that mount the real SFC against a mocked axios boot-module:
- onRejected names the rejected file (regression for the QFile @rejected fix —
  the old rejectedEntry.name was always undefined → 'undefined is not a ZIP-file').
- modloaderSelected wires the version list + picks the first version.
- selectedRunConfiguration populates the form (flattening arg/mod arrays), and
  is a no-op for an unknown id (guards the strict-mode undefined lookup).

Harness: register Notify in test/install-quasar.ts (components call $q.notify);
add the missing `assets` path-alias to vitest.config.js. Suite 6→10.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Addresses audit M1 — the download pages were migrated to TS (String() coercion,
template this.-drops, a removed debug log) with no tests. shallowMount each page
against a mocked axios boot-module and pin the download-filename logic:
- ModPackDownload: spaces in the modpack name become underscores.
- ServerPackDownload: owning modpack name underscored + .zip -> _server_pack.zip.

Suite 10->12.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Read-only audit of the Phase 4 branch against the refactoring conventions, plus
the remediation: M1 fixed with forward characterization tests; H1 (onRejected
bugfix) and M2 (multi-concern 4a) fixed by splitting those commits in a
non-interactive history rewrite (final tree verified byte-identical to the
pre-rewrite backup); LOW items are behavior-equivalent with no code defect.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Phase 4 (web-frontend) refactor: Vitest harness, settings-store $q
decoupling, full TypeScript migration, and a Quasar component test
harness (Vue Test Utils).
Add Vue-Test-Utils characterization tests for the three untested
presentational SFCs: DrawerLink and IndexItem (prop -> rendered title /
caption / icon) and NotAvailableCard (the static N/A placeholder).
Routing (`:to`) is left to integration. Suite 12 -> 16.

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>
The drawer navigation icons had `colour="accent"` instead of `color`.
Quasar's QIcon prop is `color`, so the misspelled attribute was silently
ignored and the drawer icons rendered in the default color rather than
accent. Sole occurrence (100 correct `color=` usages elsewhere). Surfaced
while adding DrawerLink's characterization test; kept as its own fix
commit. The test pins rendering, not color, so it stays green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Update the refactor-state table (web-frontend 6 -> 23 tests), the
frontend module CLAUDE.md migration plan, and REFACTOR-LOG with the
Phase 4e component-coverage work, the DrawerLink colour fix, and the
rationale for leaving the QTable wrappers untested.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Replace the 4 GlobalScope.launch sites in ConfigEditor (loadConfiguration,
updateGuiFromSelectedModpack, checkServer, stepByStepGuide) with launches on
a new ComponentCoroutineScope, cancelled from removeNotify() when the tab is
closed. This ends the structured-concurrency anti-pattern: a config load or
modpack scan no longer outlives a disposed editor. Each launch keeps its
original dispatcher and CoroutineStart, so per-site semantics are unchanged.

Opt-ins: the three non-ATOMIC sites drop @OptIn(DelicateCoroutinesApi) (it
was only there for GlobalScope). loadConfiguration keeps it because
CoroutineStart.ATOMIC is itself a delicate API, independent of GlobalScope.

ComponentCoroutineScope (gui/utilities) is the shared helper: a SupervisorJob
scope that lazily re-creates after cancel (so detach/re-attach works) with
synchronized access (launches may start off the EDT). Behavior-affecting;
needs GUI runtime verification.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Replace the 4 GlobalScope.launch sites (delayed focus-request, the search /
search-regex highlight loops, and regex search-and-replace) with launches on
a ComponentCoroutineScope cancelled from removeNotify(). All four used
Dispatchers.Swing (no ATOMIC), so the @OptIn(DelicateCoroutinesApi)
annotations drop entirely. Dispatcher/start per site unchanged.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
IconPreview: the 2 async icon-load launches move to a ComponentCoroutineScope
cancelled from removeNotify(), so a slow ImageIO read can't complete into a
disposed preview. Both keep CoroutineStart.ATOMIC, so each retains its
@OptIn(DelicateCoroutinesApi) for ATOMIC alone (vestigial ExperimentalCoroutinesApi
opt-in dropped).

SuggestionProvider is not a Swing component, so it ties its scope to the
sourceComponent via an AncestorListener (cancel on ancestorRemoved); the
helper's lazy re-create makes a tab-switch (which also fires ancestorRemoved)
harmless. The GlobalScope/DelicateCoroutinesApi imports are removed.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
SelectedInclusionDetails (3 sites: delayed focus-request + search/regex
highlight loops) and InclusionsEditor (2 sites: updateTip on miscDispatcher,
sourceWasEdited on Swing) move their GlobalScope.launches onto a
ComponentCoroutineScope cancelled from removeNotify(). No site uses ATOMIC,
so the @OptIn(DelicateCoroutinesApi) annotations drop. Dispatcher/start per
site unchanged.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Move the 3 GlobalScope.launch sites (close-button visibility on tab-change,
the config-not-found dialog, the load-config dialog loop) onto a
ComponentCoroutineScope. TabPanel is not a Swing component, so cancellation
is anchored to its panel via an AncestorListener (cancel on ancestorRemoved),
effectively firing on window close; the helper's lazy re-create makes a
main-window tab-switch harmless. No site uses ATOMIC, so the class-level
@OptIn(DelicateCoroutinesApi) is removed.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Convert the one-shot/dialog GlobalScope.launch sites in MainWindow (first-run
guide prompt), MigrationInfoItem and ThirdPartyNoticesItem (about-menu report
dialogs) and TipOfTheDayManager (tip-of-the-day) onto ComponentCoroutineScopes.
The menu items are JMenuItems so they cancel from removeNotify(); MainWindow and
TipOfTheDayManager are not components, so they cancel via a WindowListener on the
main frame (windowClosed). All sites are Dispatchers.Swing (no ATOMIC), so the
class-/method-level @OptIn(DelicateCoroutinesApi) annotations are removed.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
SettingsCheckTimer and ConfigCheckTimer launched their periodic checks on
GlobalScope from an ActionListener passed to the Timer super-constructor (where
'this' is unavailable). Register the listener in init instead so it launches on
a ComponentCoroutineScope, cancelled when the owning tab's panel leaves the
screen (AncestorListener). The checks are idempotent, so cancel-on-tab-switch
plus the helper's lazy re-create is harmless. ConfigCheckTimer keeps its large
listener body verbatim as a property to avoid re-indentation churn. Both drop
the class-level @OptIn(DelicateCoroutinesApi) (UNDISPATCHED, no ATOMIC).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Move the generation GlobalScope.launch onto a ComponentCoroutineScope anchored
to the control panel itself (the always-visible bottom bar), so it is cancelled
only on window close and never by a tab-switch — a running generation must not
be interrupted by a UI event. CoroutineStart.ATOMIC is preserved (a started
generation must not be cancellable before its first suspension), so the narrow
@OptIn(DelicateCoroutinesApi) stays for ATOMIC alone; the vestigial
ExperimentalCoroutinesApi opt-in is dropped.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Mark the GUI GlobalScope.launch anti-pattern resolved in the root refactor
state, replace the app module's OPEN ISSUE with the ComponentCoroutineScope
ownership/cancellation rules (ATOMIC opt-in nuance, ControlPanel anchoring,
timer init-restructure), and append the blow-by-blow to REFACTOR-LOG.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Saving settings (even with no edits) made the warning/unsaved-changes icon
appear on the active settings tab and the overall Settings title, and it
never cleared. Root cause: several settings are normalized when stored
(WebserviceConfig database-URI migration, locale parsing, and PathsConfig's
Tomcat base-directory being reset to the home-directory on read), so
hasUnsavedChanges() — which compares the raw widget value against the
normalized apiProperties getter — stayed permanently true after a save.

Fix: reload each editor from the persisted properties after saving and before
re-checking, mirroring what load() already does. The widgets then hold exactly
what hasUnsavedChanges() reads back, so the dirty-check clears for every field
regardless of which getter normalizes. Verified compiling + app suite green;
GUI behavior to be confirmed by Griefed.

Pre-existing bug, independent of the GUI coroutine-scope refactor.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Phase 4e: broaden web-frontend component-test coverage (cards + nav SFCs,
suite 12->23) and fix DrawerLink's misspelled `colour` icon prop.
GUI structured-concurrency: replace all 26 GlobalScope.launch sites across the
Swing GUI with a lifecycle-owned ComponentCoroutineScope (cancelled on
removeNotify / ancestor-removed / window-closed).

# Conflicts:
#	CLAUDE.md
#	REFACTOR-LOG.md
Fix: settings save no longer leaves the unsaved-changes icon stuck on
(reload editors from the normalized properties after persisting).
Widen REFACTOR-AUDIT.md from the previous 48-commit scope (through the Phase 4
frontend merge, 69a587b6..37bd79184) to the full 66-commit claude range
69a587b6..develop. Adds the 18 post-37bd79184 commits: Phase 4e frontend
coverage, the GUI structured-concurrency refactor (GlobalScope ->
ComponentCoroutineScope, 8 commits), and the settings dirty-icon fix.

New finding M3 (MEDIUM): the GUI coroutine-scope series is a behavior-affecting
change carrying a `refactor` label, and its one runtime-free, unit-testable unit
(ComponentCoroutineScope) shipped without a test. Phase 4e and the dirty-icon
fix are recorded as clean / model discipline. Summary tally now HIGH 1 /
MEDIUM 3 / LOW 2.

Also records the branch reconciliation done this session: all 15 claude-prefixed
branches verified merged into develop and deleted; backup-pre-rewrite audited
(its 17 unique commits are superseded pre-rewrite originals, develop is a strict
content superset) and deleted.

Read-only audit; no source modified.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The GUI structured-concurrency series introduced ComponentCoroutineScope (the
single point all 26 former GlobalScope.launch sites now depend on) without a
unit test, even though it is runtime-free, unit-testable Kotlin — audit finding
M3's one mechanically-fixable gap.

Add ComponentCoroutineScopeTest pinning the four behaviours its doc-comment
promises: scope stability while active, cancellation of in-flight work,
re-creation + usability after cancel (detach/re-attach), and SupervisorJob
sibling-independence. Written without kotlinx-coroutines-test (not a declared
dependency) using runBlocking + latches/deferreds bounded by withTimeout so a
regression fails fast instead of hanging. App suite green.

Update REFACTOR-AUDIT.md: mark M3's test gap resolved, correct the
"no source modified" notes to disclose this test, and realign the tables.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Adds focused unit tests across the config and serverpack packages, targeting
the previously least-covered branching:

- ModListCompiler: clientside-mod exclusion filter modes, whitelist override,
  per-loader/Minecraft-version scanner selection (line 38.8->79.1%).
- ServerPackFileGatherer: the getServerFiles when-arm matrix, runFilters,
  excludeFileOrDirectory, lazy-mode (branch 59.0->84.6%).
- ConfigurationHandler: checkConfiguration negative branches (icon/properties,
  modpack-type, unparsable-file, quiet-check, fallback lists).
- InclusionsValidator/ModloaderValidator: remaining edge cases (both ->100% line).
- ServerPackHandler.run: custom destination, zip/icon/properties toggles,
  manifest contents, update-prune (branch 52.9->88.2%).
- PackConfig: save/load round-trip incl. inclusions; setter blank-filtering.
- ServerPackProvisioner: existing-custom properties, 64x64 icon direct-copy,
  placeholder escaping (isolation pins; network-bound residual).
- ModpackManifestParser: all Modrinth loader branches, checkManifests
  precedence, malformed-manifest error path.

API suite 161 -> 210 tests, all green. Behavior-preserving: no production code
changed. Baseline and per-class deltas in TEST-COVERAGE-AUDIT.md. Audit also
flags a latent bug in StringUtilities.checkForInvalidPathCharacters (current
behavior pinned, fix proposed separately).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
StringUtilities.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>
- Remove the commented-out jNeedle scanner: the disabled function, its init
  block and imports in SecurityScans, plus the two commented call-sites in
  ServerPackHandler and ConfigurationHandler. The active Nekodetector scan
  stays; jNeedle remains available as a separate plugin.
- Remove ApiProperties.fallbackArtemisQueueMaxDiskUsage, dead since the move to
  MongoDB and with no consumers (confirmed via grep).

Behavior-preserving; API/app suites green (212/66).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- ListUtilities: printListToConsoleChunked and printListToLogChunked were
  byte-for-byte identical except for the emit target. Extract a private
  forEachChunkedLine(... emit) backend; both public functions now delegate
  (signatures unchanged, behavior-preserving). KISS/DRY.
- PackConfig: the two empty `catch (ignored: Exception)` blocks around the
  optional plugins/scripts config sections now carry a comment explaining why
  the exception is intentionally swallowed, per the error-handling convention.

API/app suites green (212/66).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Roadmap for the items deferred during the test-coverage push and quality
analysis: MockK enabler + network coverage (PR1), ignored-catch documentation
(PR2), versionmeta URL/tag-name externalization (PR3), detekt + baseline (PR4),
release-gated removal of the 6.0.0 deprecations (PR5), and an optional
Desktop->adapter inversion (PR6). One mergeable branch per item, sequenced for
the branch-work-merge workflow.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Declutter the repo root by relocating the Claude-generated working
documents (DEFERRED-WORK-PLAN, REFACTOR-AUDIT, REFACTOR-LOG,
TEST-COVERAGE-AUDIT) into a dedicated claude-docs/ directory. Root
CLAUDE.md is left in place (Claude Code auto-loads it from root) with
its REFACTOR-LOG.md references repointed at the new location.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Add MockK (1.14.6, aligned with the app module's transitive version) as a
testImplementation to serverpackcreator-api so unit tests can stub
network-bound collaborators and exercise branches previously deferred for
lack of a mocking library. Production code is unchanged.

New coverage:
- ServerPackProvisioner.serverDownloadable: per-loader reachability
  (Fabric reachable, Forge present+reachable, Forge absent short-circuit,
  Quilt unreachable, LegacyFabric malformed-URL catch, NeoForge
  present+reachable, unknown-loader else).
- ServerPackProvisioner.getImprovedFabricLauncher: launcher-present (copies
  jar + writes SERVER_PACK_INFO.txt) and launcher-absent (writes nothing).
- ModpackManifestParser.getAndSetIcon: icon-download success (sets
  serverIconPath) and failure (leaves it unset), driven through the
  minecraftinstance.json parser with only WebUtilities.downloadFile stubbed.

api + app suites green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Per the error-handling convention, every silently-swallowed exception in
the api module now carries a why-comment explaining the safe-to-ignore
rationale. Behavior is unchanged — comments only.

Covered sites:
- modscanning (Fabric/Quilt/Forge-annotation/Forge-toml scanners): NPE/Json
  swallows during mod-metadata extraction mean "the queried field is absent,
  so skip this entry" rather than aborting the scan.
- versionmeta installers (Fabric/Quilt): a version whose installer URL can't
  be parsed is omitted; latest/release URLs stay unset.
- MinecraftMeta.getServer: unknown version / absent metadata -> empty Optional.
- ServerPack(Handler|FileGatherer): the target dir may already exist; a real
  write failure resurfaces when files are written into it.
- ModpackManifestParser: a manifest without an icon URL/name leaves the
  server-icon path unset.
- FileUtilities.deleteQuietly / JarUtilities / ServerPackProvisioner: noted the
  quiet-by-contract / already-exists / unreachable rationale.

api suite green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Second slice of the ignored-catch pass, covering the app module. Every
silently-swallowed exception now carries a why-comment; behavior unchanged.

- cli commands (RunHeadless/HomeDir/ConfigGen/Language): closing the
  System.in-backed scanner can't meaningfully fail, so a close error is ignored.
- ConfigCheckTimer: a huge server-icon can OOM while being read; swallowed so
  the periodic validation coroutine doesn't take down the GUI.
- InclusionsEditor: out-of-range index / not-yet-realized details panel during
  preview assembly are skipped.
- SuggestionProvider: a stale caret offset yields no suggestions.
- CustomTip / CustomTipOfTheDayUI: missing image resource / absent enclosing
  JDialog are no-ops.

app suite green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Pin the hardcoded manifest URLs (public VersionMeta url-vals) and the
interpolated installer-URL templates (Forge/Fabric/Quilt/LegacyFabric),
deriving installer expectations from a resolved version so they stay
version-agnostic. Guards the upcoming centralization into VersionMetaConfig.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Clears the ~59 "TODO Move URL/tagName to property" markers across the
versionmeta package by introducing VersionMetaConfig — a single internal
object holding every manifest URL, installer/launcher URL template and
manifest tag-/field-name as a named compile-time constant (grouped per
loader). Each loader/installer/instance now sources its value from there
instead of carrying inline literals.

Behavior-preserving: every string is identical to before. Interpolated
installer URLs (Forge / old+new NeoForge) become small format-functions on
the config object producing byte-identical URLs. Guarded by the existing
VersionMetaTest (real version resolution from cached manifests) plus the
new URL characterization test.

api + app suites green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Record the upstream compatibility blocker: project is on Kotlin 2.3.20;
stable detekt 1.23.8 targets Kotlin 2.0.21 (incompatible, issue #8865), and
only detekt 2.0.0-alpha.3 supports Kotlin 2.3.21. Decided against adopting an
alpha analysis plugin in a Maven-published library's build. Revisit when a
stable detekt supports Kotlin 2.3+.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The java.awt.Desktop convenience methods (openLinkInBrowser/openFolder/
openFile) live in -api by design: only -api is published to Maven, and a
plugin may run under the GUI or the web backend, so keeping them in the API
lets plugin authors use them regardless of host. Inverting them behind an
app-side adapter would remove that capability from plugins. Recorded as a
won't-do in the plan and as a "stays in -api" landmine in the api CLAUDE.md.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Adds a three-phase pipeline that automates the [Clientside-mod Addition
Request] issues: derive the clientside-list file-name stems for a
CurseForge/Modrinth project, assess whether it is server-unsafe, and —
once a maintainer accepts — open the PR.

New `clientside/` package + four CLI verbs (wired through Mode +
CommandlineParser + ServerPackCreator dispatch, not just the picocli
shell):
- -scan: declared sideness of local jars via apiWrapper.modScanner.
- -clientsidereport: metadata signal (Modrinth client_side/server_side +
  jar metadata scan) -> Markdown report with suggested entries.
- -verifyclientside: also boots a server per loader with the mod
  force-included (ServerStarterJar), classifying crash vs ready and
  capturing the crash excerpt. A crash is decisive (HIGH), catching the
  "declares server/both yet crashes" case metadata cannot.
- -clientsideapply: inserts accepted entries into GenerationConfig.kt and
  serverpackcreator.properties in sorted position (pure, unit-tested).

Distribution-locked CurseForge files (allowModDistribution=false) are
fetched via a lazy Playwright headless-browser downloader; everything
else via HTTP. Platform clients sit behind an injectable HttpFetcher so
they are unit-tested against canned JSON (no live network).

Workflows: clientside-verify (issues opened/edited, cheap metadata pass),
clientside-boot (verify-boot label/dispatch, per-loader boot + log
artifact), clientside-accept (accepted label -> PR via
create-pull-request). All need the CURSEFORGE_API_KEY secret for
CurseForge links.

Domain core (-api) untouched; all logic lives in -app. Full app test
suite green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Follow-up to 13d8c4fbf, kept separate per one-concern-per-commit.

- M2: extract the pure boot file/Minecraft-version selection out of
  BootVerifier into BootCandidateSelector (behaviour-preserving move) and
  unit-test it (numeric MC ordering, bootable-candidate fallback,
  dependency-file selection). Add a BrowserDownloader test for the
  no-page-URL short-circuit (no Chromium launched). The live boot, live
  Playwright download and dispatch wiring stay integration-only.
- L1: replace the new !! in ClientsideListEditor with a safe ?. + fallback.
- L2: confirmed the properties trailing-`,\` corruption fix is pinned by a
  regression test (no code change).
- M1 (single feature commit) accepted; recorded in REFACTOR-AUDIT.md.

App test suite green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The verify and boot workflows were ~90% identical (resolve link → build
jar → run CLI verb → post sticky comment). Move that pipeline into a
reusable clientside-report-reusable.yml (workflow_call), parametrised by
`boot` (selects -verifyclientside vs -clientsidereport) and issue_number.

- clientside-verify.yml / clientside-boot.yml become thin callers
  (126→27 and 129→33 lines); one keeps boot:false, the other boot:true.
- clientside-accept.yml stays standalone: a reusable workflow is a
  separate job, so it can't share the build job's working-tree edits with
  the create-pull-request step.
- Kept one workflow per goal (not collapsed into one file) to preserve
  least-privilege permissions per goal — verify/boot need only
  issues:write, accept needs contents/pull-requests:write.

Note: job timeout-minutes can't take an `inputs` expression
(actions/runner#1555), so the reusable uses a static 90-min ceiling.

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>
WORKFLOW-AUDIT.md: full security/correctness audit of the six pre-existing
workflows (grouped by severity, file:line, fixes, remediation status).
REFACTOR-AUDIT.md: convention-compliance audit of this branch.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Security/correctness audit + remediation of the six pre-existing GitHub
Actions workflows (tj-actions removal, least-privilege permissions,
SHA-pinning, ::set-output, token handling, discord.sh pinning, pages
needs/version fix). Reports in claude-docs/WORKFLOW-AUDIT.md.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Reverses the H3/L1 exemption from the workflow audit that left first-party
actions/* and gradle/* on floating major tags. Every remaining version-tagged
action is now pinned to the latest patch within its existing major (no major
bumps), each carrying a # vX.Y.Z comment so Dependabot keeps it current.

After this, every `uses:` across all workflows is either a 40-char commit SHA
or a local ./.github/workflows/ reusable workflow. Tags are mutable pointers a
compromised maintainer can move; SHAs are immutable, closing the tj-actions
class of supply-chain attack.

Pinned: actions/checkout v6.0.3, actions/setup-java v5.4.0,
actions/upload-artifact v7.0.1, actions/download-artifact v8.0.1,
actions/github-script v7.1.0, actions/cache v5.0.5,
gradle/actions/setup-gradle v6.2.0, peter-evans/create-pull-request v7.0.11.

Dependabot github-actions ecosystem already enabled; WORKFLOW-AUDIT.md updated.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
clientside-boot already exposed workflow_dispatch with an issue_number input;
clientside-verify (the cheap, read-only Phase-1 metadata scan) only fired on
issue open/edit. Add the same workflow_dispatch input so a maintainer can run
the verification against an arbitrary issue number from the Actions UI — useful
for testing the pipeline against the existing backlog of clientside-mod issues.

The reusable report pipeline already resolves the project link from issue_number
alone, so no pipeline change is needed. The job `if` now also passes on
workflow_dispatch (operator picked the issue deliberately); the issue trigger
stays gated on the addition-request title. Concurrency group and issue_number
fall back to inputs.issue_number on dispatch, mirroring clientside-boot.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Add newcomer-oriented module/package documentation following the Dokka
`module.md` convention so the generated API docs explain, in plain language,
what each package and class is for.

- api/module.md: rewritten — ELI5 intro + per-class breakdown for every
  package (config, modscanning, serverpack, settings, versionmeta + loader
  sub-packages, utilities, plugins). Drops the stale `api.i18n` package entry
  (i18n now lives in settings/I18nConfig + i18n4k).
- app/module.md: rewritten — detailed `clientside` walkthrough (the package
  flagged as hard to understand) plus all packages: launcher, cli, updater,
  the full gui tree, and the web backend.
- plugin-example/module.md: new — explains the plugin model and all six
  extension points; wires the module into Dokka via dokka-conventions so it
  now publishes API docs.
- web-frontend/module.md: rewritten from a one-line stub into a newcomer
  guide to the Vue/Quasar SPA (directory map + components by job).

Build: flip suppressGeneratedFiles=true and suppress build/generated by path
in the shared dokka-conventions, so the i18n4k-generated `Translations` object
(605 undocumented-member warnings) no longer pollutes the docs or the
reportUndocumented output. Verified: api/app/plugin-example dokkaGenerate all
succeed with zero unresolved links and zero Translations warnings.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
I preffered when gradle used to resolve this on its own... le sigh

Signed-off-by: Griefed <griefed@griefed.de>
Signed-off-by: Griefed <griefed@griefed.de>
Move the `clientside/` package out of serverpackcreator-app into a new
standalone `serverpackcreator-clientside` Gradle module (package
`de.griefed.serverpackcreator.clientside`), ahead of building a standalone
Docker "grinder" service that needs to reuse the engine without dragging in the
app's Spring/Swing runtime.

Why a separate module and not -api: the engine pulls Playwright + server-boot
machinery, which must not pollute the published, compatibility-frozen -api core
(module-boundary rule). Why not exclude transitive deps from an -app dependency:
that is fragile whack-a-mole (runtime NoClassDefFoundError, rotting exclude
list) versus a lean module that never carries Spring/Swing at all.

Behavior-preserving: 16 main + 10 test files moved via git mv, package renamed
(dropping the wrong `.app.` segment); the engine's only imports were already
-api + Playwright + Jackson + log4j-kotlin, no -app types. -app now depends on
the module via api(project(...)) (Playwright dropped from its build, transitive
from here) and the four CLI command files were repointed. Docs split into the
new module's module.md/CLAUDE.md, trimmed out of -app's; root module-map,
refactor-state table and REFACTOR-LOG updated. tests/ gitignored to match the
other modules.

clientside: 37/37 green; app: 71/71 green; no new warnings.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Behavior-preserving extraction that the grinder hangs off. BootVerifier.verify()
now delegates to three pieces:

- prepareBootPack() — host-side staging (pick combo, download mod + deps,
  generate the self-installing server pack); public so the grinder can stage on
  the host and hand the pack to its own runner.
- ServerRunner — runs a prepared pack and returns the RAW console lines + exit
  status (RunResult.Completed/NotStarted), classification deliberately left to
  the caller. HostProcessServerRunner is the current start.sh-spawning boot()
  body verbatim; the grinder will add a container-backed impl (--network none,
  resource-capped) for isolation + parallelism.
- outcomeFor() — the shared verdict seam: writes the log, runs BootLogClassifier
  and (only on a crash) BootLogExcerpt. Pure given a logFile, so every runner is
  judged identically.

verify()'s public signature is unchanged; the new serverRunner constructor param
defaults to HostProcessServerRunner(), so existing callers are untouched. No
behaviour change — clientside 37/37 green, app compiles, no new warnings.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Two offline tests the BootVerifier split makes possible (both paths were
boot-only before):

- BootVerifierOutcomeTest pins outcomeFor() across every RunResult branch:
  NotStarted -> INCONCLUSIVE with no log; non-zero exit -> CRASHED with a
  crash-excerpt and a written log; ready-line -> SURVIVED with no excerpt.
- HostProcessServerRunnerTest pins the runner's "cannot launch" contract: a pack
  dir with no start.sh reports RunResult.NotStarted (the contract the grinder's
  container runner must mirror).

Docs updated: module CLAUDE.md (boot seam done, 41 tests), root refactor-state
table, REFACTOR-LOG. clientside 41/41 green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Writing the second ServerRunner implementation surfaced that logFile was dead:
neither runner writes it — BootVerifier.outcomeFor persists the log from the
returned lines. The contract is "return raw lines; the caller persists", so the
param was misleading. run(serverPack, timeout) now. Boy-Scout cleanup; clientside
41/41 green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
New standalone module serverpackcreator-grinder (package
de.griefed.serverpackcreator.grinder), depending only on
serverpackcreator-clientside + docker-java (core + zerodep transport, 3.7.1).
This is the foundation of the fire-and-forget service that boot-verifies mods at
scale in isolated, network-less containers.

The reuse hinge is clientside's ServerRunner interface:

- ContainerServerRunner implements ServerRunner — boots a prepared pack in a
  hardened container instead of a host process, so it slots into BootVerifier
  unchanged and feeds the same BootLogClassifier. Host-side staging (start-script
  check, eula, ContainerSpec assembly) + raw-output mapping live here, behind a
  ContainerEngine seam, so it is unit-tested with a fake (no daemon).
- ContainerEngine / ContainerSpec carry the untrusted-mod hardening as DEFAULTS:
  --network none, read-only rootfs, drop ALL capabilities, no-new-privileges,
  non-root user, tmpfs /tmp, and memory/cpu/pids caps. Docker socket never
  mounted.
- DockerJavaContainerEngine is the real docker-java translation (create -> start
  -> follow logs -> stop -> inspect exit -> force-remove). Integration-only by
  design; the testable orchestration is kept out of it behind the seam.

ContainerServerRunnerTest (fake engine, offline) pins: no-start.sh -> NotStarted
without launching, raw output -> RunResult.Completed, and the assembled spec
carries the hardening + pack mount + written eula. 3/3 green.

Still to build (documented in the module CLAUDE.md): pre-bake cache per
(loader, loaderVersion, minecraftVersion), popularity-ranked queue + worker pool,
and the verdict store feeding the sortable/CSV table.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Add the grinder to the root CLAUDE.md module map and refactor-state table, and
append the REFACTOR-LOG entry for the container-backed ServerRunner scaffold.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Each candidate mod boots with --network none, but the first boot of a given
loader/MC needs network for the ServerStarterJar to self-install the loader +
libraries. LoaderCache resolves that: ensureInstalled(loader, loaderVersion,
minecraftVersion) returns a cached installed-server base, running a one-off
LoaderInstaller (the only place network is allowed) ONLY on a miss.

- Marker-gated: .spc-installed is written only after a successful install, so a
  crash mid-install is redone rather than served half-baked.
- Serialized per tuple via interned locks, so concurrent workers share a single
  install instead of racing.
- Failed/throwing install -> null and the partial tree is cleaned for a clean
  retry.

LoaderInstaller is the seam (real impl = a setup container with network that
snapshots the install; integration-only). LoaderCacheTest (5, offline, fake
installer) pins miss-installs-once-then-hits, failure/throw -> null + nothing
left, install-once across 6 concurrent threads, and independent tuples.

Also documents the host prerequisites for when BootVerifier is wired in
(CURSEFORGE_API_KEY + host-side Playwright/Chromium for locked CurseForge files;
the two are complementary). grinder 8/8 green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Validates the one piece no unit test can: the real docker-java glue against a
live daemon. Gated behind GRINDER_DOCKER_IT=1 (@EnabledIfEnvironmentVariable) so
a normal / daemon-less CI run skips it (verified: units 8/8 pass, IT 2/2
skipped). Run with: docker pull busybox && GRINDER_DOCKER_IT=1 ./gradlew
:serverpackcreator-grinder:test --tests "*DockerJavaContainerEngineIT".

Two cases, exercising create -> start -> stream logs -> ready-detect/stop (or
natural exit) -> inspect exit code -> force-remove, under the production
hardening defaults (--network none, read-only rootfs, dropped caps, non-root):

- non-zero exit + stdout captured (exit 3 read back, lines captured);
- ready-line detection stops a 120s-sleeping container promptly (<30s) without
  timing out.

Verified passing against Docker Desktop 29.5.3; no containers leaked (finally
force-remove confirmed via docker ps -a).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The logic-dense layer, built at a testable altitude by collapsing the
integration-bound boot pipeline behind a CandidateVerifier seam (the real impl
wires ClientsideVerifier + container BootVerifier + LoaderCache later):

- Grinder.grind — verify one candidate (skip already-ground via store, swallow a
  thrown boot so a bad mod can't sink a worker) and record one GrindVerdict per
  loader.
- GrindPool.grindAll — drain a popularity-ranked batch across N worker threads
  (N ≈ host-RAM / per-boot-memory; each in-flight grind holds a booting
  container). Rejects a non-positive worker count.
- VerdictStore (+ InMemoryVerdictStore) — accumulate verdicts keyed by
  slug+loader (re-verify replaces, not duplicates), thread-safe; hasVerdictFor
  drives skip-already-done. Persistent impl is the production upgrade.
- VerdictCsvExporter — RFC-4180 CSV (Name, Project, NamePattern, Confidence,
  Loader, Detail), highest-confidence-first; the CSV export from the original
  feature ask.

13 new unit tests (VerdictStoreTest, VerdictCsvExporterTest, GrinderTest +
shared GrindTestFixtures): replace/skip/swallow/per-loader recording, CSV
escaping + ordering + name-pattern, pool drains all + most-popular-first.
grinder 21/21 unit green (+2 gated IT).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
A multi-day fire-and-forget run must resume after a restart, not re-boot
everything. JsonVerdictStore loads verdicts from a JSON file on construction and
re-persists on every record (whole-file, temp-then-atomic-move so a crash
mid-write can't truncate the store; falls back to a plain replace where atomic
moves are unsupported). Keyed by slug+loader like the in-memory store
(re-verify replaces). A corrupt/unreadable file degrades to empty + logs rather
than crashing the service, and recovers on the next write.

Instant (de)serialization via jackson-datatype-jsr310 (jackson-databind + the
Kotlin module arrive transitively). JsonVerdictStoreTest (4): survive-a-reopen,
replace-across-reopen, corrupt-file-degrades-to-empty, creates-file-and-parents.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The visible half of the original ask, kept standalone (no Spring, no new web
dependency) so the grinder doesn't have to depend on -app:

- VerdictReportRenderer: a self-contained HTML page with click-to-sort columns
  (vanilla JS) and a Download-CSV button backed by the embedded CSV. Cells are
  HTML-escaped, and the CSV embedded in the <script> block is \uXXXX-escaped so a
  mod-supplied "</script>" can't break out (jackson doesn't escape these).
- ReportServer: serves the table (/) and CSV (/export.csv) live off the store via
  the JDK's built-in com.sun.net.httpserver.HttpServer. Loopback by default.

Decision: rendered as a standalone report rather than through the app's Quasar
frontend (as first mooted) — coupling the standalone grinder to -app would drag
in Spring/Mongo/Swing and defeat its purpose. Documented in the module CLAUDE.md.

5 new tests: VerdictReportRendererTest (sortable headers, embedded CSV,
HTML/script escaping) and ReportServerTest (real loopback HTTP on an ephemeral
port — / HTML + /export.csv, live store, content types). grinder 30/30 unit
green (+2 gated IT).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Signed-off-by: Griefed <griefed@griefed.de>
Signed-off-by: Griefed <griefed@griefed.de>
ModrinthCandidateSource enumerates Modrinth mod projects most-downloaded-first to
seed the grind queue. Modrinth's search API is keyless and returns the download
count, so the popularity ranking (which decides what to grind first — the mods
most likely to be in a modpack) comes for free. Paginates until the requested
limit or catalog exhaustion, behind the clientside HttpFetcher seam (reused from
-clientside), so it is unit-tested against canned JSON with no network. A failed
page returns what was gathered (partial catalog beats aborting); limit 0 fetches
nothing.

5 tests (ModrinthCandidateSourceTest): download-order preserved, pagination +
exhaustion + a defensive over-limit take (a test caught a final page overshooting
the remaining slots), failed-page-returns-partial, limit-0 no-fetch.

A CurseForge sibling (needs the API key, no declared sideness) is the natural
follow-up. grinder 35/35 unit green (+2 gated IT).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
docker/Dockerfile + README for the container the grinder boots SPC server packs
in. SPC's generated start.sh installs the loader itself, so the image is
loader-agnostic — the neoforged ServerStarterJar is Forge/NeoForge only; Fabric
/ Quilt / LegacyFabric use their own installers, all driven by start.sh. The
image therefore ships only the shell tooling the template needs (bash,
curl/wget, gawk, tar/gzip, ca-certificates) plus Temurin JDK 8/17/21 — a single
JDK can't boot every Minecraft (<=1.16 -> 8, 1.17-1.20.4 -> 17, 1.20.5+ -> 21),
so the grinder sets $JAVA per MC version with no Java download, keeping mod-boots
runnable under --network none.

Built on Docker Desktop 29.5.3 and smoke-tested: all three java -version work,
$JAVA defaults to 21, bash/curl/wget/gawk/tar/gzip present, runs non-root uid
1000; ~1.6 GB (the 3-JDK tradeoff, documented). Arch-independent JDK symlinks
(resolved temurin-*-jdk-arm64 on Apple Silicon).

Next: the real LoaderInstaller (setup boot with network, snapshot into
LoaderCache) and the CandidateVerifier cache-overlay seam.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Generated + booted real packs (Forge 1.20.6 & 1.12.2, NeoForge 1.21, Fabric
1.20.6, Quilt 1.20.6) and diffed each booted dir against its pre-boot baseline.
Records the durable design the spike settled:

- The install layer is mod-independent but loader-specific (SSJ: server.jar +
  installer + shim + run.sh/bat + user_jvm_args; old Forge: forge.jar +
  minecraft_server.<mc>.jar; Fabric: fabric-server-launcher.jar + .fabric +
  versions; Quilt: quilt-server-launch.jar + server.jar + .cache + versions);
  libraries/ dominates (~40-180 MB/tuple).
- The CLEANUP variable is an INCOMPLETE snapshot manifest -> use a DENYLIST
  instead: snapshot (post-boot - pre-boot pack - runtime-state), where
  runtime-state = world*/logs/crash-reports/*.json/eula.txt/.previousrun/
  hs_err_pid*/README.txt/.DS_Store.
- Offline-boot levers (variables.txt, confirmed in default_template.sh):
  WAIT_FOR_USER_INPUT=false, SERVERSTARTERJAR_FORCE_FETCH=false, pre-written
  eula.txt=true, JAVA per MC version.
- Cache-overlay seam RESOLVED: the install layer never name-collides with pack
  files, so overlay = recursive copy via an optional BootVerifier.verify
  packPostProcessor hook (after prepareBootPack, before serverRunner.run);
  default null keeps host-runner behavior unchanged.

No code change — investigative spike captured in the module CLAUDE.md.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Adds an optional packPostProcessor hook to BootVerifier.verify, invoked on the
staged pack AFTER prepareBootPack and BEFORE serverRunner.run — the seam the
grinder uses to overlay the cached loader install and set the offline-boot levers
without any deep BootVerifier change. Default null keeps the host runner's
behavior unchanged; a thrown hook is reported INCONCLUSIVE (never propagated) so
a grinder overlay failure can't crash a worker.

The post-process -> boot -> classify path is extracted into a companion
runPrepared(), which needs no ApiWrapper (unlike staging) and is therefore
unit-tested directly: BootVerifierRunPreparedTest pins ordering (hook before
boot), swallow-and-skip on a thrown hook, and null-hook pass-through.

ServerRunner becomes a `fun interface` (single-method) so fakes read as lambdas;
the `class ... : ServerRunner` implementors are unaffected. clientside 44/44
green; app + grinder compile.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
#2 of the integration core, built on the loader-install spike. DockerLoaderInstaller
generates a mod-less pack (VanillaPackGenerator / ApiVanillaPackGenerator over
ApiWrapper), boots it ONCE with network (networkMode=bridge — the only networked
boot) so start.sh installs the loader + MC server + libraries, then snapshots the
install layer into the LoaderCache target.

The error-prone pieces are pure and unit-tested (the integration shell is not):
- InstallLayerSnapshot — denylist diff/copy: snapshot post-boot files that didn't
  exist pre-boot and aren't runtime state (world/logs/*.json/eula/crash dumps).
  SPC's CLEANUP variable was rejected as the manifest (it misses forge.jar,
  minecraft_server.*, quilt-server-launch.jar, versions/, .fabric/, .cache/).
- PackVariables — the unattended-boot levers: eula=true, WAIT_FOR_USER_INPUT=false,
  JAVA per MC, and SERVERSTARTERJAR_FORCE_FETCH=false for offline boots only (the
  install boot keeps it on).
- JavaForMinecraft — MC -> bundled JDK (the 1.20.4/1.20.5 Java-17->21 boundary).

11 new tests (InstallLayerSnapshotTest, PackVariablesTest, JavaForMinecraftTest).
Operational note (CLAUDE.md): the bind-mounted pack must be writable by container
uid 1000. grinder 43/43 unit green (+2 gated IT).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
ContainerCandidateVerifier wires the whole chain: drives the clientside
ClientsideVerifier (metadata + boot) with the boot run in an isolated container,
and supplies the packPostProcessor hook that — between staging and boot — ensures
the loader is installed (once per tuple, with network, via DockerLoaderInstaller),
overlays the cached install layer into the pack, and sets the offline levers
(PackVariables, offline=true) so the mod-boot runs under --network none.

GrinderApplication.main wires it end to end: ModrinthCandidateSource (or project
URLs from argv) -> GrindPool(Grinder(verifier, JsonVerdictStore)) + ReportServer,
all configured by SPC_GRINDER_* env vars. The application plugin makes it
runnable (:serverpackcreator-grinder:run / installDist).

Both are integration-only (live daemon + image + real ApiWrapper); the pieces
they compose are individually tested. grinder unit suite unchanged + green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
End-to-end verification (grinding a real Modrinth mod through the full chain)
surfaced and fixed real bugs in the boot path — which means server-pack boot had
never actually worked:

- BootVerifier.generateServerPack set no `inclusions`, so SPC rejected the pack
  as "empty" and every boot failed at config-check. Now auto-detects the modpack
  directories via configurationHandler.suggestInclusions (same as the CLI/GUI).
- ApiVanillaPackGenerator had the same gap for its mod-less install pack; it now
  sets inclusions and ships a minimal `config/.spc-grinder-keep` placeholder so
  the pack is not "empty".
- BootVerifier now only boots a stable Minecraft *release* (gating on
  versionMeta.minecraft.serverReleases()), so a mod's newest file targeting a
  -pre/-rc/-snapshot is skipped.
- DockerLoaderInstaller logs the container's exit code + last output when an
  install produces no library layer (turned a silent failure into a diagnosable
  one — it's how the Java-version root cause below was found).
- GrinderApplication honors SPC_GRINDER_SPC_PROPERTIES to point at a specific SPC
  home/config for reproducible runs.

Verified live: pipeline + verdict store + CSV + report server all work on real
data; the hardened container install works end-to-end on a Java-21 Minecraft
(1.20.6 → 38 library files). Known limitation found, not yet fixed: the runtime
image bundles JDK 8/17/21, but current Minecraft (26.x in this env) needs a newer
JDK, and JavaForMinecraft's mapping predates that scheme — so the grinder must
bound MC selection to image-supported Java (or bundle a newer JDK). Documented as
the next follow-up.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Expose the server's declared required-Java major (Mojang's
javaVersion.majorVersion) as a first-class MinecraftMeta query, returning
Optional<String>. Refactor ConfigEditorViewModel to consume it instead of
reaching through getServer().javaVersion(). Additive API surface — source
compatible. Foundation for the grinder bounding Minecraft selection to the
runtime image's bundled JDK set.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The e2e verification surfaced that the grinder picks the newest Minecraft
release (26.x here), whose required JDK the image's 8/17/21 set lacks, so
start.sh aborts at a Jabba Java-install prompt. Replace the hand-rolled
JavaForMinecraft heuristic (wrong for the 26.x scheme) with ImageJavaRuntimes,
which sources the required Java major authoritatively from
MinecraftMeta.requiredJavaVersion and exposes supports(mc) (required-Java known
AND bundled) + javaPath(mc).

BootVerifier gains an injected minecraftAcceptable predicate (default accept-all
for the host CLI; ContainerCandidateVerifier passes imageJava::supports), AND-ed
into candidate selection — a version whose JDK the image lacks is never selected,
so it is never booted on the wrong JDK and never mis-scored as a clientside crash
(false HIGH). Deliberately not SKIP_JAVA_CHECK.

PackVariables.prepareUnattended now takes a resolved javaPath; the Dockerfile
JDK set and ImageJavaRuntimes.bundledMajors are documented as the single coupled
source of truth (extend coverage by adding a JDK to both).

Swap JavaForMinecraftTest -> ImageJavaRuntimesTest. clientside 44/44, grinder
44/44 unit green (+2 gated IT).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Verified follow-up to the image-supported-Java bound. Latest Minecraft release
26.2 declares Java 25 (java-runtime-epsilon); Adoptium ships Temurin 25 GA (now
the most-recent LTS) and temurin-25-jdk is in the bookworm apt pool for amd64
and arm64.

Add temurin-25-jdk + the /opt/java-25 symlink to the runtime Dockerfile and 25
to ImageJavaRuntimes.bundledMajors (setOf(8,17,21,25)) — the two coupled
sources of truth. Java 26 is deliberately not bundled: it appears only on
snapshots, which the release-gate skips.

Rebuilt + smoke-tested: all four JDKs resolve (25.0.3 LTS), $JAVA defaults to
21, runs non-root uid 1000, tooling intact; image ~2.08 GB. New test pins
26.2 -> Java 25 now booting. grinder 45/45 unit green (+2 gated IT). Docs
(grinder CLAUDE.md, docker README) updated.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Signed-off-by: Griefed <griefed@griefed.de>
Closes https://github.com/Griefed/ServerPackCreator/issues/1122 Closes https://github.com/Griefed/ServerPackCreator/issues/1123 Closes https://github.com/Griefed/ServerPackCreator/issues/1124 Closes https://github.com/Griefed/ServerPackCreator/issues/1125 Closes https://github.com/Griefed/ServerPackCreator/issues/1126 Closes https://github.com/Griefed/ServerPackCreator/issues/1127 Closes https://github.com/Griefed/ServerPackCreator/issues/1130 Closes https://github.com/Griefed/ServerPackCreator/issues/1131 Closes https://github.com/Griefed/ServerPackCreator/issues/1132 Closes https://github.com/Griefed/ServerPackCreator/issues/1133 Closes https://github.com/Griefed/ServerPackCreator/issues/1134 Closes https://github.com/Griefed/ServerPackCreator/issues/1136 Closes https://github.com/Griefed/ServerPackCreator/issues/1137 Closes https://github.com/Griefed/ServerPackCreator/issues/1138 Closes https://github.com/Griefed/ServerPackCreator/issues/1139 Closes https://github.com/Griefed/ServerPackCreator/issues/1140 Closes https://github.com/Griefed/ServerPackCreator/issues/1141 Closes https://github.com/Griefed/ServerPackCreator/issues/1142 Closes https://github.com/Griefed/ServerPackCreator/issues/1143 Closes https://github.com/Griefed/ServerPackCreator/issues/1144 Closes https://github.com/Griefed/ServerPackCreator/issues/1145

Signed-off-by: Griefed <griefed@griefed.de>
Closes https://github.com/Griefed/ServerPackCreator/issues/1146 Closes https://github.com/Griefed/ServerPackCreator/issues/1147 Closes https://github.com/Griefed/ServerPackCreator/issues/1148 Closes https://github.com/Griefed/ServerPackCreator/issues/1149 Closes https://github.com/Griefed/ServerPackCreator/issues/1151 Closes https://github.com/Griefed/ServerPackCreator/issues/1152 Closes https://github.com/Griefed/ServerPackCreator/issues/1153 Closes https://github.com/Griefed/ServerPackCreator/issues/1154 Closes https://github.com/Griefed/ServerPackCreator/issues/1155 Closes https://github.com/Griefed/ServerPackCreator/issues/1156 Closes https://github.com/Griefed/ServerPackCreator/issues/1157 Closes https://github.com/Griefed/ServerPackCreator/issues/1158 Closes https://github.com/Griefed/ServerPackCreator/issues/1159 Closes https://github.com/Griefed/ServerPackCreator/issues/1160 Closes https://github.com/Griefed/ServerPackCreator/issues/1161 Closes https://github.com/Griefed/ServerPackCreator/issues/1162 Closes https://github.com/Griefed/ServerPackCreator/issues/1164 Closes https://github.com/Griefed/ServerPackCreator/issues/1165 Closes https://github.com/Griefed/ServerPackCreator/issues/1166 Closes https://github.com/Griefed/ServerPackCreator/issues/1167 Closes https://github.com/Griefed/ServerPackCreator/issues/1168 Closes https://github.com/Griefed/ServerPackCreator/issues/1192 Closes https://github.com/Griefed/ServerPackCreator/issues/1195 Closes https://github.com/Griefed/ServerPackCreator/issues/1196 Closes https://github.com/Griefed/ServerPackCreator/issues/1221

Signed-off-by: Griefed <griefed@griefed.de>
Signed-off-by: Griefed <griefed@griefed.de>
Signed-off-by: Griefed <griefed@griefed.de>
Signed-off-by: Griefed <griefed@griefed.de>
Audit M3. The published `requiredJavaVersion` query (added in 07e931ab9 and
leaned on by the grinder) had no API-module test — only the app-side
ConfigEditorViewModelTest exercised the facade with a mocked VersionMeta, so the
actual delegation to MinecraftServer.javaVersion was unpinned.

Pins the declared server-Java major for a spread of known releases offline
(bundled per-version manifests): 1.16.5->8, 1.17->16 (deliberately 16, not 17 —
guards against an "era->JDK" remap creeping back), 1.20.1->17, 1.20.6->21, plus
the empty-Optional path for an unknown version.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Audit M2 + M4. The BootVerifier orchestration was split (d0d257fb0) and later
gained the minecraftAcceptable gate (81e5f2528) and release-only filter
(0c9c121b5) without a characterization test on prepareBootPack itself — only the
pure BootCandidateSelector building block and the runPrepared boot/verdict half
were covered.

Pins the selection wiring against a real offline ApiWrapper: the
minecraftAcceptable gate short-circuits when it rejects every candidate; an
accept-all gate lets a real release pass selection and advance to download; and
a project targeting only pre-releases is filtered out by the serverReleases()
gate. Staging stops at the (no-network) download step, so nothing boots.

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>
A grinder e2e on Minecraft 26.2 surfaced a false clientside HIGH: Fabric has no
build for the brand-new MC yet, so start.sh aborts with "Fabric is not available
for Minecraft 26.2" before the mod is ever loaded — exit 1, no ready-line — and
BootLogClassifier scored it CRASHED (→ HIGH). The mod was never actually tested.

All of start.sh's crashServer messages are pre-launch environment/loader/install
failures (loader-not-available, launcher-jar/install download failure, Java or
EULA or variables.txt setup failure, unknown modloader). Classify those
(setupAbortMarkers) as INCONCLUSIVE. Kept specific so a genuine mod-load crash
(stacktrace, mixin error) still scores CRASHED — pinned by the existing
nonZeroExitWithoutReadyMeansCrashed test plus three new regression tests using
verbatim e2e output. clientside 50/50 green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The "Still to build" section was stale (claimed the CandidateVerifier and main
entrypoint were unbuilt — both have shipped). Rewrite it as "Status & what
remains": the core loop is built and e2e-verified; what's left is continuous
operation (re-scan loop / queue checkpointing), the optional CurseForge
candidate source, and a selection-time loader-availability optimization.

Record the 2026-07-28 full-loop e2e on current Minecraft: modmenu on MC 26.2
booted offline on JDK 25 to "Done!" (Quilt → SURVIVED/MEDIUM), validating the
container-install → offline-boot → verdict chain; the Fabric/26.2 false-HIGH it
exposed is fixed in -clientside. Bump root test counts (clientside 50, grinder
47, api 163).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
LoaderVersionResolver.latest returned the intermediary loaders' global-latest
version for ANY Minecraft version — so Fabric/Quilt/LegacyFabric looked bootable
for a brand-new Minecraft they have no build for yet (e.g. Fabric on 26.2). That
combo then got selected and spun up a container that aborted "not available".

Gate the three intermediary loaders on the existing Meta.isMinecraftSupported(mc)
(the fabric-intermediary presence check; Forge/NeoForge were already MC-specific).
A null return drops the combo from BootVerifier.prepareBootPack's selection, so
no container is spun up — the primary defense; the classifier's setup-abort
INCONCLUSIVE mapping remains the backstop. Benefits the grinder and the CLI verbs.

New LoaderVersionResolverTest (real offline metadata): supported MC -> non-null;
unsupported MC -> null for all loaders. clientside 53/53 green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Add a default interface method returning the newest verifiedAt across a slug's
per-loader verdicts (null when never ground), computed from all(). This is the
data a continuous grind needs to re-verify only stale projects. Both the
in-memory and file-backed stores inherit it; the test fixture gains an optional
verifiedAt so store tests can pin the newest-across-loaders and survives-reload
behavior.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
With no project-URL args, GrinderApplication now loops instead of running one
batch: each pass re-pulls the popularity-ranked candidates and grinds them, then
sleeps SPC_GRINDER_INTERVAL (default 6h). Grinder's skip logic changes from "any
verdict exists" to "a FRESH verdict exists" — it skips projects whose newest
verdict is younger than reverifyTtl (SPC_GRINDER_REVERIFY_TTL_DAYS, default 30)
and re-verifies stale ones, so evolving mods, new loader versions and
newly-supported Minecraft releases get rechecked over time. Verdicts persist per
record, so a restart resumes; a JVM shutdown hook stops the loop. Passing
explicit project URLs keeps the one-shot path (for verification).

Grinder gains an injected reverifyTtl (default 30d) and reuses its existing
clock seam for freshness. GrinderTest pins skip-fresh vs re-verify-stale with a
fixed clock. grinder 50/50, clientside 53/53 green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Introduce a CandidateSource interface (candidates(limit) most-downloaded-first);
ModrinthCandidateSource now implements it. Add CurseForgeCandidateSource: browses
CF /mods/search sorted by TotalDownloads (sortField=6) with the x-api-key header,
index/pageSize<=50 pagination capped at index<10000, mapping each mod to a
GrindCandidate whose projectUrl is links.websiteUrl (a curseforge.com URL the
clientside CurseForgePlatform resolves), falling back to a slug-built URL.

GrinderApplication wires Modrinth always and CurseForge only when
CURSEFORGE_API_KEY is set (mirrors clientside supportedPlatforms); each pass runs
both and GrindPool re-sorts the union by popularity so they interleave. New env
SPC_GRINDER_CF_LIMIT. Store dedup stays slug-keyed (a mod on both platforms is one
project) — noted as a follow-up.

CurseForgeCandidateSourceTest mirrors the Modrinth source's canned-JSON coverage
(order, pagination, exhaustion, failed-page partial, websiteUrl fallback, limit 0).
grinder 56/56 green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
A separate derived image (FROM spc-grinder-runtime) for the script-template
matrix IT: adds fish (the new .fish templates) and PowerShell pwsh (the .ps1
templates). pwsh is installed from the GitHub release tarball, not the Microsoft
apt repo, which has no arm64 Debian pwsh — the tarball covers amd64 and
Apple-Silicon alike. Kept separate so the production grind image stays bash-only
and lean; the grinder never uses it.

Built + smoke-tested: fish 3.6.0, pwsh 7.4.6 (runs a script on arm64), JDK 25 +
bash intact, non-root uid 1000, ~2.48 GB. docker/README documents it.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
A gated integration test that boots the generated start-scripts across a
(Minecraft version × loader × shell) matrix to prove the templates themselves
install the loader + server and reach the ready-line — the reason being the new
.fish templates, with bash as control and PowerShell alongside. Nothing else
runs the templates (the rest of the suite only asserts the files are written).

Per cell: force the default sh/fish/ps1 templates, generate a mod-less pack
(ApiVanillaPackGenerator), point $JAVA at the per-MC bundled JDK, and boot in the
spc-grinder-templates image with [shell, script] and networkMode=bridge (the
template does its own install). A cell passes on "Done (…)! For help". Invalid
combos (loader has no build for the MC, or its JDK isn't bundled — via the
step-3 LoaderVersionResolver gate + ImageJavaRuntimes) are reported skipped.
Cells run through a bounded executor; one @TestFactory DynamicTest per cell.

Gated behind GRINDER_TEMPLATE_IT=1 (live daemon + built image; network-heavy),
matrix dims env-overridable for a focused subset. Verified working on a
Fabric/1.20.1 subset: bash reaches ready-line; fish surfaced a real template bug
(fixed next).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
default_template.fish's runJavaCommand split the assembled Java command on
spaces and, unlike bash's unquoted word-splitting, fish's `string split` keeps
empty tokens. An empty $JAVA_ARGS (a double space in the command) therefore
injected a stray "" argument, which Java read as the main class →
"Could not find or load main class" / ClassNotFoundException. bash was unaffected.

Fixed with `string split --no-empty`. Caught by the new ScriptTemplateMatrixIT:
on a Fabric/1.20.1 boot the bash cell reached the ready-line but fish did not;
after the fix both reach "Done (…)! For help" (fish ≡ bash), re-verified in a
container. This is the exact fish-vs-bash divergence that harness exists to find.

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>
The source relied on unverified magic numbers. Verified against CurseForge's REST
docs: pageSize default/max 50, index + pageSize <= 10000, sortOrder asc|desc,
downloadCount, links.websiteUrl, x-api-key. The docs render ModsSearchSortField
numerically WITHOUT names, so the one value that mattered (sortField=6 =
TotalDownloads) is corroborated against PrismLauncher's FlameAPI sorting table
(1 Featured, 2 Popularity, 3 LastUpdated, 4 Name, 5 Author, 6 TotalDownloads,
7 Category, 8 GameVersion). The original assumption was correct.

Rather than keep trusting a magic number: name it (SORT_FIELD_TOTAL_DOWNLOADS,
MAX_INDEX, MAX_PAGE_SIZE) with the sources in KDoc, and add warnIfNotDescending
so a page the API did not sort by downloads is logged instead of silently
changing which projects get fetched. Ordering correctness never depended on the
API anyway — GrindPool re-sorts the union by popularity — now documented.

New tests pin the request contract (gameId/classId/sortField/sortOrder/x-api-key),
the pageSize guard, and that an out-of-order page is passed through intact. The
test-only `!!` in this file becomes requireNotNull (part of L3; the IT's `!!` is
handled separately). Audit finding M2. grinder 60/60 green.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The --no-empty fix lived only behind ScriptTemplateMatrixIT, which needs a live
Docker daemon plus a hand-built image and so never runs in CI — the exact bug it
cost a container boot to find could return silently in a published resource.

ScriptTemplateContentTest (api, always runs) pins the load-bearing construct at
source level and asserts the empty-token-keeping split cannot come back. Verified
to have teeth: reintroducing the bug fails it with the intended message. It also
runs `fish -n` over both fish templates when a fish binary is on PATH, skipping
(not failing) otherwise, so a syntax regression is caught without the container.

Audit finding M4 investigated and CLOSED as a non-issue rather than "fixed":
cleanServerFiles' comma split has no --no-empty, but bash's `IFS="," read -ra`
keeps empty fields too and both shells pass the empty token to `find -name ""`,
which matches nothing (exit 0). Confirmed empirically in the container — both
shells produced 3 fields, deleted exactly the target, left the keeper. Left
unchanged deliberately (a --no-empty there would be churn and a divergence from
the bash reference) and documented so it is not re-litigated.

Audit findings M3 (fixed) + M4 (closed).

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>
Log the M1–M4 + LOW remediation with the evidence for each (including M4, which
was investigated and closed as a non-issue rather than "fixed"). Root test table
refreshed from a measured run: api is 228, not 163 — that entry had been stale for
several sessions, it is not a regression. grinder is 60 with 3 daemon-gated.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The matrix IT machinery is proven, but only 2 of ~36 cells have ever run
(Fabric 1.20.1 bash + fish). State that plainly so no one assumes parity: every
pwsh cell is unexecuted (.ps1 has only had `pwsh --version` smoke-tested, and fish
already proved a template can be genuinely broken), as are all Forge/NeoForge/Quilt
cells and the 1.12.2/1.16.1 rows (the Java 8 path and the pre-ServerStarterJar
Forge branch).

Also records the two shipped-but-unobserved paths: the continuous loop is
unit-tested for its TTL policy but never run end-to-end, and the CurseForge source
has never made a real API call (no key available).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Running the first-ever pwsh cell surfaced two things. (1) pwsh needs a writable
$HOME: it creates $HOME/.cache at start-up and the container hardens the rootfs
read-only, so it died with a TypeInitializationException (exit 133) before parsing
anything. (2) More fundamentally, the .ps1 template shells out to Windows `CMD /C`
three times (Java-version detection, the server launch, the bit check), so on
Linux it fails at "The term 'CMD' is not recognized", mis-reads the Java version
and aborts at the Jabba prompt. That is a platform mismatch, not a template defect
— .ps1 targets Windows.

So a pwsh boot cell is not achievable here and is now rejected by scriptFor with
that reason. PowerShell instead gets powerShellTemplatesParse, which runs
PowerShell's own Parser::ParseFile over both shipped .ps1 templates inside the
image — the same syntax-regression class the fish check covers, one rung down from
a boot. Verified: both templates parse clean, and bash+fish boots still pass
alongside it.

Docs record the constraint, the corrected coverage ledger (bash+fish boots and the
.ps1 parse verified; Forge/NeoForge/Quilt and 1.12.2/1.16.1 still unrun), and the
live continuous-mode verification (fresh verdicts skipped across two 40s-spaced
passes, report server serving throughout, SIGTERM shutdown hook, and TTL=0 proving
the stale path really re-verifies).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Slugs are not globally unique — `jei` exists on Modrinth AND CurseForge — but the
store keyed on slug + loader and both lookups matched on slug alone. With both
sources wired that meant one platform's verdict overwrote the other's row and,
worse, made the other platform's project look already-ground so it was never
verified at all.

The identity is now platform + slug + loader, via a shared internal verdictKey()
used by both stores so their schemes cannot drift apart again (they already did
once — the NUL-separator fix). hasVerdictFor and newestVerification take the
platform, and GrindCandidate carries it so the freshness check can be scoped;
sources set it from ModPlatforms (MODRINTH / CURSEFORGE), which documents the
required agreement with the clientside ModPlatform names. CLI-passed URLs get
ModPlatforms.ofUrl. Grinder warns loudly if a candidate's platform disagrees with
the resolved report's, since that combination would otherwise re-grind forever.

No migration needed: keys derive from fields every persisted verdict already has.
New tests pin both halves — same slug on two platforms stays two rows, and a fresh
Modrinth verdict does not skip CurseForge's same-slug project. grinder 64/64 green.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Ran the whole grid (3 Minecraft versions × 4 loaders × bash/fish) plus the .ps1
parse check. Headline: bash and fish agree in EVERY cell — the template parity
this harness was built to prove.

Two findings. (1) Quilt on Minecraft 1.16.1 fails in both shells: "Quilt Installer
requires Java 17 or greater" while 1.16.1 pins $JAVA to Java 8 per Mojang's
declared requirement — the installer and the server need different JDKs. Real
toolchain constraint, reproducible, not a shell-porting defect; left for a
decision since it touches all three templates. (2) An initial 3-worker run
produced 8 spurious failures (JVM SIGKILLed mid "Preparing level" — container OOM,
3 GB per cell); all 8 passed serially. SPC_GRINDER_TEMPLATE_WORKERS therefore
defaults to 1 with the reasoning recorded: in a correctness harness a false FAIL
costs more than a slow pass.

Docs carry the result table, both findings, and a third gap the verification
surfaced: SIGTERM abandons an in-flight boot, so the engine's force-remove finally
never runs and a container can leak.

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>
Quilt could not be installed on older Minecraft at all: its installer requires
Java 17+, but the templates ran every installer with $JAVA — which for Minecraft
1.16.1 is Java 8 per Mojang's declared requirement. One JDK cannot satisfy both,
so the install died with "Quilt Installer requires Java 17 or greater to run" and
the template then blamed the network. Reproduced in a container for bash AND fish,
so it was never a shell-porting defect.

All three templates now run modloader installers through
runInstallerJavaCommand / RunInstallerJavaCommand, which uses the optional
JAVA_INSTALLER from variables.txt and falls back to $JAVA when unset. Packs that
never set it behave exactly as before, and no new placeholder plumbing or public
config surface was needed — the templates already parse variables.txt into shell
variables (.ps1 gained the one explicit lookup it needs). The misleading
"check your internet connection" message now names the real cause and the fix.

ScriptTemplateContentTest pins the construct in all three templates and asserts
the Quilt install cannot regress to the server's JAVA.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Wires the grinder to the templates' new JAVA_INSTALLER override.
ImageJavaRuntimes.installerJavaPath() returns the newest bundled JDK >=
MINIMUM_INSTALLER_JAVA (17, set by the Quilt installer's own requirement), and
PackVariables writes it as JAVA_INSTALLER when supplied — the server's JAVA is
never touched.

installerJavaPathFor(minecraftVersion) is what the boot paths actually call: it
returns the override *only* when the server's own Java is older than the installer
minimum. A modern Minecraft already satisfies every installer, so its pack is left
with no JAVA_INSTALLER entry — exactly what a hand-made pack looks like. That
keeps the templates' plain-JAVA fallback on the path the matrix really boots
instead of leaving it dead weight (it is the branch every existing pack takes, so
a slip there would break installs for everyone).

Tests pin both directions of the resolver and that JAVA_INSTALLER is absent unless
asked for.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The drain landed on the concrete DockerJavaContainerEngine, so the guarantee the
seam's own KDoc makes — "always removes it" — was unenforceable for any other
implementation, and ContainerCandidateVerifier (which holds the seam type) could
not trigger cleanup at all. ContainerEngine now extends AutoCloseable with a
no-op default close(), so every engine can be drained and fakes need no change.

GrinderApplication drops from two shutdown hooks to one, registered before any
boot can start: it stops the pool pulling new candidates, then releases in-flight
containers. Two hooks ran in an unspecified order, which happened to be harmless
but was incidental rather than designed.

Behaviour-preserving: the drain IT still passes (it is what proved the leak fix in
the first place), and the IT is tidied while open — DockerClient imported instead
of fully qualified, and the local engine no longer shadows the class-level one.

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>
Matrix table covers 5 Minecraft versions x 5 loaders x bash/fish: every runnable
cell green, bash == fish everywhere, N/A cells correctly filtered. Documents why
the installer override is deliberately conditional (so the plain-JAVA fallback
stays the exercised path), that the .ps1 selection is executed rather than only
parsed, and the completed shutdown drain. Test counts refreshed from a measured
run: api 229, clientside 56, grinder 68.

CLAUDE.md keeps its original CRLF line endings, so this is the three-line table
edit it claims to be rather than a whole-file rewrite.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
CLAUDE.md was the last CRLF Markdown file in the repo; the other 545 tracked
.md/.kt/.kts files are LF. Converts it, and nothing else.

Declared on purpose and kept to its own commit: an earlier docs commit had done
this silently as a side effect, which made a three-line table edit look like a
179-line rewrite, broke that diff for review and re-pointed `git blame` for the
whole file at it (audit finding M1). Verified line-endings-only —
`git diff --ignore-all-space` reports zero changed lines.

Note: `serverpackcreator-api/.../modscanning/ScanResult.kt` is still CRLF; left
alone as it is outside this commit's scope.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The last CRLF file left in the repo; every other tracked .md/.kt/.kts file is LF.
Converts it, and nothing else — verified line-endings-only, `git diff
--ignore-all-space` reports zero changed lines.

Unlike the CLAUDE.md normalization this touches a source file in the published
-api module, so it was compiled and tested rather than assumed inert: ModScannerTest
green, and the full api (229) and clientside (56) suites pass. Kept to its own
commit because it reflows the whole file in `git blame`, which should never ride
along with a behaviour change.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A detailed guide for developers embedding the API: dependency coordinates,
quickstart, ApiWrapper, a full PackConfig reference, validation, generation
results, version metadata, mod scanning, settings, plugins and a pitfalls section.
Keeps the original build/test commands.

The mod-scanning example documents the real contract: scan takes a
Collection<File> and returns a ScanResult carrying exclusions and dependencies —
not the list of files an earlier draft claimed, which could never have compiled.
ScanResult arrived with "refactor: Improve modscan result readability"; the
following commit adds the gate that stops that drift recurring.

Symbols were verified against the sources rather than written from memory, all
TOC anchors resolve under GitHub's slug rules, and every relative link resolves.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The README taught a snippet that could not compile, and nothing caught it: prose
is not built. ReadmeExamplesTest mirrors the guide's Kotlin so the compiler is the
gate — rename a member or change a return type and this file stops building.

Expensive, side-effecting snippets (ApiWrapper.api(), serverPackHandler.run) sit in
functions that are never invoked; compiling them is the point. The cheap,
side-effect-free ones are also executed: the ScanResult shape, and the version-meta
accessors including the documented trap that Fabric/Quilt/LegacyFabric always
return a loader version while support is a separate question.

Verified to have teeth: reintroducing the original mistake fails the build with
"Initializer type mismatch: expected 'List<File>', actual 'ScanResult'".

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Step-by-step usage for the four verbs — scan local jars, the fast metadata report,
the boot-based verification, and applying accepted entries — with the exact flags
and short forms, both as jar arguments and interactive-shell commands. Explains
that the module is the engine while the commands ship in the app jar, and how to
read a verdict: only CRASHED is decisive, SURVIVED is not proof of server-safety.

The headless-browser prerequisite is documented in full. Playwright downloads the
browser binary automatically, but not its system libraries; on a headless Linux
host those need `npx --yes playwright install-deps chromium`, which is what this
project's own CI installs before a boot run. Without it Chromium fails to launch —
a failure the guide previously did not predict. Noted as best-effort, since only
locked CurseForge files are affected and everything else verifies regardless.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Build the runtime image, do a one-shot run to prove the Docker setup, then run it
as a continuous service. Full environment-variable table with defaults, where to
read the table and CSV, a systemd unit, troubleshooting, and the maintainer-only
template matrix kept separate. Flags the ~3 GB-per-worker RAM budget, because
exceeding it shows up as false failures rather than an obvious error.

Env vars and defaults were taken from GrinderApplication, and the documented
installDist launcher path was produced and checked on disk.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Both guides restate facts that live in code, so both can drift silently — the same
class of problem as the API snippet that could not compile.

ReadmeConfigurationTest (grinder) compares the README's configuration table with
GrinderApplication: every SPC_GRINDER_* the service reads must be documented, those
with a literal default must show that exact default, and the table must not invent
variables nothing reads. Verified to have teeth: staling one default fails with
"README does not state SPC_GRINDER_INTERVAL's real default (21600)".

ClientsideReadmeFlagsTest (app) does the same for the four verbs' flags, which are
documented in a different module than the one implementing them — so nothing
otherwise links a renamed option to the guide a user follows. It models
mixinStandardHelpOptions honestly, since --help is accepted without being declared.
Writing it surfaced two long forms the guide only showed in short form
(--directory, --url), now documented.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Two tests had been failing on develop since 07e931ab9, which refactored
ConfigEditorViewModel.requiredJavaVersion to delegate to the new
MinecraftMeta.requiredJavaVersion. The tests still stubbed getServer(), so the
delegate had no mockk answer and threw MockKException.

Stub what the view-model actually calls, and drop the now-unused MinecraftServer
mock and import. The assertions are unchanged: "17" for a known requirement, "?"
for none.

Found while running the app suite for the new documentation guards. It slipped
through because that refactor was verified with :serverpackcreator-app:compileKotlin
rather than the app test suite — compiling is not testing. app 73/73 green.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Nothing enforced the repo's LF convention. A contributor with core.autocrlf=true
would commit CRLF shell scripts, and a CRLF start.sh dies at runtime with a "bad
interpreter" error — inside a *shipped* server pack, because the templates under
resources/server_files are copied verbatim into every generated pack. That is the
sharp edge this pins.

Policy: LF everywhere (`* text=auto eol=lf`), with explicit eol=lf for *.sh, *.fish
and gradlew where a carriage return breaks execution rather than just the diff, and
eol=crlf for *.bat because cmd.exe mis-parses LF-only batch files. .ps1 stays LF —
PowerShell handles it. spc.install4j is marked -text so install4j owns its bytes.
Known binaries are declared so a future test fixture (jars, icons, the sample
modpack zip) can never be corrupted by normalisation.

Renormalisation touches exactly two files, both line-endings-only, verified with
`git diff --ignore-all-space` reporting 0 changed lines for each:
  - docker/init-mongo.js: CRLF -> LF (it is JavaScript)
  - gradlew.bat: CRLF -> LF in the index; the working tree stays CRLF via eol=crlf

Consequence worth knowing: default_template.bat and serverpackcreator-plugin-example's
gradlew.bat keep LF blobs but now check out as CRLF, so a build from a fresh clone
ships a CRLF start.bat to Windows users instead of an LF one. That is the correct
form for a batch file and fixes a latent fragility, but it is a change in generated
output rather than pure bookkeeping.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The line-ending pin flipped default_template.bat to CRLF as a side effect of the
blanket *.bat rule. That file is not a script this repo runs — it is copied verbatim
into every generated server pack, so its endings are the endings users receive, and
changing them changes shipped output. That is a product decision, not something a
line-ending pin should quietly make, and the call is to keep shipping LF.

Scoped to resources/server_files/*.bat so a future batch template inherits the same
rule, and placed below the blanket *.bat line because later rules win. Gradle's two
wrapper scripts keep eol=crlf, since those really are Windows scripts.

No content renormalisation: the blob was already LF, only the working-tree checkout
changes back. api 231/231 green.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Pre-existing working-tree formatting, committed on its own so the
catalog-cursor commits that follow contain only their own concern.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`CandidateSource.candidates(limit)` could only ever return the head of a
platform's catalog: both sources restarted at offset 0 on every call, so a
continuous grind re-checked the same most-downloaded projects forever and
never reached rank limit+1. Replace it with `page(offset, limit)` returning a
`CandidatePage` (candidates + nextOffset + endOfCatalog) so a caller can keep
a position and walk the whole catalog across passes.

`endOfCatalog` is the part that makes unattended crawling correct: it is true
only when the platform genuinely ran out of results (or, for CurseForge, at
its hard search-index cap), never when a request failed. Without that
distinction a transient 503 deep in the catalog is indistinguishable from the
end and would reset the crawl to the top.

Also pins the two measured/documented coverage ceilings in the source docs:
Modrinth clamps offset at 99 999 (~71 000 mods today, so reachable), while
CurseForge's /mods/search refuses index >= 10 000, capping a sweep at the
10 000 most-downloaded mods until the search is partitioned.

The entry point keeps its current behaviour (page(0, limit)) — the crawler
that actually advances the offset lands separately.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Adds the piece that was missing for unattended coverage: a `CursorStore`
(in-memory double + `JsonCursorStore`, same temp-then-atomic-move persistence
as the verdict store) holding a `CatalogCursor` per platform, and a
`CatalogCrawler` that hands the grind loop the *next* slice of every catalog
and advances each source's position by what it actually handed out.

Behaviour that makes it safe to leave alone for months:
- the position is written on every advance, so a restart resumes mid-catalog
  instead of restarting at the most-downloaded mods;
- a source that reports the end of its catalog wraps to the top and counts a
  sweep, handing the re-verification decision back to the verdict TTL;
- a position already past the end wraps *and* takes the new sweep's head
  slice in the same pass, so no pass is wasted (guarded so an empty catalog
  cannot spin);
- a failed request keeps its position and is retried next pass, and a source
  that throws is skipped — neither can stall or sink the crawl.

`CandidateBatch.sweepCompleted` is the "we have been all the way round"
signal the daemon loop needs to distinguish "still crawling" from "catalog
covered and current"; the loop that consumes it lands next.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`Grinder.grind` now returns a `GrindOutcome` (VERIFIED / FAILED /
SKIPPED_FRESH) and `GrindPool.grindAll` returns how many candidates it
verified, so the daemon can tell a pass that did real work from one that found
everything fresh.

`GrindPacing.pauseAfterPass` turns that into the wait: carry straight on while
work keeps turning up, pause briefly when nothing was due but the catalog goes
on, and idle for the long interval only once a full sweep found nothing left to
verify. A fixed sleep between passes is what capped sweep speed — 25 projects
per 6 h cannot cover a 71 000-project catalog in any useful time.

Failures deliberately do not count as work: if the host is broken every
candidate fails, and treating that as progress would race the crawl position
through the catalog leaving thousands of projects unverified. Keeping the
policy a pure function keeps it tested rather than buried in a sleep in main.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The crawler's unit tests run against a fake catalog, which cannot prove the two
*platform* assumptions the coverage claim rests on: that Modrinth really serves
the offsets the crawl walks, and that a restart continues rather than
re-serving the head.

`CatalogCrawlLiveIT` (gated, no containers, a handful of search calls) checks
consecutive live batches return different projects, a fresh crawler over the
same cursor file resumes, and offset 40 000 still serves real projects — the
manual API probe that informed this design, kept as something repeatable.

Verified passing against the live API on 2026-07-29.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Wires the crawler into the daemon: each pass now takes the *next* slice of
every platform's catalog and paces itself on how much work it found, so an
unattended grinder works its way through a catalog and then keeps it current —
which is what "leave it running and check the report whenever" requires.

Config changes: `SPC_GRINDER_BATCH` replaces `SPC_GRINDER_MODRINTH_LIMIT` /
`SPC_GRINDER_CF_LIMIT` (their "top N per pass" meaning no longer exists — it is
now the per-platform slice size, and the sweep-speed lever),
`SPC_GRINDER_CURSORS` points at the crawl-position file, `SPC_GRINDER_SCAN_DELAY`
paces passes that only scanned past fresh verdicts, and `SPC_GRINDER_INTERVAL`
now means "idle after a full sweep found nothing due" rather than a fixed sleep
between every pass.

Docs state the arithmetic and the ceilings rather than implying completeness: a
sweep is catalog ÷ batch × pass-duration (so 25/pass over ~71 000 Modrinth mods
is ~2 850 passes — raise the batch and keep the TTL longer than a sweep), and
**CurseForge coverage stays capped at its 10 000 most-downloaded mods** because
/mods/search refuses index >= 10 000. The cursor cannot lift that; it needs a
partitioned search, which is recorded as remaining work.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Found while verifying the crawl loop against live Modrinth: SIGTERM during a
pass killed the JVM with a bare `Exception in thread "main"`. The shutdown hook
interrupts the main thread, which is parked in `GrindPool.grindAll` joining its
workers, so the resulting InterruptedException propagated straight out of main.
Pre-existing — the hook and the join both predate this branch.

`grindAll` now treats it as what it is: abandon the rest of the batch, request
stop, restore the interrupt flag for the caller, and return the count gathered
so far. In-flight containers are still torn down by the engine's own cleanup.

Reproduced as a failing test first (`anInterruptedPassStopsInsteadOfThrowing`),
then re-verified live: clean shutdown, no leaked containers.

Also records the live crawl verification in the module docs — with batch 5 and
a store seeded fresh for the top 10 projects, passes #1/#2 verified nothing and
paused 3 s each while the cursor advanced 5 -> 10 -> 15, and pass #3 reached
the first unseeded projects and started verifying for real.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
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 modloader split left two holes: a mod carrying no modloader tag appears in
no loader slice, so it was unreachable past its version's top 10 000; and a
(version, loader) slice above 20 000 mods lost its middle. An over-cap version
is now crawled per modloader **and** per category, and a category slice past
both sort directions is narrowed by modloader — version x category x modloader
being the narrowest slice /mods/search can express.

Both axes run, rather than category replacing loader, because neither is
provably total: CurseForge tags a mod with a loader only if it has one, and its
own support docs disagree on whether a category is mandatory (the submission
guide calls the main category required; the project-creation page lists only the
class). Running both means a mod is reachable if it has *either* tag, for ~6
extra requests per over-cap version. Every category is crawled, children
included, because whether a search on a parent category returns its children is
undocumented. Category ids come from /categories?gameId=&classId=, with isClass
entries dropped.

Remaining gap, logged with a count: a deepest slice above 20 000 loses its
middle. A mod with neither tag is unreachable beyond its version's cap and
cannot be detected from outside.

Fixes a restart bug the new tests exposed, introduced by the partitioning commit
earlier on this branch: the axis lists were read only when partition == null,
i.e. at sweep start. A daemon restarting mid-sweep resumes with a partition
token and an empty in-memory list, so the plan found no next partition, reported
the catalog finished and wrapped — discarding exactly the position the cursor
exists to preserve. Both lists are now re-read whenever they are missing. It
shares this commit because the category-stage test that exposed it cannot pass
without the fix.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
First run with a real CURSEFORGE_API_KEY, and the design taken from
CurseForge's documentation turned out to be inert. Two silent killers:

1. `pagination.totalCount` SATURATES at the paging cap. The whole catalog,
   gameVersion=1.12.2 and any slice above 10 000 all report exactly 10 000;
   only genuinely smaller slices report a true size. Every split condition was
   written as `> CAP` (and `> 2 x CAP` for the category->loader narrowing), so
   not one of them could ever fire: the "partitioned" crawl would have covered
   the top 10 000 of each version and nothing more, without a word in the log.
   `warnIfSliceIsUnreachable` was dead for the same reason. All rules now key
   off `>= CAP` — saturation is the only signal the API gives.

2. The version axis was 98% junk. /games/432/versions returns 7 339 strings
   across 36 version types, including Forge version families (47.0.42) and
   types named `Server Side`, `Shader Loader`, `Addons` and `DO NOT USE -
   Grouped MC Versions`. Filtering to types named `Minecraft …` (via
   /games/432/version-types) leaves 135 real versions — a 54x smaller axis.
   Unfiltered, a sweep would burn 7 200 requests on partitions that cannot
   hold a single mod.

A third fix came from the same data: sortOrder=desc only *trends* by downloads
(one adjacent inversion in a 10-mod page) and asc is not ordered at all, so the
mis-order warning would have fired on ordinary pages. It now checks the
descending trend (first vs last) and skips ascending — which does reach the
tail, and is what makes the both-ends crawl worth ~10 000 extra mods a slice.

Assumptions that held: the cap applies to index + pageSize (9 950+50 served,
9 951+50 refused); the modloader filter is honoured and maps as documented
(1.16.5 -> Forge 10 000 / Fabric 3 344 / Quilt 377 / NeoForge 238, and sodium
appears under Fabric but not Forge); 0 of 100 sampled mods lack a category. One
was disproved in the safe direction: a parent category does NOT reliably
include its children (3 of 6 sampled), which is why the crawl already visits
all 52 categories rather than the 23 parents.

CurseForgeCrawlLiveIT (gated GRINDER_CF_IT=1 plus a present key, ~40 small
calls) now pins all of it, including the saturation fix end-to-end: 1.12.2
paged out at 10 000 continues into 1.12.2|*|1|desc with real candidates instead
of declaring the catalog finished. Each test prints a [live] line with its
measured numbers.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A supervised live run showed SIGTERM on the one-shot path printing a bare
`Exception in thread "main" java.lang.InterruptedException` over an otherwise
clean shutdown: the `CountDownLatch.await()` that holds the report server open
let the hook's `mainThread.interrupt()` escape `main`. Same defect that was
fixed inside `GrindPool.grindAll` for the continuous path — the fix simply
didn't cover this second park. Now it logs "Report server stopped" and exits;
re-verified by SIGTERM against a rebuilt dist.

Also trims the module CLAUDE.md's completed-work narrative and verification
chronicles (43.7k -> 37.9k chars, back under the ~40 000-char memory-file
warning) per the repo's own convention that history belongs in
claude-docs/REFACTOR-LOG.md, keeping every landmine and remaining-work item;
records the supervised run there, including that the CurseForge grind path is
now verified end to end on two loaders.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The loader cache only ever grew: each `(Minecraft, loader, loader-version)`
tuple costs ~150 MB, only *failed* installs were cleaned up, and loaders keep
shipping builds — so a months-long sweep mints tuples indefinitely and
eventually fills the disk. Nothing evicted them.

`LoaderCache.evictUnusedSince(retention)` now deletes tuples nothing has booted
within the window, and the daemon runs it after every pass
(`SPC_GRINDER_CACHE_TTL_DAYS`, default 7, `0` disables).

Retention is measured from **last use**, not install time: `ensureInstalled`
stamps the marker on every cache hit, so a tuple the sweep still boots stays
fresh however old its install is, and only genuinely idle ones go — a re-install
costs one networked setup boot if it comes back. Directories with no completion
marker are swept at any age: a half-finished install can never be served, so
keeping it only leaks disk.

Eviction takes the same per-tuple lock as installing, so it can never delete a
tree a worker is installing into. That required keying the lock on the sanitized
cache *path* rather than the raw tuple, so both sides agree on one monitor —
eviction only ever learns the on-disk name.

Six tests: evicts only the stale tuple, a cache hit refreshes the stamp,
zero/negative retention disables it, unmarked partials swept regardless of age,
empty cache is a no-op, and eviction racing an install of the same tuple leaves
the freshly installed tuple intact.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The install cache churned: `BootVerifier` always booted the newest loader build,
so every loader release cost another ~150 MB networked install for a server that
boots mods identically. On a catalog-wide sweep that install is the bulk of the
work — the boot itself takes seconds.

`-clientside` gains a `LoaderVersionPolicy` seam: `preferredVersion` is what gets
booted, `latestVersion` is the authoritative newest. `LoaderVersionResolver`
answers both identically, so the default path is unchanged. The grinder supplies
`CachedLoaderVersions`, preferring the most-recently-used build already installed
for that `(loader, Minecraft)` pair — most-recently-used so the sweep stays on one
build and keeps it warm against cache eviction instead of rotating through builds.
Raw version strings come from the completion marker, never the sanitized
directory name.

That reuse is only admissible because of the guard shipped with it: a CRASHED
outcome produced on a non-newest build is re-booted on the newest before it may
stand. A mod that merely needs a newer loader than the cached build fails to load,
exits non-zero and classifies as CRASHED — which would publish a server-safe mod
as a HIGH-confidence clientside mod, the exact false HIGH this module is built to
avoid. `latestVersion` also still drives the support gate, so a cached build can
never revive a loader/Minecraft combination the loader does not support.

The two decisions are pure and unit-tested (`shouldRecheckCrash`,
`reconcileRecheck`) because `verify` needs an ApiWrapper, real generation and a
running server: crash-then-survive takes the newest build's verdict and records
why, crash-then-crash keeps CRASHED with the newest evidence, and an INCONCLUSIVE
re-check leaves the original crash standing — a flaky second boot is not evidence.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The live sweep showed the cursor committing the moment candidates were handed
out. Restarting the daemon mid-pass abandoned the rest of the batch while the
cursor had already moved past it, so those projects were silently deferred to
the next full sweep — about seven weeks at the measured throughput. Nothing in
the logs said so.

`CatalogCrawler` is now two-phase: `nextBatch()` moves nothing and returns the
batch plus a `CrawledPage` per source (cursor-at-start, candidates, end-of-catalog
flag, continuation), and `commit(batch, reached)` advances each source only past
pages whose candidates were *all* reached — stopping at the first that was not,
leaving that source's cursor where the page began so the whole page is re-handed.

`GrindPool.grindAll` returns `GrindPass(reached, verified)`. Reached includes
fresh-skips and failures on purpose: all three are done with, and treating a
failure as unreached would stall the sweep forever on one poison candidate. It
is recorded only after `grind` returns, so a candidate still being ground when
the JVM tears down comes back next pass rather than being skipped.

Granularity is per page rather than per candidate because a partitioned source
can cross partitions inside a single page, making a candidate's exact catalog
position unrecoverable from outside. Re-handing a page is nearly free — anything
already ground in it now has a fresh verdict and is skipped in microseconds. A
sweep is likewise only counted when the page that ended the catalog was itself
fully ground.

Also splits the grinder's module CLAUDE.md into per-subpackage files
(container/loader/source/report), 39.8k -> 19.3k chars for the module-wide file,
keeping the cross-cutting prohibitions always-loaded.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
An hour of live sweep showed the fairness half of the ordering problem:
`GrindPool` sorted each batch by `popularity`, and CurseForge's download counts
run several times Modrinth's for equivalent mods (jei 602 M vs fabric-api
218 M), so every CurseForge candidate outranked every Modrinth one. Measured
over 65 minutes: 108 CurseForge projects ground, zero Modrinth. With a ~2-hour
pass, any interruption shorter than that meant Modrinth never progressed at all.

The two-phase commit shipped earlier prevents un-ground candidates being
skipped; it does nothing about starvation. `interleaveByPlatform` now rotates
one candidate per platform per turn, keeping each platform's own
most-downloaded-first order and dropping a platform from the rotation when it
runs out.

The deeper reason the global sort was wrong: the counts are not comparable.
CurseForge counts file downloads across every version, Modrinth counts
differently — ranking them against each other silently promoted one platform for
the entire run. Within a platform the ranking is kept, because there it means
something.

Four tests, including the one that states the goal directly: an interrupted pass
must have reached both platforms. Also corrects every doc comment and module doc
that claimed a global popularity sort.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
An admin could see what the daemon was doing (log4j rolling log, worker thread in
every line) but nothing about its workers: the container engine streamed output
yet only accumulated it in memory, and the per-attempt boot.log was written after
the run finished. A hung boot was therefore undiagnosable until its 12-minute
timeout fired, and a killed boot left no output at all. The report server exposed
results only — no notion of current activity.

1. Live per-boot console. `ServerRunner.run` takes an `onLine` sink and
   `BootVerifier.runPrepared` appends+flushes each line into the attempt's
   boot.log while the boot runs, so `tail -f` works on a boot in progress and a
   killed boot still leaves its console behind. `DockerLoaderInstaller` streams
   the install the same way into <tuple>/.spc-install.log — the slowest phase of
   a cold grind and the one most worth watching.

2. `/status` on the report server: uptime, current pass,each busy worker with the
   candidate it holds and how long it has held it, crawl position per platform,
   installed-tuple count. Serialized with Jackson because slugs come from the
   internet; a snapshot is a copy so a reader never observes mutation; absent
   optional collaborators render null rather than 500, since a monitoring
   endpoint that fails is worse than a thin one.

3. One INFO line per candidate ground (`Grinding <platform>/<slug>` …
   `Done … → Forge=LOW`), with the fresh-skip at DEBUG — a pass can skip dozens
   in microseconds and would otherwise bury the line that matters.

Fixes a pre-existing bug the new tests caught: `outcomeFor`'s final
`logFile.writeText` was unguarded, so an unwritable log propagated and failed a
verification that had already run. Every log write is now wrapped — logging is
diagnostics, never a dependency of the verdict.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The live views were mentioned in a subsection buried inside "Read the results",
which is the wrong place: that section is about what the grinder has *found*,
these are about what it is *doing*. Promoted to section 7 with the detail an
admin actually needs, and the later sections renumbered.

Adds: a question-to-place lookup table; a full `/status` example with what each
field means and why `busySeconds` is the one to watch (a worker past a few
minutes is installing a cold tuple or stuck, and the boot budget is 12 minutes);
curl/jq one-liners including a watch-based dashboard; the candidate lifecycle as
it appears in the daemon log, and a grep table mapping each notable pattern to
what it means (reuse, crash re-check, held-back cursor, evictions, unreachable
CurseForge slices, sort-order regressions, OOM kills); how to tail an in-flight
boot and a cold loader install; the alternative of attaching to the container;
and a log-file table with what rotates, plus the journald equivalent for systemd
and the note that `grinder.log` only exists if you redirect stdout yourself.

Also fixes a missing space introduced in the previous commit's version of that
text, and points the module CLAUDE.md's logging section at README §7 so the two
stay in step.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The hourly sweep check showed throughput jumping 10x while the loader cache
stayed frozen — verdicts were being produced without booting. Every boot in the
window came back INCONCLUSIVE, from two upstream causes the new logging exposed:
NeoForge 21.1.247's -installer.jar 404s (the version is in maven metadata), and
Fabric claims Minecraft 26.1.2/26.2 support via a placeholder 0.0.0 intermediary
and then aborts in start.sh — 103 boot directories carried that abort.

Griefed asked whether the grinder had booted a version the mod never listed. It
had not: CurseForge's latestFilesIndexes matrix really does list
`26.1.2 modLoader=4` (Fabric) for Croptopia, and BootCandidateSelector pairs each
file with its own declared versions. The loader-support gate was the wrong party,
trusting metadata that claims more than the loader can deliver.

Four fixes:

1. `LoaderCache.failureCooldown` (1h, in memory): a tuple whose install just
   failed is not re-attempted, so candidates wanting it fail fast and the reason
   is logged once instead of once per candidate. 1.21.1+NeoForge is one of the
   most common combinations in the catalogue, and each retry cost ~46s of
   container time.

2. The live install console moves out of the cache directory, which LoaderCache
   wipes when an install fails — deleting the console exactly when it is the only
   evidence of why. That was a defect in logging shipped an hour earlier.

3. BootVerifier logs *why* a boot was inconclusive, at the source, rather than
   leaving the log saying only `boot:INCONCLUSIVE`.

4. `LoaderSupportMemory`: learn from the abort. A (loader, Minecraft) combination
   whose console says the loader has no build for it is recorded and dropped from
   candidate selection, so the first mod to discover it spares every later mod
   the same wasted boot. Expires after 24h so upstream can catch up, and keys off
   a deliberately narrow marker — the Java/EULA/variables aborts say nothing
   about loader support and must not drop good combinations.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Deployed an hour ago; within minutes it had marked Fabric unusable for 22
Minecraft versions — 1.19.2, 1.20.x, 1.21.x through 26.2, which is every version
Fabric actually supports. Left running it would have deleted Fabric from the
sweep, trading wasted boots for a silent coverage hole: strictly worse.

The marker was wrong, not the data. default_template.sh:316 raises "Fabric is
not available for Minecraft X" when FABRIC_AVAILABLE != 200, and that variable
holds an HTTP status from a curl/wget probe — which cannot succeed inside the
grinder's `--network none` boot container. The message means "I could not check",
not "unsupported". 0.19.3 is a genuine current Fabric *loader* version, so SPC's
version plumbing is fine.

Removed: the selection gate, the recording, the class, its tests, and the
misleading BootLogClassifier.loaderUnavailable predicate. The finding is kept as
a landmine in serverpackcreator-clientside/CLAUDE.md so nobody re-derives the
same wrong conclusion from the same log line.

Fixes 1-3 from that commit stand and were verified firing in the live daemon:
the install-failure cooldown (NeoForge 21.1.247), install logs written beside the
pack rather than into the cache directory that gets wiped on failure, and
inconclusive boot reasons logged at the source.

The real issue this exposed is left open and documented: Fabric's offline path in
the pre-baked install layer is incomplete — Forge/NeoForge/Quilt reach the ready
line under `--network none`, Fabric aborts on the online probe. Fix the pre-bake;
do not suppress the symptom.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`setupFabric` decided the launcher from network probes first: it took the
improved-launcher branch only on HTTP 200 and otherwise crashed on
`FABRIC_AVAILABLE != "200"`. That negative form is satisfied by any request that
simply could not be made, so "no internet" was reported as "Fabric is not
available for Minecraft X". Quilt and LegacyFabric crash on the positive form
(`== "[]"`), which an unreachable network cannot produce — which is precisely why
they booted offline and Fabric never did.

Consequences observed in the grinder, whose mod-boots run `--network none` by
design: every Fabric boot aborted before the mod was loaded (103 boot
directories), and a mechanism that trusted the message concluded that 22
Minecraft versions were unsupported — 1.19.2 through 26.2, i.e. all of them.

All three templates now settle the launcher from disk first, returning
immediately when `fabric-server-launcher.jar` or `fabric-server-launch.jar` is
already present, and only probe the network when neither is. This is also the
right behaviour for any user with a complete pack and no connection.

The pre-baked install layer already contained the launcher jar, so nothing about
the cache needed to change. `ScriptTemplateContentTest` pins the ordering — disk
check before network probe — in all three templates, so a regression is caught
without fish or pwsh installed.

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>
Each attempt stages a full server pack with the overlaid loader
libraries under <work>/verify/boot/<slug>-<loader>, plus the downloaded
mod jars under <work>/verify/verify/<slug>-<loader>. Staging only ever
deleted a directory when that same (slug, loader) pair was attempted
again -- which during a catalog sweep is never, so the work tree grew
without bound: 98 GB across 1750 attempt directories, ~23 GB/h measured
live, enough to fill the host inside a day.

BootWorkspaceReaper strips a finished candidate's staging down to its
boot.log (the verdict detail is read from it; the packs are
reproducible), and GrinderApplication sweeps orphans at startup for
whatever a killed run left behind.

Two deliberate choices, both pinned by tests: the per-candidate reap
runs in a finally, because a thrown verification is exactly when staging
gets left behind; and matching cuts the -<loader> suffix rather than
prefix-matching the slug, because workers run in parallel and a prefix
match would delete jei-extras' pack out from under a running container.

First live startup reclaimed 8897 MiB (8.7 GB -> 155 MB).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Also documents a bug found while diagnosing, which is NOT yet fixed: SPC
resolves PathsConfig.homeDirectory through a machine-wide per-user
Preferences node shared by GUI, web backend, test suites and grinder, and
re-reads it on every access. A test suite starting up therefore relocates
a running daemon's home into its own scratch dir and then deletes it,
after which every boot fails on a missing server-icon.png and is recorded
as a metadata-only boot:none verdict -- indistinguishable in the report
from "this mod was never bootable". The preference wins over both cwd and
serverpackcreator.properties. Fixing it properly means making the node
name injectable, which touches published -api surface, so it is left as
an open question rather than decided unilaterally.

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>
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
classify() mapped any non-zero exit without a ready-line to CRASHED,
which aggregate() promotes to HIGH-confidence "this mod is clientside".
A container OOM-killed by the host exits 137 with no ready-line, and the
template's `Killed "$JAVA"` line deliberately does not match the
setup-abort markers -- so host memory pressure was published as a
clientside verdict, biased towards the largest mods.

Measured while sizing the catalog sweep: the grinder caps each boot at
3 GiB while Docker Desktop's VM held 1.93 GiB, so the cap cannot be
honoured and fat mods get killed by the VM rather than failing on their
own merits. Over a sweep whose entire deliverable is the
suspected-clientside list, that is a systematic false-positive source.

Exit 137 (SIGKILL) and 143 (SIGTERM), plus console evidence of memory
exhaustion, are now INCONCLUSIVE. SIGABRT (134) is deliberately still
CRASHED -- a fatal JVM abort is a real failure of the running server --
and the guard stays narrow: a test pins that a genuine
NoClassDefFoundError: net/minecraft/client/... still reads CRASHED, and
that a boot which reached the ready-line before being killed stays
SURVIVED.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The grinder had produced 517 verdicts with zero HIGH, while a kept boot log
contained a textbook `NoClassDefFoundError: net/minecraft/client/Minecraft`.
The decisive signal was being generated and discarded. Three causes:

1. The start scripts ended their run loop in an unconditional `exit 0`,
   throwing away the server's status, so the classifier saw 0 for every
   boot and returned INCONCLUSIVE. All three templates now capture and
   propagate it (useful to users' service wrappers too), pinned by a test
   that executes the extracted run loop.

2. The exit code is untrustworthy regardless: NeoForge's ServerStarterJar
   prints the crash in full and exits 0. A client-only-class failure is
   therefore decisive from the console alone, ahead of the exit-code
   logic -- but still subordinate to the timeout and killed/OOM guards, so
   host trouble cannot manufacture a HIGH. This produced the engine's
   first NeoForge=HIGH(boot:CRASHED).

3. Missing dependencies wasted boots and taught nothing. Quilt now falls
   back to a dependency's Fabric build (Quilt runs Fabric mods, which is
   why Fabric API -- Fabric-tagged only -- is the canonical Quilt
   dependency; 210 dropped deps, all but 44 on Quilt). Unstageable
   required dependencies are collected and logged rather than silently
   skipped, and staging now refuses to boot at all, with a named reason:
   a mod the loader rejects for missing deps never runs its own code.

The boot detail now records the exit status, without which an inconclusive
verdict cannot be diagnosed from the report.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Minutes after exit-status propagation started working, the sweep promoted
boots whose entire console was `Error: Unable to access jarfile forge.jar`
to HIGH-confidence clientside: the server never launched, but it exited
non-zero, and non-zero meant CRASHED. Ten of the first fifteen HIGH
verdicts were this class, including balm, collective and geckolib --
library mods that certainly do run on a server.

`launchFailureMarkers` (unable to access jarfile / could not find or load
main class / invalid or corrupt jarfile) now classify INCONCLUSIVE
alongside the other pre-launch aborts. Making the exit status trustworthy
is precisely what made this class visible.

The underlying fault -- 24 Forge boots that never start because the
template picks the legacy `forge.jar` branch while the install layer does
not provide it -- is recorded as B8 in claude-docs/BACKLOG.md.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Signed-off-by: Griefed <griefed@griefed.de>
The mapping fails silently: a wrong pattern does not throw, it produces
an empty or mis-assigned NeoForge version list, and everything downstream
behaves as though NeoForge has no builds for that Minecraft version. So
28a786b58's rule now gets tests, with the pattern-building `when` lifted
out of the parse loop (behaviour-preserving) so it can be reached at all.

Measured against the real manifest (1639 versions), the mapping 28a786b58
replaced was over-claiming badly: Minecraft 1.21 matched `^21.*` and so
swallowed every 21.x.y build -- 985 claimed where 165 are correct, 820
wrong attributions -- and 1.21.1 reached into 21.10.x/21.11.x, 348 versus
240. That is why the tests lean on adjacent-version rejection: 1.21 must
not claim 21.1.247, 1.21.1 must not claim 21.10.5.

Covered: both versioning schemes with real pairs (1.21.1 -> 21.1.247,
1.21 -> 21.0.167, 26.1.2 -> 26.1.2.93, 26.2 -> 26.2.0.40-beta), the
omitted-patch-becomes-zero rule, build suffixes, the bare Minecraft part
not being a NeoForge version, snapshots yielding no pattern, and two
limitations recorded so a future change is deliberate rather than silent:
the two-digit-major assumption of the new scheme, and large minors in the
classic one.

Also documents `toDotEscapedRegex` per the module's comment-everything
convention and restores the file's trailing newline.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Completes the mapping coverage started for NeoForge. The loaders answer
"which loader versions exist for this Minecraft version" in two different
ways, so they need two different kinds of test.

Forge (and NeoForge) encode Minecraft into their own version, so their
mapping is a parse. Forge's is string surgery -- cut
`minecraftVersion.length + 1` characters off a manifest entry -- which is
exact and exactly as silent when wrong. `forgeVersionFrom` is lifted out
so it can be reached (behaviour-preserving), and the tests pin the eras
Forge has shipped plus the invariant the cut secretly depends on: the
manifest writes `1.7.10_pre4` where Mojang writes `1.7.10-pre4`, and the
cut uses the reconciled version's length against the raw entry, so it is
only correct because swapping `_` for `-` preserves length. A test also
documents the fragile edge rather than hiding it: an entry not carrying
its Minecraft key cuts at the wrong offset and returns plausible garbage,
and a too-short entry throws where `update` catches only MalformedURL and
NoSuchElement.

Fabric, Quilt and LegacyFabric publish one Minecraft-independent loader
list, so their mapping is a gate -- `isMinecraftSupported`, answered from
Fabric's intermediaries or LegacyFabric's game manifest. That gate decides
whether a combination exists at all, and the clientside engine drops any
combination whose loader reports nothing, so a wrong answer silently
erases Minecraft versions from verification. Pinned as rules and permanent
anchors rather than "the newest version is X", so refreshing the cached
manifests cannot fail them spuriously: no loader claims a nonsense
version, Quilt tracks Fabric's intermediaries exactly, LegacyFabric covers
old Minecraft and does not claim modern releases, and both parsing loaders
report builds for established versions and nothing for nonsense.

19 mapping tests total across the three files. Runs offline.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Records the two failure modes found while pinning the mapping: a wrongly
prefixed entry returns plausible garbage silently, and a too-short one
throws StringIndexOutOfBoundsException, which update() does not catch --
so one malformed entry aborts the whole Forge load. Both are pinned in
ForgeVersionMappingTest, which will need updating alongside the fix.

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>
B9 records that boot deadlines are wall-clock, so a suspended host writes
off successful boots -- 19 of 153 verdicts overnight, some reading
SURVIVED (timed out) with the ready line seconds into the console.
Mitigated by running under caffeinate; only worth fixing properly if the
grinder ever runs somewhere that suspends.

B10-B12 record the three TODO markers that exist in SPC's sources, so they
are tracked somewhere other than a grep: the variables.txt string literal,
the Corepack workaround task, and the ForgeLoader length guard.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The test's doc comment referenced 'the TODO on forgeVersionFrom', and
IDEA's scanner matches the literal token anywhere in a comment, so a
cross-reference showed up as a fourth TODO item. Points at the hardening
note and B12 instead.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Running any suite rewrote src/test/resources/serverpackcreator.properties,
because 37 call sites across 28 test files handed ApiProperties the source
file itself -- and ApiProperties saves back to whatever file it was given.
That churn was swept into seven commits on this branch and carried real
content with it: the MongoDB URI silently lost its user:password@
component, and server.tomcat.basedir came to hold a machine-specific
build/ path.

Both files are restored to their develop content, and every call site now
reads build/resources/test/serverpackcreator.properties -- the copy
processTestResources already produces -- so writes land in build output
where they belong.

Verified by running all four suites and confirming the source files show
no unstaged change afterwards.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Audit L1. `newest!!` was provably safe -- shouldRecheckCrash returns false
for a null newest, and the early return above guarantees it -- but the
guarantee lived in another function, which is exactly the reading burden
the no-new-!! convention exists to avoid. An explicit `newest == null ||`
in the same condition smart-casts it away.

Behaviour-preserving: shouldRecheckCrash already returned false for null,
so the added check changes nothing. Existing assertions unchanged.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The audit's open item. `classify` is seven ordered guards whose
correctness rests entirely on that order, and they accreted one at a time
in reaction to live false positives -- so every constraint was covered
individually while the decision table as a unit was not. Two guards could
be swapped with every existing test still green.

Each case puts a higher-priority signal in the same console as a
lower-priority one and asserts the higher wins, which is the only shape
that makes a swap fail. Verified by moving the client-class guard above
the launch-failure guard: 2 tests fail, as intended.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Audit H2. The API compatibility policy covered source compatibility but
said nothing about behaviour: PathsConfig.homeDirectory now consults a
system property ahead of the stored preference, which changes what an
exported call returns for any embedder that sets it, while breaking no
signature. The policy now states that distinction and tables the two
changes this branch made.

REFACTOR-AUDIT.md gains a remediation table recording what was fixed
(M1, L1, the untested guard order), what was reviewed and left alone (L2),
what earlier commits already closed (M2, M5, M6) and what is not
remediable retroactively without rewriting landed history (H1, M3, M4).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The project already reserves <module>/tests for exactly this -- gitignored
bar its .gitkeep, and what server.tomcat.basedir has always pointed at --
so putting the isolated test home under build/spc-test-home was a
deviation from an existing convention, invented when the home override was
added.

Verified: all four suites green, generated files (README, CHANGELOG,
server_files, manifests, logs, configs) land in <module>/tests, no
build/spc-test-home remains, nothing new appears in $HOME, and the
checked-in test properties stay untouched.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Catalog-wide grinding, and the correctness work that had to happen before
its output could be trusted.

Grinder: a persisted crawl cursor with two-phase commit so coverage
accumulates instead of re-checking the top N; CurseForge crawled in
partitions (version x modloader x category) to get past its 10 000-result
API cap; round-robin interleaving so neither platform starves; work-driven
pacing; a loader-install cache bounded by last use and reused across
builds, guarded by a crash re-check; BootWorkspaceReaper, without which
staging grew ~23 GB/h (98 GB measured); live boot logs, a /status endpoint
and per-candidate log lines.

Correctness: the grinder had produced 517 verdicts and zero HIGH because
the decisive signal was being generated and discarded. Three causes, all
fixed -- the start scripts swallowed the server's exit status; the exit
status is untrustworthy anyway (NeoForge's ServerStarterJar reports a
crash and exits 0), so a client-only-class console hit now decides
CRASHED; and boots that never launched, were killed, ran out of memory or
lacked required dependencies are INCONCLUSIVE rather than false HIGHs.
Fabric now boots offline from an already-installed launcher, and Quilt
resolves Fabric-tagged dependencies (210 were being dropped silently).

API: hosts can pin their Preferences node and home directory, so a test
suite can no longer relocate a running daemon's home -- or a developer's
GUI installation.

Tests: 19 Minecraft-to-loader mapping tests where there were none,
covering both versioning schemes and adjacent-version bleed.

Audited against the refactoring conventions before merging
(REFACTOR-AUDIT.md): M1, L1 and the untested guard order remediated; H1,
M3 and M4 recorded as commit-hygiene findings not remediable without
rewriting landed history.
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>
The rule goes in serverpackcreator-api/CLAUDE.md as a landmine, with both
instances, the evidence (24 Forge boot logs that never started a server)
and the survey result that bounds it to the templates -- the Kotlin side
derives from metadata or compares component-wise.

B8 is deleted from the backlog rather than annotated: it is a queue, not a
ledger. The blow-by-blow goes to REFACTOR-LOG.md.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
SPC's default SSJ_FORGE_ARGS is -Djava.security.manager=allow
(PackConfig.spcSSJArgsKeyDefaultValue), which Forge's ServerStarterJar
needed on older Java. JEP 486 removed Security Manager support in Java 24,
so from that release the flag is not ignored -- the VM refuses to start:

  Error occurred during initialization of VM
  java.lang.Error: A command line option has attempted to allow or enable
  the Security Manager. Enabling a Security Manager is not supported.

Minecraft 26.x requires Java 25, so every modern Forge pack SPC generates
died before Forge loaded. This is user-facing, not grinder-specific:
anyone running a current Forge pack on Java 24+ hits it. NeoForge, Fabric
and Quilt never pass the flag, which is why only Forge was affected.

All three templates now pass it only when the detected Java is below 24,
guarded against a non-numeric JAVA_VERSION (SKIP_JAVA_CHECK leaves it as a
placeholder). The default value is deliberately unchanged -- older Java
still needs it.

Found by the launcher-era fix: with the era corrected, Forge reached the
ServerStarterJar path and this became the next failure. Verified
end-to-end -- balm on Minecraft 26.1.2 now reports
Forge=LOW(boot:SURVIVED), the first successful Forge boot on current
Minecraft, with server.jar and libraries in the install cache.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
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 B6. Modrinth serves deep offsets but clamps at 99 999, answering
with zero hits past it rather than an error -- which `page` cannot tell
apart from an exhausted catalog. At ~71 000 mod projects there is headroom
today, so the failure is not live; it is that if the catalog ever outgrows
the ceiling, the crawl wraps early and reports itself complete, losing the
tail with nothing in the log to say so.

Two warnings now mark the region: one when the next page would reach the
ceiling, and one when an empty page arrives at or past it, spelling out
that "end of catalog" may be the clamp. Behaviour is unchanged -- this is
diagnosis, not a new policy, because the source genuinely cannot tell the
two cases apart and guessing either way would be worse.

The boundary itself is a pure decision (approachingOffsetCeiling,
ceilingMayMasqueradeAsEnd) so it is tested without an HTTP fetcher, plus a
test asserting today's catalog still fits below the ceiling -- if that ever
inverts, the crawl needs facet-partitioning like CurseForge has.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Backlog B9. The boot deadline is wall-clock, so a host that suspends
mid-boot spends the budget on a frozen container and the run is written off
as a timeout even though the server never got the time. Measured
2026-07-31: a laptop idle-sleeping in ~16-minute cycles produced 19 of 153
verdicts reading `timed out`, several of them `SURVIVED (timed out)` --
their consoles show the server reaching its ready line seconds after
launch, and the wake times in `pmset -g log` line up with the grinder's log
gaps to the second.

The wait loop now measures the gap between polls and adds a suspended
interval back to the deadline, so a timeout means "the boot had this long
and did not make it" rather than "this much clock passed". It also warns,
pointing at `caffeinate -ims`, since a boot interrupted this way learns
nothing either way.

Detection is deliberately conservative -- a gap must exceed a minute (and
30x the poll interval) to count -- because under-detecting merely preserves
the old behaviour, while over-detecting would hand a genuinely slow boot
budget it should not get. The threshold is a pure decision so it is tested
without a Docker daemon; the surrounding engine is integration-only.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Backlog B2. A loader version can be listed by its maven metadata while its
installer artifact is simply absent: NeoForge 21.1.247 is in the version
index but neoforge-21.1.247-installer.jar 404s (measured 2026-07-30), and
1.21.1 + NeoForge is among the most common combinations in the catalogue.
Every candidate wanting it paid a full download-and-boot before failing and
took an INCONCLUSIVE verdict -- for a build nobody can install.

With nothing cached, the version policy now prefers the newest build the
cache is not already refusing, stepping down to an older one when the
newest is on install cooldown. LoaderCache gains isInstallOnCooldown so the
policy can ask before choosing rather than each candidate discovering it the
expensive way.

The contract that makes reuse safe is untouched: latestVersion still
delegates, so the support gate and the crash re-check measure against the
real newest. A crash on a stepped-down build is re-checked against that
newest exactly as a cached build's crash is, and if the newest is the
uninstallable one the re-check returns INCONCLUSIVE, which by design leaves
the crash standing rather than clearing it.

Scoped to Forge and NeoForge, the only loaders publishing per-Minecraft
builds where one can be absent while a sibling works. Fabric, Quilt and
LegacyFabric supply no version list and keep their previous behaviour,
pinned by a test.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Phase 2's four completed items are removed from the queue and recorded in
REFACTOR-LOG.md.

Also repairs a mistake: the commit closing B8 (64d2d70e5) truncated
BACKLOG.md from B8's heading to end-of-file, which took B9, B10, B11 and
B12 with it. B10 (variables.txt as a string literal) and B11 (the Corepack
workaround) were still open and are restored from history. B9 and B12 are
closed by this phase, so they stay removed deliberately rather than by
accident.

B5 stays open on purpose -- dedup by project identity needs a project id
threaded through the models and the store key plus a migration for existing
verdicts.json files, which wants its own pass.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The homeDirectory getter persists whatever it resolves, so the override
added in dd4fcc935 was writing itself into the preference -- meaning a
temporary `-D`, which the build sets for every test JVM, quietly replaced
the user's durable home directory, and every later read (including from
another process sharing the node) inherited it.

The override is now honoured for the process and returned directly, without
touching the preference. Nothing else about resolution changes.

Found while consolidating the app's preference call-sites: the `--home`
test began failing because CommandlineParser stored a home and the next
ApiProperties read overwrote it with the build's override. Pinned by a test
asserting the durable value survives an override and resolves again once it
is gone -- verified to fail when the persistence is reinstated.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Backlog B1. Four call-sites hard-coded both the Preferences node name and
the home key, while -api resolves the node through
ApiProperties.resolvePreferencesNode -- so a host claiming its own node (the
grinder daemon, every test JVM) had the app writing a home that -api would
never read back. That is the mismatch class that once let a test suite
relocate a running daemon's home.

HomeDirectoryPreference now owns the node and the key; CommandlineParser,
ServerPackCreator (x2) and HomeDirCommand go through it, and their unused
Preferences imports are gone. The node is resolved per call rather than
captured, pinned by a test, so a host that claims its own node genuinely
gets its own home.

GuiProps deliberately stays on the default node, with the reasoning
recorded at the call-site: window geometry belongs to the installation a
user sees, not to whichever process resolved a home, and routing it through
the resolver would reset every existing user's saved layout. It needs a
migration, not a rename, if that ever changes.

CommandlineParserTest now reads through the same resolver instead of a
literal node -- asserting against the hard-coded name would only pass while
the default happened to be in play.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Backlog B10. The whole variables.txt body -- 91 lines of comments an
operator reads to configure a pack -- was a Kotlin string literal, so
correcting a sentence meant editing and recompiling the API. It now sits in
server_files beside the start-script templates, which are the same kind of
thing: shipped content the user may adjust.

Verified as a faithful move: the extracted resource is byte-identical to
what the literal produced through trimIndent (91 lines, 5912 chars).

Existing homes are left alone -- ApiWrapper.setup creates the file when
absent (checkServerFilesFile) rather than overwriting it like the script
templates, so an operator's edited wording survives an upgrade; generation
only needs the placeholders, which an edit keeps. The delete-watcher in
ServerPackCreator restores it when removed, alongside the template
branches, so what the operator edits and what generation reads stay the
same file.

Reading a file introduces one risk the literal did not have, so it is
guarded: a missing or unreadable template falls back to the copy bundled in
the jar rather than producing a pack with no variables.txt. Tests pin the
fallback, that the file on disk is what generation reads, and that every
placeholder generation substitutes is present.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Backlog B7. SPC_GRINDER_WORKERS is the biggest lever on how long a full
sweep takes, and the README said only "budget ~3 GB RAM each", leaving the
operator to infer the rest. §5 now gives the rule -- workers ~ (memory
available to Docker - overhead) / 3 GiB -- with concrete figures for a
dedicated box (~20), a workstation (4) and a laptop on Docker Desktop's
default (1).

Two things worth an operator's attention are called out. The limit is the
memory assigned to *Docker*, not the host's total: measured on a 48 GB
laptop whose Docker VM held 1.93 GiB, less than a single boot's cap. And
over-subscribing wastes boots rather than corrupting results -- an
OOM-killed boot is scored INCONCLUSIVE -- so the failure is slow, not
wrong. Also notes keeping the host awake, since a suspended machine is
simply not grinding.

The sizing formula divides by ContainerResources.memoryBytes, so
ReadmeConfigurationTest now checks the quoted figure against the real
default: change the cap and the advice would otherwise start
over-subscribing silently. Verified by doubling the cap and watching the
test fail.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
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>
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The 4f entry was inserted ahead of the paragraph closing 4e, leaving it
trailing under the wrong bullet.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Phase 6, closing audit finding M-A. Two lines in the root CLAUDE.md's
Refactor discipline:

- Shell templates, manifests and version parsing get their test written and
  observed failing first. The rule is not different there; the failure mode
  is -- they produce a plausible value rather than an error, so they get
  verified by hand and pinned afterwards, if at all. Three instances across
  two audits, and a measured cost: 24 wasted boots, 820 mis-attributed
  NeoForge versions.
- A test that only asserts shape is not a pin. Prefer executing the unit,
  and confirm the test fails before the fix -- a teeth-check silently passed
  twice in this session because a mis-indented edit left the "broken" run
  unmodified.

Also backlogs six items found while executing the plan but outside its
phases: B13 the fish/.ps1 template changes unverified by execution (the
gated matrix IT has not run since), B14 HostProcessServerRunner sharing the
wall-clock deadline B9 fixed only for containers, B15 the loader-cache
marker not recording which template produced an install, B16 the checked-in
test properties still carrying machine-specific absolute paths, B17
.gitignore hiding new server_files resources, B18 an install console wiped
by the next attempt on that tuple.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Ran ScriptTemplateMatrixIT over 1.16.1/1.20.1/26.2 x Forge/NeoForge/Fabric x
bash/fish at one worker. 16 of 18 boot cells reached the vanilla ready-line and
bash == fish in every cell, so the fish port of Phase 1's three template changes
is verified by execution rather than by analogy. Both PowerShell tests pass, and
the Java-24+ security-manager guard is now confirmed on a real Java 25 boot: the
variable is echoed while the emitted run command omits the flag.

Two findings, both new entries rather than fixes, since a shipped-template change
needs its failing pin written first:

- B19: on a fresh pack, Forge on 26.x installs and exits 0 without ever launching.
  Isolated to Forge-on-26.x by comparison against Forge 1.20.1 and NeoForge 26.2,
  which both install and boot in one invocation. The sweep never saw it because it
  pre-bakes the install and boots from cache; a user running start.sh hits it.
- B20: a missing per-version Minecraft manifest is swallowed into Optional.empty()
  and surfaces as a benign "[N/A] SKIPPED" cell. It silently skipped all four 26.2
  cells on the first run - the newest Minecraft, and the exact branch Phase 1 fixed.

Also records that the matrix's default dimensions contain no 26.x version at all.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Audits bc2bfe7fa..HEAD -- the 21 commits of the 2026-07-31 plan -- for the
first time, and carries the previous report's findings forward with updated
status rather than re-arguing them.

New: 1 HIGH, 2 MEDIUM, 1 LOW. The segment is the best-disciplined in the range
(no new !!, no stray debug, a documented invariant respected under pressure),
and every new finding is about labelling or recording a change rather than a
change being wrong:

- H-B  7815d5960 alters what an exported call returns (generation now reads an
       operator-editable variables.txt) without the compatibility-table row the
       project's own policy requires for exactly that case.
- M-B  all 8 code commits bundle their test with the production change, so the
       history cannot show any pin failing.
- M-C  two behaviour changes are labelled refactor:, and in one the rule's own
       signal fired -- an existing test had to change.
- L-C  a new exported var that nothing assigns, where its siblings are val.

M-A is closed by b28d131ea. H-A is unchanged and now 102 commits deep.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Closes audit findings H-B, M-B and M-C.

H-B: 7815d5960 changed what an exported call returns -- generation reads an
operator-editable server_files/variables.txt instead of a compiled-in literal --
and added two exported members, without the compatibility-table row this
project's own policy demands for precisely that case. A default install still
gets byte-identical output, but the value stopped being a constant, so an
operator editing that file now changes every embedder's generation.

M-B: pin-first now names the commit boundary. The failing test lands red in its
own commit, then the fix. All eight code commits of the 2026-07-31 plan bundled
guard and change, so no revision exists where the pin can be observed failing --
and in-session verification is not a substitute, having silently passed twice
that same session when a mis-indented edit left the "broken" run unmodified.

M-C: refactor: is a claim about behaviour. A changed *existing* test means the
label is already wrong. Cites the two commits that got it wrong, both of which
described their behaviour change honestly in the body -- only the type lied.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
L-C claimed PathsConfig.defaultVariablesTemplate was gratuitous exported
mutability. Inspecting the declaration before changing it shows the finding was
wrong on both counts: the setter is private, so val would not narrow the public
surface, and the var is load-bearing -- the getter assigns the backing field so
the path re-derives per access and therefore follows a home directory that
changed. 31 properties in that file use the same pattern, including
serverFilesDirectory itself.

The audit had compared against the two plain vals at :586 and :603, which turn
out to be the exception rather than the standard -- and to carry the actual
defect: eight template properties are evaluated once at construction, so they
keep pointing into the old home after a home change, which this branch made
re-resolve on every access. Recorded as B21 rather than fixed here; it predates
this range, spans eight properties, and wants its own pin.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Backlog B19. RED on purpose -- this commit adds the failing guard, the fix
follows in the next one.

ServerStarterJar runs the Forge installer in its own JVM and depends on a
SecurityManager (SecurityAccess.wrapNoForceExit) to swallow the System.exit(0)
the installer calls when it finishes. JEP 486 removed Security Manager support
in Java 24, and SSJ catches the resulting UnsupportedOperationException
silently, so from Java 24 on the installer's exit takes the whole process with
it.

Measured on Minecraft 26.2 / Java 25: a fresh pack installs, prints "The server
installed successfully", exits 0, and never launches -- no world, no ready-line.
The same pack booted a second time reaches "Done (7.287s)! For help", because
the install is already there and SSJ only has to launch. Exit code 0 is what
makes it dangerous: nothing downstream distinguishes it from a clean shutdown,
and the grinder never sees it because it pre-bakes the install and boots from
cache. Only a user starting a fresh Forge pack hits it, and to them the server
just does nothing.

The guard executes setupForge across Java 17/21/24/25 and asserts that below 24
the install stays with SSJ (that path works and must not change), while from 24
on the template installs Forge itself and launches through the argfile the
installer produces.

Observed failing: "on Java 24 SSJ cannot trap the Forge installer's
System.exit ... expected: <false> but was: <true>".

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Backlog B19; turns the previous commit's guard green.

ServerStarterJar runs the Forge installer in its own JVM and relies on a
SecurityManager to swallow the System.exit(0) the installer calls on success.
JEP 486 removed Security Manager support in Java 24, and SSJ catches the
resulting UnsupportedOperationException silently, so the installer's exit takes
the process with it: the pack installs, prints "The server installed
successfully", exits 0, and never launches. Nothing downstream can tell that
from a clean shutdown.

From Java 24 on, the templates therefore stop handing SSJ the install. They
download and run the Forge installer themselves and launch from the argfile it
produces -- unix_args.txt for sh/fish, win_args.txt for ps1 -- which is the same
mechanism the USE_SSJ=false path already used, and the path proven to boot when
the earlier failing pack was started a second time. Below Java 24 the SSJ branch
is untouched, including the flag, because that combination works.

This is the second half of c571e2d7f: dropping the fatal flag stopped the VM
refusing to start, and revealed that the flag was load-bearing for SSJ's install
step rather than cosmetic.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The JEP 486 landmine in the api module now carries its second half: the
security-manager flag is load-bearing rather than cosmetic, because SSJ runs the
Forge installer in its own JVM and needs a SecurityManager to swallow that
installer's System.exit(0). On Java 24+ it cannot, catches the failure silently,
and a fresh Forge pack installs and exits 0 without launching -- which exit code
0 makes indistinguishable from a clean shutdown downstream.

Also records why the grinder structurally cannot catch this class: it pre-bakes
the install and boots offline from cache, so it only ever exercises the launch of
an already-installed tuple. And that cached tuples survive the change, since the
argfile the new path launches is what the installer already produced.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Verified by elimination rather than assumed: the pair appears seconds after the
daemon starts and stays gone while it is stopped, its working directory was
confirmed to be the grinder home via lsof, and an explicit
SPC_GRINDER_SPC_PROPERTIES makes it read from the home while still writing into
the repo root.

The test suite is explicitly ruled out, which is the natural first suspicion
because it was the cause of this class of pollution until earlier today: a full
module test run now leaves the repository root untouched, since the java
conventions pin every module's test home to <module>/tests. Only the daemon
reproduces it, and only at startup.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Backlog B20. RED on purpose; the fix follows. For new API in a statically typed
language the red state is a compilation failure, and this one is: "Unresolved
reference 'ImageSupport'" / "Unresolved reference 'supportFor'".

Both states are equally unbootable, which is why supports() rightly collapses
them to false -- but they mean opposite things. "This image lacks Java 25" is a
legitimate permanent exclusion; "I could not determine what this version needs"
is a metadata failure that silently removes coverage, and
MinecraftServer.javaVersion() turns any exception, a failed download included,
into exactly that same empty answer.

Not hypothetical: ScriptTemplateMatrixIT reported all four Minecraft 26.2 cells
as a benign "[N/A] SKIPPED" -- Fabric among them, which the live sweep boots
fine -- while still reporting green. The newest Minecraft, and the precise branch
the Forge template fixes were written for, went untested behind a message that
reads like a deliberate exclusion. Today's B19 proof needed the metadata seeded
by hand to get those cells to run at all.

The test also asserts supports() keeps its existing contract, so no caller
changes meaning.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Backlog B20; turns the previous commit's guard green.

ImageJavaRuntimes gains supportFor() -> ImageSupport, separating SUPPORTED,
JDK_NOT_BUNDLED and REQUIREMENT_UNKNOWN. supports() is now a facade over it and
keeps its exact contract, so no existing caller changes meaning.

ScriptTemplateMatrixIT uses the distinction: a loader with no build, or a version
whose Java the image genuinely lacks, is still reported N/A with the accurate
reason, while a version with no *known* Java requirement now fails the cell and
says so -- that its coverage is missing, that the cause is an absent per-version
manifest, and what to do about it. A metadata gap removes coverage from the
newest Minecraft versions first, which are the ones worth testing.

MinecraftServer.javaVersion() and url() keep returning Optional.empty() -- the
contract is exported -- but now log why. That swallow was the origin: any
exception, a failed manifest download included, became indistinguishable from
"this version declares no required Java", which the matrix rendered as a benign
"[N/A] SKIPPED".

Also puts 26.2 in the IT's default Minecraft axis, replacing 1.21.1 (1.21.11
already covers that line). The defaults contained only 1.x versions while three
template bugs lived in YY.x handling, so the branch those fixes were written for
was never exercised by default.

Guard verified by breaking it: mapping an unknown requirement back onto
JDK_NOT_BUNDLED fails the test, restoring passes.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Backlog B14. RED on purpose -- "Unresolved reference 'SuspendAwareDeadline'" --
with the implementation following.

A boot deadline measured on the wall clock expires on a server that never got the
time. Measured 2026-07-31: a laptop idle-sleeping in ~16-minute cycles produced
19 of 153 verdicts reading "timed out", several of them "SURVIVED (timed out)"
whose console showed the server reaching ready seconds after launch -- verdicts
about the host's sleep rather than about the mod.

The grinder's container engine was fixed for this in 1f92f585c; the host-process
runner that the app's -verifyclientside verb uses has the identical wall-clock
loop and was not. Rather than copy the logic, these tests pin it as one unit with
an injected clock -- necessary because both real callers are integration-shaped
and cannot be made to sleep for a test.

Four cases: ordinary polling still exhausts the budget (or nothing would ever
time out), a suspended interval is added back and reported once, gaps below the
floor are slowness rather than sleep, and the threshold scales when the poll
interval itself is unusually long.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Turns the previous commit's guard green. Behaviour-preserving for the grinder --
the container engine's loop is the same arithmetic, now delegated.

The suspend detection added in 1f92f585c lived in DockerJavaContainerEngine's
companion, where the clientside host-process runner could not reach it: grinder
depends on clientside, not the other way round. SuspendAwareDeadline therefore
lands in -clientside, so dependencies keep pointing inward, and the container
engine delegates to it.

It takes an injected clock, which is what makes the threshold testable at all --
both real callers are integration-shaped and cannot be made to sleep for a test.
The grinder's existing SuspendGapTest is repointed at the new home; the
assertions are unchanged, only the symbol moved.

The host-process runner adopts it in the next commit, which is a behaviour change
and so kept separate.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Backlog B14, closing the half of B9 that was left behind.

HostProcessServerRunner measured its deadline on the wall clock, so a host
suspend mid-boot expired the timeout on a boot that never got the time. The
grinder's container engine was fixed for this in 1f92f585c; this is the path the
app's -verifyclientside verb uses, and it had the identical loop.

It now polls through SuspendAwareDeadline, handing any suspended interval back to
the budget and warning once per suspend with the same operator guidance. The
arithmetic itself is pinned by SuspendAwareDeadlineTest against an injected
clock; what changes here is which loop uses it.

The one remaining System.currentTimeMillis deadline in the tree is inside a gated
integration test, not a boot path.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Backlog B18. RED on purpose -- "Unresolved reference 'InstallLogRetention'" --
with the implementation following.

DockerLoaderInstaller deliberately writes the live install console beside the
generated pack instead of into the cache directory, because LoaderCache wipes the
cache on failure and would delete the log exactly when it is the only evidence of
why. But the pack's own tuple directory is wiped wholesale at the start of the
next attempt, so the failing console vanished precisely when a retry made you
want to compare the two.

Three cases: a previous console is carried across the wipe, a first attempt
leaves no empty .previous file that would read like a lost log, and only one
generation is kept so the tuple directory does not accumulate stale consoles.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Backlog B18; turns the previous commit's guard green.

ApiVanillaPackGenerator.generate wipes the tuple directory before regenerating,
which took install.log with it -- the console of the attempt a retry is meant to
be compared against. It now carries that console across the wipe as
install.log.previous.

The retention is a separate object because the generator needs a live ApiWrapper
and is integration-only, whereas this is plain file handling worth pinning: one
generation only, and nothing written when there was no console, since an empty
.previous would imply one had been captured and lost. Both reads and writes are
wrapped -- failing to keep a diagnostic must never fail an install.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Backlog B15. RED on purpose -- "Unresolved reference 'TemplateProvenance'" and no
templateProvenance parameter -- with the implementation following.

The install boot runs the pack's own start.sh, so a cached tuple is a product of
the start-script templates in force when it was made. The completion marker
recorded only loader/version/Minecraft, so ensureInstalled served a stale layer
regardless -- which cost a hand-invalidation of two Forge tuples during the
launcher-era fix, with nothing warning it was needed.

Deliberately not retroactive, and today's Forge work is why: a marker written
before provenance existed carries none and is tolerated rather than invalidated,
because treating absent as mismatched would re-install all 74 cached tuples (~150
MB and a networked boot each) to answer a question that may not apply to them.
When B19's fix landed I checked those tuples instead of invalidating them, and
every one was still bootable -- the argfile the new launch path uses is what the
installer had already produced.

Six cases: same provenance hits, different provenance misses, an absent
provenance is tolerated, no supplier behaves exactly as before, the digest tracks
content and is order-independent, and unreadable templates yield no digest so a
broken home degrades to current behaviour instead of thrashing the cache.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Backlog B15; turns the previous commit's guard green.

The completion marker now carries a SHA-256 over the start-script templates the
install was produced with, and isInstalled treats a recorded-but-different
provenance as a miss. That removes the manual step the launcher-era fix needed,
where a stale cached layer was served with nothing warning it was stale.

An absent provenance stays trusted, with one warning per run rather than per
tuple. Treating unknown as different would reinstall all 74 cached tuples, ~150 MB
and a networked boot each, and today's Forge change shows why that is the wrong
default: its cached tuples were checked and every one was still bootable.

The digest is order-independent and mixes each template's name with its bytes, so
swapping two templates' contents still registers. Unreadable templates yield null,
which callers must read as "unknown" rather than "changed" -- otherwise a broken
home would thrash the whole cache. GrinderApplication computes it per call, since
SPC resolves templates from the then-current home and the daemon's home can be
re-resolved while it runs.

Guard verified by inverting the comparison: 2 of 6 tests fail, restoring passes.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Backlog B17. The bare `server_files` rule matches that directory name at any
depth, so the templates SPC ships under src/main/resources were tracked only
because they predate the rule. Anything new there was invisible: variables.txt
needed `git add -f` when it landed, and today's template fix drew the same
"paths are ignored" warning -- a packaging bug whose symptom is a file missing
from a release.

Re-included with the same idiom the file already uses for `configs`, rather than
anchoring the rule to the repository root: each module's test home is
`<module>/tests`, so `<module>/tests/server_files` must stay ignored, and root
anchoring would have exposed those generated copies instead.

Verified with `git check-ignore -v --no-index` -- the --no-index matters, since
check-ignore otherwise suppresses any path containing tracked files and reports a
still-ignored directory as clean. Without the change a new file under the shipped
directory is hidden and `git add` warns; with it, the directory is attributed to
the re-include while both test homes and a root-level server_files stay ignored.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Backlog B16, the half that M1 left. RED on purpose -- the committed-paths guard
fails today -- with the build change following.

38 test call-sites boot an ApiWrapper from
build/resources/test/serverpackcreator.properties, whose committed source carries
one developer's absolute paths: an SDKMAN JDK under their home directory and an
absolute server.tomcat.basedir. Harmless while only that machine runs the suite,
meaningless anywhere else, and exactly the sort of value that survives for years
because nothing fails visibly when it is wrong.

Three guards, split by obligation: the committed file must name no host, and the
generated file must resolve a JDK that actually exists here and this module's own
reserved tests directory -- the same isolated home the conventions plugin already
injects. The latter two pass today only because this is the machine the paths came
from; they become load-bearing as soon as the committed values are blanked.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Backlog B16; turns the previous commit's guard green and closes the half of the
audit's M1 that remained.

The suite reads build/resources/test/serverpackcreator.properties, and two of its
values are inherently per-machine: the JDK path SPC writes into generated packs,
and server.tomcat.basedir. Committing resolved values committed one developer's
filesystem to three modules. The committed files now leave both blank and
processTestResources fills them in -- the JDK from the configured Java 21
toolchain rather than whichever JVM happens to run the build, and the basedir from
the module's own tests directory, the same isolated home the plugin already
injects for the preferences node.

Both are declared as task inputs, so changing the toolchain or moving a module
re-runs the copy instead of serving a stale one, and both are escaped for
properties syntax: backslashes and colons are separators there, so an unescaped
Windows path would be read back mangled.

api, clientside, grinder and app suites green.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Removes the entries this branch closed from the backlog (B13-B20), leaving only
the deliberate deferrals B4, B5 and B11 alongside the two findings this work
turned up, B21 and B22. Cut each entry's own section only -- a previous truncation
here silently removed B9 through B12.

Marks H-B, M-B and M-C remediated in the audit report and L-C withdrawn, and
records that H-A is now the single open item and the one that cannot be done
locally.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
grinder: two new cross-cutting landmines -- a cached install is a product of the
templates that built it (differing provenance misses, absent is tolerated, and
check before invalidating, since the Forge fix's cached tuples turned out fine);
and "not applicable" must never mean "we could not find out", which is how all
four Minecraft 26.2 cells left a green matrix run.

clientside: where the shared suspend-aware boot deadline lives and why it is here
rather than in the grinder (dependency direction), plus the landmine that any new
boot-bounding loop must use it instead of System.currentTimeMillis.

root: refreshed suite counts (api 272, clientside 87, grinder 224, app 76) and a
current-state paragraph for this branch.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The entry named 1.1.1.1 and 8.8.8.8, which both misstates the machine's actual
configuration (Griefed replaced them with OpenDNS + Quad9) and records as advice a
choice he rejected: a resolver sees every hostname looked up, so its operator's
business model is the point, not its uptime.

Restated as the requirement -- a reachable, non-loopback resolver the operator
trusts -- with the underlying condition kept, since that is the durable part: the
host resolves via 127.0.0.1, which Docker's VM forwarder cannot reach, and a
restart only appears to fix it. Also records the consequence worth recognising,
that broken container DNS stops the pre-bake installing new tuples while cached
ones keep booting, so it presents as a quiet sweep rather than an error.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Audit finding M-4. As first written the rule said a changed *existing* test means
a refactor: label is already wrong. That over-fires: moving a symbol between
modules necessarily updates imports and receivers in its tests, and Strangler-Fig
moves are precisely what the conventions ask for.

Its own counter-example is b6b778b82, which the rule flagged as mislabelled while
being a clean cross-module move -- the whole test diff was the receiver symbol
changing, every assertion byte-identical, and the production change verified line
by line as behaviour-preserving.

The signal is now scoped to a changed assertion, argument or expected value, with
the reference-only carve-out stated and the reasoning attached: a convention that
cries wolf on legitimate refactors gets ignored wholesale, which would cost the
rule its whole value.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Audit finding M-2 -- a defect this branch introduced one commit after closing H-B,
which was the same class: embedder-visible behaviour changed on an exported path.

Making the swallowed metadata failure visible was right; doing it at warn with a
full stack trace was not. setServerJson() does not remember a failed fetch, and
getServer() calls both url() and javaVersion(), so one requiredJavaVersion lookup
on a broken version emitted two warn-plus-stacktrace pairs -- on a path that runs
per candidate in the grinder, per cell in the template matrix, and on every GUI
version selection. That is a log flood, and at debug with the exception type in the
message the diagnosis survives without it.

Dropping to debug also settles the untabled part of the finding: debug logging is
not an embedder-visible contract change, so it needs no compatibility-table row.

The retry itself predates this branch and is deliberately left alone rather than
fixed in scope -- recorded as B23, together with the reason it is not a same-day
fix: remembering the failure wants a pin, and pinning it wants a download seam
MinecraftServer does not have.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Audit finding M-3. The .gitignore fix in 8f1b3a76f was verified by hand but
nothing pinned it, and the regression is silent in exactly the way the original
was: re-broaden the rule, a shipped resource stops being tracked, and the only
symptom is a file missing from a release. ReadmeConfigurationTest is the
precedent for pinning config this way.

Both directions are asserted because they pull against each other: the shipped
resources under src/main/resources must not be ignored, and each module's
generated tests/server_files must stay ignored -- which is why the fix re-includes
the resource path instead of anchoring the rule to the repository root.

Two things this needed to be a real guard rather than a decoration:

- --no-index on every check-ignore call. Without it git skips paths containing
  tracked files and reports a still-ignored directory as clean, which is why the
  first reading of this finding was wrong.
- .gitignore declared as an input of the test task. Without that the task reports
  UP-TO-DATE after a .gitignore change and never runs -- and that is precisely how
  this guard's own first teeth-check appeared to pass while asserting nothing.

Teeth verified after declaring the input: removing the re-include fails 1 of 2,
restoring it passes.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Found while pinning M-3 -- the api build file's test block turned out to clear the
shared node at configuration time, which is the open question grinder/CLAUDE.md
recorded: whether anything still writes the machine-wide ServerPackCreator node
during a build. It does, in three places.

java-conventions' cleanup() called removeNode() on the shared node and wrote the
module's tests directory in as the home; the -api and -app build files each
clear()ed and sync()ed it at configuration time. cleanup() runs in doFirst of both
test and clean, for every module, so any build relocated the home of the
developer's own GUI -- and of any running daemon -- into the repository. The
shared node on this machine currently holds
serverpackcreator-api/tests, written by exactly this mechanism.

All three writes are removed. Nothing needs them: the per-module preferences node
and the injected -Dde.griefed.serverpackcreator.home already isolate test runs,
and SPC prefers that property over the stored preference. No test reads the shared
node's contents -- PreferencesNodeTest only exercises resolvePreferencesNode's
string logic. All four suites green.

A stale value may still be stored from before this fix; clearing it is the
operator's call, not the build's.

Also records B24: the same cleanup() wipes <module>/tests before every run, which
is what kept deleting the seeded per-version Minecraft metadata during the B19
work, and which sits awkwardly against the documented claim that the api suite
needs no network.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Audit findings L-1 and L-3. Behaviour-preserving: all four suites green with the
existing assertions.

L-1: escapeForProperties was a top-level fun in a precompiled script plugin, so
its name entered the scope of every build script applying the convention. Now
private, and moved up beside its only caller instead of sitting after the signing
block.

L-3: TestPropertiesTest resolved both properties files relative to the working
directory. Correct under Gradle, whose test working directory is the project
directory, but an IDE run configuration starting from the repository root would
fail the test on a missing path rather than on the property it checks. Both paths
now derive from the isolated test home the build already injects.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
M-2 (bb6115ade), M-3 (c8a8f7e66), M-4 (4767786c9), L-1 and L-3 (8693ff3c6) are
closed; M-1 and L-2 recorded as historical, not remediable without rewriting
landed history.

Also notes that fixing M-3 uncovered the shared-Preferences-node writes in three
build scripts (4f53aa889), which answers a question grinder/CLAUDE.md had left
open and explains the metadata that kept vanishing during the B19 work.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Closes the third audit's actionable findings and empties the backlog down to the
deliberate deferrals, then closes the fourth audit's findings on its own work.

Shipped defect fixed: a fresh Forge pack on Minecraft 26.x installed and exited 0
without ever launching. ServerStarterJar runs the Forge installer in its own JVM
and needs a SecurityManager to swallow that installer's System.exit(0), which JEP
486 removed in Java 24 -- SSJ catches the failure silently, so the exit took the
process with it. From Java 24 the templates install Forge themselves and launch
from the installer's argfile; below 24 the SSJ path is untouched. Verified
fresh-pack, first-invocation on 26.2 in bash and fish, with Forge 1.20.1 and
NeoForge 26.2 as regression controls.

Also: the "not applicable" skip that hid that defect now fails loudly and
distinguishes an unknown Java requirement from an unbundled JDK; both boot paths
share one suspend-aware deadline in -clientside; the loader cache records which
templates produced an install; test properties are generated instead of committed
with one machine's paths; .gitignore no longer hides shipped server_files
resources, now pinned; and three build scripts no longer rewrite the shared
Preferences node, which had been relocating the developer's own GUI home into the
repository on every test or clean.

Every code change landed as a red test commit followed by its fix, with the
observed failure recorded in the message. One audit finding was withdrawn as wrong
on inspection rather than defended.

Suites: api 272 (1 skip), clientside 87, grinder 224 (19 skip), app 76 -- green.
Griefed's call. LarsonScanner stays as it is: a self-contained Swing widget with
no dependants beyond the GUI and no known defects, assessed twice and judged not
worth splitting. Keeping a permanently-declined item in a queue only trains
readers to skim past it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Backlog B21. RED on purpose -- "defaultShellScriptTemplate still points into the
old home" -- with the fix following.

homeDirectory is re-read on every access and serverFilesDirectory re-derives from
it, so a home change at runtime (--home, the -D override, the GUI settings panel)
moves everything SPC reads and writes. The eight shipped script-template paths do
not follow: they are plain vals evaluated once at construction. They feed
defaultStartScriptTemplates() and defaultJavaScriptTemplates(), so a stale one
means generation reads a template out of a directory the user has left behind --
their edits appear to do nothing, and nothing reports an error.

The existing defaultScriptTemplatesResideInServerFilesDirectory cannot catch it,
because it builds the config after setting the home, so a captured-once value
still looks correct. This test changes the home underneath a live instance and
asserts all eight follow, with serverFilesDirectory itself as the control.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Backlog B21; turns the previous commit's guard green.

The eight script-template properties were plain vals evaluated once, so they kept
pointing into whichever home was current when PathsConfig was built. Everything
around them follows a home change -- homeDirectory re-reads per access,
serverFilesDirectory re-derives from it -- which made these the odd ones out, and
they are exactly the values generation uses: a stale one means SPC reads templates
from a directory the user has left behind, so their edits do nothing and nothing
says why.

Now `val ... get() = ...`, which re-derives per access, keeps the property
immutable and adds no dead backing-field write. Behaviour for a stable home is
unchanged, which is why the other 30-odd tests were unaffected.

Guard verified by reverting one property to a bare initialiser: 1 of 12 fails,
restoring passes. The rule is recorded as a landmine in the api module context,
since the next path derived from the home will face the same choice.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Backlog B23. RED on purpose -- "No parameter with name 'downloadCooldown' found"
-- with the fix following.

Nothing remembered a failure. setServerJson() re-downloads whenever the manifest
file is absent, and a failed downloadFile *deletes* the partial file and returns
false, so the next call is in exactly the same position. getServer() then evaluates
url().isPresent && javaVersion().isPresent and both take that path, so one
requiredJavaVersion lookup on an unfetchable version costs two attempts.

Worse than the backlog entry recorded: WebUtilities.downloadFile logs each failure
at ERROR with a full stack trace before returning false. So the flood is
error-level and predates the debug line added in bee9e3187 -- that line was only
the visible tip.

Two guards, because the cooldown must gate the right thing: a failed download is
not retried while it holds and is retried once it lapses (a cooldown, not a
permanent memory -- the grinder runs for days and a transient failure must not
poison a version for the life of the process), while a manifest already on disk is
read regardless, since re-reading a present file costs nothing and gating it would
manufacture exactly the false "unknown" that hid the newest Minecraft versions.

Shape and default follow LoaderCache.failureCooldown, which exists for the same
reason.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Backlog B23; turns the previous commit's guard green.

A failed download deleted the partial file and left nothing remembered, so every
lookup re-attempted -- and getServer() consults both url() and javaVersion(), so
one requiredJavaVersion cost two attempts, each logging an ERROR with a stack trace
from WebUtilities. On a sweep that scales with the catalogue.

readServerJson() now gates the *download* behind a one-hour cooldown while always
reading a manifest that is already on disk: a present file costs nothing and cannot
fail the way a download does, and gating it would manufacture the same false
"unknown" that hid the newest Minecraft versions from the template matrix. A
cooldown rather than a permanent memory because the grinder runs for days -- the
same reasoning, shape and default as LoaderCache.failureCooldown.

The cooldown and clock are defaulted constructor parameters, so the single
production call-site in MinecraftClient is untouched, and the exported Optional
contract is unchanged. Tabled in the compatibility table anyway: for a *failing*
installation the observable behaviour differs, which is exactly the distinction
that table exists to record.

Guard verified by disabling the gate: 1 of 2 fails, restoring passes.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Backlog B24, whose premise this corrects. The manifests *are* shipped as resources
and seeded from the jar by ApiWrapper.setup(), so the offline guarantee has real
backing -- the problem was not that the cache is unavailable but that cleanup()
destroyed it on every run.

Measured before changing anything: planting a marker and a cached manifest in
serverpackcreator-api/tests/manifests, then running a single test task, left the
marker gone and mcserver at 0 files, down from 643. Nothing re-seeded them because
that task boots no ApiWrapper.

cleanup() now spares manifests/ while still wiping everything else. Those files are
a cache of immutable upstream data, not state under test, so keeping them costs
nothing and buys three things: repeat runs stop re-downloading, the documented
offline guarantee becomes true for the versions in the snapshot, and freshly
fetched per-version metadata survives -- which is what kept deleting the seeded
26.2 metadata during the Forge work and what left the newest versions resolving as
"required Java unknown" in the template matrix.

updateManifests benefits as well: it copies that directory into the shipped
resources, so the snapshot can now accumulate versions released since the last
refresh rather than being capped at the seeded set. Verified: an api run took the
cache 643 -> 659, and the next run preserved all 659.

The residue is a data problem, not a code one, and is recorded as B25: the shipped
minecraft-manifest lists 26.2 while mcserver/ has neither 26.2 nor 1.21.x. The
offline claims in the root and api module contexts are corrected to say what is
actually true.

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>
Backlog B25. RED on purpose -- 16 advertised releases have no shipped per-version
manifest -- with the data refresh following.

SPC ships manifests/ as resources and seeds them into the home, which is what makes
the suite and a first run on a fresh machine work offline. The guarantee holds per
version though: asking for one whose mcserver/<id>.json is absent means a download,
and a failed download yields an empty Optional that consumers read as "declares no
required Java" -- which downstream became a benign [N/A] SKIPPED and dropped the
newest Minecraft versions out of the template matrix.

minecraft-manifest.json advertises 26.2 as the newest release while mcserver/ has
neither it nor any 1.21.x: 16 releases promised and not cached, from 1.20.5 up.
The guard matters more than the one-off refresh, because the symptom is invisible
on any machine whose home already holds the files -- which is every machine that
has run the suite once.

Releases only, deliberately: hundreds of advertised snapshots are absent too, and
caching those would multiply the shipped resources for versions the grinder's
release gate never selects.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Backlog B25; turns the previous commit's guard green.

Adds the per-version manifests for every release the shipped
minecraft-manifest.json advertised without caching: 1.20.5, the whole 1.21 line
through 1.21.11, and 26.1 through 26.2. The set now covers all 102 advertised
releases, so a fresh clone resolves any release's required Java offline instead of
fetching -- and instead of answering "unknown" when that fetch fails, which is what
hid the newest Minecraft versions from the template matrix.

Sourced from serverpackcreator-api/tests/manifests, whose suite had already fetched
exactly these 16, and each file checked for parseability and the fields
MinecraftServer reads before being copied in. Three ancient releases (1.6.1, 1.6.2,
1.6.4) legitimately carry no javaVersion -- Mojang's data predates the field -- so
the guard asserts the manifest is present, not that every one declares a Java
version.

Releases only. Hundreds of advertised snapshots remain uncached, which is a
deliberate scope call recorded in the guard: caching them would multiply the shipped
resources for versions the release gate never selects.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The task refreshes the shipped manifest snapshot but copied from
serverpackcreator-app/tests/manifests, while the api suite is the one that
exercises MinecraftMeta and therefore fetches the per-version mcserver files.
Measured: the app home held 643, the api home 659, and the difference was exactly
the 16 releases missing from the shipped set -- so as wired, the documented refresh
path could never have added them.

Now sources this module's own test home. Combined with cleanup() no longer wiping
manifests/, a plain `test` followed by `updateManifests` genuinely advances the
snapshot instead of copying back what was seeded.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Backlog B5. RED on purpose -- "No parameter with name 'projectId' found" -- with
the implementation following.

The store keyed on platform + slug + loader, and a slug is a mutable display name.
A project that renames itself becomes a second row, its old verdicts linger under
the old name, and the crawl grinds it again as if unseen. Tolerable for scratch
data; not once the store is a dataset anyone reads, where the same mod then appears
twice with possibly opposite confidences and nothing marks which is current.

Six cases, and the legacy ones carry as much weight as the fix: a renamed project
replaces its verdict and counts as already ground; an identified verdict supersedes
the pre-id row for the same slug; verdicts without ids still dedup by slug exactly
as before; platform stays part of the identity; and one project keeps one verdict
per loader.

That supersede rule *is* the migration. 860 verdicts were recorded before ids
existed, so the id is nullable and dedup falls back to the slug -- which alone would
leave a project holding two rows. Superseding on record means the store converges as
projects are re-ground, with no schema step and no rewrite of a live store.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Backlog B5; turns the previous commit's guard green.

GrindCandidate and GrindVerdict now carry the platform's immutable project id --
Modrinth's project_id, CurseForge's numeric id -- and verdictKey uses it in place
of the slug when present. A project that renames itself therefore replaces its own
verdict and still counts as already ground, instead of becoming a second row while
the crawl re-grinds it under the new name.

No migration step, by design. The id is nullable, dedup falls back to the slug, and
Jackson reads the ~870 id-less verdicts in the live store unchanged. On top of that,
recording an identified verdict removes the id-less row for the same slug, so the
store converges on project identity as projects are re-ground rather than holding a
slug-keyed and an id-keyed copy of each. Both stores derive their key from the
shared identityKey(), which is what stopped them drifting apart before.

Platform stays part of the identity: jei on Modrinth and jei on CurseForge are
different projects, and one platform's id must not match the other's.

Guard verified by removing the supersede rule, which fails the legacy-migration
case. All four suites green; the json-store round-trip tests cover reading a store
written by the previous format.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Audit finding M-1. 0dacd5a58 changed eight *exported* properties from captured-once
to computed-per-access, so a previously constant value can now differ between two
calls -- which is the point of the fix and exactly what the compatibility table
exists to record. The table gained a row for B23's narrower change two commits
later; this one had none.

Third recurrence of the class (variables.txt, then MinecraftServer's logging, now
this), which is why the row is worth adding even though the trigger is narrow: for
a stable home the value is unchanged, and only a host that moves its home at
runtime sees a difference.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Audit finding L-2. The finding was that hasVerdictFor is never called from
production; inspecting why turned up the actual harm -- VerdictStoreTest's class
comment asserted that it "drives the skip check", which is false. Grinder.grind
compares newestVerification against the re-verify TTL, because "seen at all" and
"seen recently enough" are different questions and only the second is what a
continuous grind needs.

Kept rather than dropped: three test classes use it as a readable predicate for the
narrower question, and reducing that to `newestVerification(...) != null` would cost
clarity without removing surface anyone can misuse. What was actually dangerous was
the false claim, now corrected in both the test and at the declaration, so the next
reader does not have to find the test to learn which method the daemon uses.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Audit findings M-2 and L-1, resolved as a decision rather than a harness. Both were
"production change with no automated guard", and both are build logic: cleanup()'s
filter and updateManifests' source directory. buildSrc has no test source set and no
TestKit, and standing one up to pin a one-line filter is not worth a second test
framework in the build.

So the standard is now written down: measure before and after, and put both numbers
in the commit message. Both flagged commits already did exactly that, which is why
they were MEDIUM and LOW rather than worse.

Also records why the gap cannot be closed from a normal suite, so nobody re-derives
it: ApiWrapper.setup() re-seeds the manifests from the jar, so by the time any test
runs, a wiped cache is indistinguishable from a preserved one. Where a consequence
*is* reachable, pin that instead -- ShippedManifestSnapshotTest guards the outcome of
the manifest work even though nothing can guard cleanup() itself.

Two audits flagged this class; stating the ceiling stops the third from re-flagging
it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
H-1 amended out of the feature commit and out of branch history, with the swept
edit handed back to the working tree. M-1 tabled. L-2 resolved by correcting the
false claim about which store method drives the skip check, keeping the API. M-2
and L-1 closed as a recorded decision: build logic is verified by measurement here,
because buildSrc has no test harness and the consequence is not observable from a
normal suite.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Empties the backlog to a single upstream-blocked item, and closes the fifth audit's
findings on its own work.

Six items, each landed as a red test commit followed by its fix:

- B21: eight shipped template paths were captured at construction, so they kept
  pointing into the old home after a home change -- generation read templates from a
  directory the user had left and their edits silently did nothing. Now computed per
  access, and tabled as a compatibility note.
- B23: a failed server-manifest download was never remembered, and getServer()
  consults both url() and javaVersion(), so one lookup cost two attempts -- each
  logging an ERROR with a stack trace from WebUtilities. Now gated by a one-hour
  cooldown, while a manifest already on disk is still always read.
- B24: cleanup() wiped the whole test home before every run, taking the version-
  manifest cache from 643 files to 0. It now spares manifests/, which also lets
  updateManifests accumulate instead of copying back the seeded set.
- B22: the daemon created and rewrote a settings file in whatever directory it was
  launched from, because ApiProperties' default is relative and PropertyStore keeps
  every loaded file as a write target. Now resolved against the daemon's own home.
- B25: the shipped snapshot advertised 26.2 while caching neither it nor any 1.21.x.
  All 102 advertised releases now ship, pinned so it cannot drift back.
- B5: verdicts dedup on the platform's immutable project id, so a renamed project
  replaces its own verdict instead of being re-ground as new. No migration step: the
  id is nullable with slug fallback, and an identified verdict supersedes the id-less
  row, so a live store converges as projects are re-ground.

Two backlog entries were corrected rather than executed as written -- B22 had ruled
out the working directory on a misread lsof result, and B24 assumed the manifests
were not shipped when they are. Both were re-derived from evidence before any code
changed.

Also records how build logic is verified here (a measurement in the commit message,
since buildSrc has no test harness and the consequence is not observable from a
suite), and corrects a false claim that hasVerdictFor drives the daemon's skip check.

Suites: api 278 (1 skip), clientside 87, grinder 233 (19 skip), app 76 -- green.
Signed-off-by: Griefed <griefed@griefed.de>
The build runs dokka with reportUndocumented, which flagged 302 declarations
across api and grinder. This closes the grinder's 56, leaving zero in that module.

Written per symbol rather than generated: each says what the value is *for* and,
where it matters, why it is shaped that way -- the convention asks for that instead
of a restatement of the signature, so `/** The slug. */` would have satisfied dokka
and failed the rule. The /status fields carry the operator-facing reading (what
`busySeconds` reveals about a hung boot, why `failed` is deliberately not counted as
work by pacing), the crawl types carry the cursor semantics, and the CurseForge
loader constants say why dead loaders are still enumerated.

None of the 302 came from recent work: GrindCandidate.projectId and
GrindVerdict.projectId were absent from the report while every pre-existing sibling
field was in it.

Verified with warnings enabled. An earlier check with -q reported zero because quiet
mode suppresses them -- the same false-green shape as the up-to-date task earlier in
this work, so the count here comes from a --rerun-tasks run whose output was kept.

Grinder suite green.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
38 of the api's 246 undocumented declarations, all in the type plugins touch most.
api total 246 -> 208; PackConfig 38 -> 0; suite green.

Written per symbol, and two carry warnings rather than descriptions, because that
is what a reader actually needs:

- modloader's setter *silently ignores* an unrecognised value, so a typo does
  nothing rather than failing, and the check order matters because `neoforge` also
  matches Forge's pattern and `legacyfabric` matches Fabric's.
- getPluginConfigs creates an empty list on first ask, which is why it is a function
  and not a map access -- an extension can read and append without null-checking.
- defaultScriptValues records why SPC_SSJ_FORGE_ARGS_SPC must keep defaulting to
  -Djava.security.manager=allow: Forge's ServerStarterJar needs it below Java 24, and
  the templates decide when to pass it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
39 of the api's remaining declarations, in the interface plugin authors implement
against. api total 246 -> 169; this file 39 -> 0.

Signatures untouched, so this is documentation only -- verified by compiling the app
and the plugin-example, which are the real implementors, and by both suites.

The docs answer what a plugin author cannot see from the signature: that the paired
String/List getters are the same data in field form and parsed form, that
getCurrentConfiguration builds a PackConfig on demand and does *not* persist it while
saveCurrentConfiguration writes to disk, that isMinecraftServerAvailable gates
generation because Mojang does not publish a server for every version, and that
acquireRequiredJavaVersion returns Mojang's declared requirement -- the same
authority the start scripts and the grinder use rather than a version-derived guess.
setModloader also notes that unrecognised names are ignored, matching the landmine
documented on PackConfig.modloader.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
24 declarations; ApiProperties 24 -> 0, api total 169 -> 145.

The fallback* members are thin facades over the settings groups, so the concept is
explained once at the first of them -- the value SPC uses when a property is unset or
invalid, which is what keeps a broken serverpackcreator.properties from being fatal --
and each then gets a line saying what it governs.

Three carry knowledge that cost real time this session:

- devBuild is not cosmetic: it turns on DEBUG logging, rewrites log4j2.xml on every
  start, and makes the home fall back to the working directory.
- installLocationXml sits beside the *jar*, because `home` here is the jar folder and
  not SPC's home directory -- log4jXml is the one logging reads.
- clearPropertyFileList explains why it exists at all: PropertyStore writes to every
  file it has loaded, for the life of the process, which is how a daemon launched from
  a checkout dropped a settings file into the repository root.

getPreference/storePreference note that which Preferences node they touch depends on
resolvePreferencesNode, so a host claiming its own node reads a different store.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
24 declarations; both files 0 remaining. api total 145 -> 121.

VersionMeta's manifest URLs now say which upstream each is and, where it matters, why
there is more than one:

- NeoForge has two, because its first releases (Minecraft 1.20 and 1.20.1 only) live
  under the legacy net/neoforged/forge artifact and exist nowhere else.
- LegacyFabric publishes its supported-Minecraft list and its loader list
  independently, and a version needs an entry in both to be usable.
- loader, installer and intermediary manifests are separate series and not
  interchangeable -- the Quilt installer needs Java 17+ even when the server it
  installs runs on 8, which is exactly why they are tracked apart.
- fabricIntermediaries is what actually answers "does Fabric support this Minecraft
  version", since one loader line serves every version.

ConfigurationHandler's regexes get the same treatment: the loader matchers note the
ordering requirement (neoforge also matches Forge's pattern, legacyfabric matches
Fabric's), and previous/zipCheck say what they detect rather than restating the
pattern.

api and app suites green.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
40 declarations across seven files; api total 121 -> 81.

Where a field needed more than a label it got the reason instead:

- ScanResult separates exclusions from dependencies, so each says what it is *for* --
  the dependency list exists because removing a mod something else requires would
  break the server, which the pair of lists alone does not convey.
- modFileEndings includes "disabled" deliberately: a launcher marks a mod off by
  renaming it, and such a file still has to be recognised to be excluded rather than
  copied blindly.
- SimpleStopWatch's startTime/stopTime default to construction time, so elapsedTime is
  only meaningful once stopped -- worth saying, since reading it early yields a
  plausible zero rather than an error.
- ServerPackManifest records which SPC build wrote it, which is what lets a migration
  recognise an old pack.
- The GenerationConfig fallbacks explain the concept once and then say what each
  governs, matching how ApiProperties' facades were handled.

api and app suites green.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
31 declarations across eight files; api total 81 -> 50.

Several record a caveat rather than a description, which is the part worth keeping:

- NeoForgeInstance.installerUrl warns that maven metadata lists versions whose
  installer 404s, so a failure there means upstream is incomplete rather than the
  version being wrong -- the defect that cost a full download-and-boot per candidate
  before the install cooldown existed.
- defaultTomcatBaseDirectory notes that the tomcatBaseDirectory *getter* normalises a
  deviating value on read, which is why the GUI's dirty-check needs a reload after
  saving.
- ForgeTomlScanner's neoForgeMinecraft matches dependency ids that are the platform
  itself, so they must not pull a jar into the dependency list; bothServer notes the
  declared side is a self-report, and an unreliable one -- which is why the boot test
  exists at all.
- ForgeTomlScanner.modsToml is `open` because NeoForge moved the descriptor.
- MinecraftClient.url points at the per-version JSON that declares the required Java.

api and app suites green.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
47 declarations across ~30 files. api 50 -> 3, and those 3 cannot be fixed -- see
below.

The placeholder defaults in PackConfig carry the operational warnings, which is the
whole reason to document a constant whose value is visible:

- spcSSJArgsKeyDefaultValue: do not remove the default and do not pass it
  unconditionally. Forge's ServerStarterJar needs -Djava.security.manager=allow below
  Java 24 to trap the installer's System.exit; from 24 JEP 486 makes the VM refuse to
  start with it. Both halves are pinned by ScriptTemplateContentTest.
- spcWaitForUserInputKeyDefaultValue: any unattended boot must set it false or the
  script blocks on a read forever.
- spcServerStarterJarForceFetchKeyDefaultValue: an offline boot must set it false, or
  Forge/NeoForge try to fetch the starter jar with no network.

Those three are exactly the levers the grinder overrides for its network-less boots,
and each cost real debugging to learn.

Remaining 3 are unfixable rather than skipped: Comparison, SPCGenericListener and
NeoForgeInstance are an enum and two interfaces with no companion object anywhere in
their sources -- dokka synthesises the Companion entries, so there is no declaration
to attach a doc comment to. Verified by grepping all three files for "companion":
zero hits.

All four suites green.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Audit finding L-3. The previous commit left roughly 30-40 of the 299 docs as thin
restatements -- "The selected Minecraft version." on getMinecraftVersion() tells a
reader nothing the signature did not. For pure accessors the missing information is
usually shared rather than per-method, so it belongs in one place.

The interface doc now states the contract every accessor relies on: these read and
write the tab's *live* state, not the last saved configuration, so a getter reflects
unsaved edits; a setter mutates the tab exactly as typing would, which means SPC's
validation reacts to it; nothing here persists anything except
saveCurrentConfiguration; and the paired getX/getXList accessors are the same data in
field form and parsed form, the list form being the one to write logic against.

Nine accessors also gained something specific: an empty script-settings map is normal
because absent keys fall back to PackConfig.defaultScriptValues, inclusion order is
meaningful since a later entry can copy over an earlier one, and the modloader getter
names the canonical spellings a plugin must expect.

Signatures untouched -- api, app and plugin-example all compile, and both suites are
green. Some accessors remain one-liners by nature; where a field is fully described by
its name, the honest alternative to a label is silence, which dokka flags.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Audit findings L-1 and L-3, closed as conventions rather than code changes.

L-1: documenting a single-line constructor requires reshaping it to one parameter
per line, because per-parameter KDoc cannot attach to a shared line. The audit read
that as scope creep; it is the enabling change. The rule now says so, and says what
to verify -- names, types, order and defaults must survive -- since the alternative, a
class-level @param block, leaves the properties undocumented as far as dokka cares.

L-3: a doc that only restates the signature is barely better than none, but silence is
worse, and dokka flags it either way. The resolution is to put shared meaning on the
type: ServerPackConfigTab's accessors were labels until the interface doc explained
that they read live tab state rather than the saved configuration. One honest paragraph
on the type beats forty restatements on its members.

Also records the remediation status of all four findings in the audit report.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Closes the dokka reportUndocumented gap the build had been carrying: 302 flagged
declarations across api and grinder, 299 documented, 3 unfixable.

Written per symbol rather than generated, because the convention asks for what a
value is *for* rather than a restatement of its signature. The ones worth having are
the ones that record what cost real debugging: spcSSJArgsKeyDefaultValue carries the
JEP 486 warning, spcWaitForUserInputKeyDefaultValue and
spcServerStarterJarForceFetchKeyDefaultValue say why an unattended or offline boot
must flip them, NeoForgeInstance.installerUrl warns that maven lists versions whose
installer 404s, PackConfig.modloader records that its setter silently ignores an
unrecognised value, and ForgeTomlScanner.bothServer notes the declared side is an
unreliable self-report -- which is why the boot test exists at all.

The 3 residuals are synthetic: Comparison is an enum, SPCGenericListener and
NeoForgeInstance are interfaces, and none has a companion object in source, so dokka
reports a declaration with nothing to attach a comment to.

No behaviour changed. Filtering the whole range's diff to non-comment lines yields
exactly four constructor reshapes -- single-line to one-parameter-per-line, which is
what lets per-parameter KDoc attach -- with names, types, order and defaults
preserved. ServerPackConfigTab's body was rewritten wholesale, so its 39 signatures
were compared against develop as a sorted set: byte-identical, and its real
implementors (app, plugin-example) compile.

Two conventions came out of the sixth audit and are recorded in CLAUDE.md: that
reshape is in scope for a docs commit, and that shared meaning belongs on the type
rather than repeated as forty member restatements -- which is how ServerPackConfigTab's
accessors got the live-state contract they were missing.

Suites: api 278 (1 skip), clientside 87, grinder 233 (19 skip), app 76 -- green.
The Corepack workaround it tracked is no longer needed. Griefed's Node 20.18.3 ->
24.18.1 bump disabled the dependsOn, and the frontend was built to confirm rather
than assumed: installQuasar succeeded with installCorepack SKIPPED, and both
installFrontend and assembleFrontend completed clean. That is exactly the condition
B11 named for its own removal.

Leaves the file saying it is empty instead of carrying three headers with nothing
under them, so the next reader can tell "nothing outstanding" from "nobody wrote
anything down".

Note the workaround is disabled, not deleted: the task registration and its TODO
still sit in quasar-conventions.gradle.kts, commented out rather than removed --
deliberately left for Griefed, since keeping it a keystroke away until CI agrees is a
reasonable call and not mine to overrule.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Finishes what B11 prescribed. The dependsOn was already commented out by the Node
20.18.3 -> 24.18.1 bump; this deletes the dead task registration, its TODO, and the
commented-out line that referenced it.

Measured before and after, since buildSrc has no test harness and this is the
standard the conventions set for build logic:

  before: assembleFrontend BUILD SUCCESSFUL, installCorepackLatest registered = 1
  after:  assembleFrontend BUILD SUCCESSFUL, installCorepackLatest registered = 0

Both runs with --rerun-tasks, and both show installQuasar succeeding while the
plugin's own installCorepack task is SKIPPED -- which is the point: nothing needed the
globally-installed corepack@latest that this task provided.

Checked for other references first: only historical mentions in REFACTOR-AUDIT.md and
the backlog, nothing in CI or any build script. With this gone there is no TODO or
FIXME marker left anywhere in the Kotlin sources.

Upstream issue for the record: nodejs/corepack#612.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Signed-off-by: Griefed <griefed@griefed.de>
Signed-off-by: Griefed <griefed@griefed.de>
build(deps): bump org.jetbrains.kotlin:kotlin-test-junit5
Some checks failed
clientside-report-reusable.yml / build(deps): bump org.jetbrains.kotlin:kotlin-test-junit5 (push) Failing after 0s
Test / build (push) Has been cancelled
f4e4e20b19
Bumps [org.jetbrains.kotlin:kotlin-test-junit5](https://github.com/JetBrains/kotlin) from 2.3.21 to 2.4.10.
- [Release notes](https://github.com/JetBrains/kotlin/releases)
- [Changelog](https://github.com/JetBrains/kotlin/blob/master/ChangeLog.md)
- [Commits](https://github.com/JetBrains/kotlin/compare/v2.3.21...v2.4.10)

---
updated-dependencies:
- dependency-name: org.jetbrains.kotlin:kotlin-test-junit5
  dependency-version: 2.4.10
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
build(deps): bump org.junit.platform:junit-platform-launcher
Some checks failed
clientside-report-reusable.yml / build(deps): bump org.junit.platform:junit-platform-launcher (push) Failing after 0s
Test / build (push) Has been cancelled
3b17c1f2f0
Bumps [org.junit.platform:junit-platform-launcher](https://github.com/junit-team/junit-framework) from 6.1.0 to 6.1.2.
- [Release notes](https://github.com/junit-team/junit-framework/releases)
- [Commits](https://github.com/junit-team/junit-framework/compare/r6.1.0...r6.1.2)

---
updated-dependencies:
- dependency-name: org.junit.platform:junit-platform-launcher
  dependency-version: 6.1.2
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
fix: Add fish-warning recommended by DavidBevi
Some checks failed
clientside-report-reusable.yml / fix: Add fish-warning recommended by DavidBevi (push) Failing after 0s
Continuous / Build JAR (push) Has been cancelled
Continuous / Build AppImage (x86_64) (push) Has been cancelled
Continuous / Build AppImage (aarch64) (push) Has been cancelled
Continuous / Build Install4J Media (push) Has been cancelled
Continuous / Continuous Pre-Release (push) Has been cancelled
Test / build (push) Has been cancelled
clientside-report-reusable.yml / fix: Add fish-warning recommended by DavidBevi (pull_request) Failing after 0s
a6ffd41617
See https://github.com/fish-shell/fish-shell/issues/12824#issuecomment-5152165903

Signed-off-by: Griefed <griefed@griefed.de>
`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>
Turns the guard from the previous commit green. `ServerPackHandler.modFileEndings`
and `ConfigurationHandler.zipCheck` become getters delegating to
`ModListCompiler.modFileEndings` / `ModpackZipInspector.zipCheck`, so each literal
now exists exactly once in the module.

Behaviour-preserving: both facades return the identical value they returned before —
the same literal, now read from one place instead of two. Verified across the api,
app, clientside, grinder and plugin-example suites, all green, with no assertion
changed anywhere.

Getters rather than initialisers, deliberately: `modFileEndings` is declared at
`ServerPackHandler:86` but `modListCompiler` only at `:95`, and `zipCheck` at
`ConfigurationHandler:82` with `zipInspector` at `:103`. Kotlin initialises
properties in declaration order, so `val x = collaborator.y` would read the
collaborator before it exists — the ordering landmine already recorded for
`ApiProperties`' setting groups. Computing on access sidesteps it entirely.

The explanatory doc comments move to the copies that are actually consulted, which
is the substance of the fix: before this, the reasoning for `disabled` lived on the
dead constant while the live one — the list generation genuinely walks with — had
none. An edit aimed at the documented copy would have changed nothing at all.

Neither symbol was ever unused by mistake: both extractions (a35f3cda6 Phase 1c,
b0dc98131 Phase 1d) moved their sole call site into the new class along with a
private copy, and the moved code is byte-identical. Nothing regressed then; the
duplication was simply left behind, which is why this is a refactor and not a fix.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Per the definition of done: current-state snapshot in the root `CLAUDE.md`, durable
landmines in the affected module files, blow-by-blow appended to
`claude-docs/REFACTOR-LOG.md`.

Root `CLAUDE.md`: status date to 2026-08-02, suite counts refreshed (api 272 → 280,
grinder 224 → 233), a dated entry for the branch, and one compatibility-table row —
the two owned constants are new exported surface, and while both facades still return
the value they always did, they are no longer separate objects from their owner's.

`serverpackcreator-app/CLAUDE.md`: FlatLaf's `SystemFileChooser.FileFilter` has no
`accept` to override, so a subclass adding one compiles *without* `override` and is
never called. Records that nothing was broken (the call sites validate after the
dialog returns) and that `setApproveCallback` is the mechanism if in-dialog
validation is ever wanted, so the next reader does not "restore" the filter.

`serverpackcreator-api/CLAUDE.md`: a constant kept on an extraction facade must read
its owner rather than re-declare the literal, with the getter-not-initialiser caveat
tied to the declaration-order landmine already above it.

Line-number citations were re-derived from the files after the code changes rather
than carried over from the audit notes; four had drifted.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`settings.gradle.kts` lists all six modules, `libs.versions.toml:4` declares
`kotlin = "2.3.20"`, and the convention plugins are visible in
`buildSrc/src/main/kotlin/` — so these three lines restated what a session can read
directly, in an always-loaded file. The line that follows already carries the part
that is not derivable: that each module documents itself.

Root memory file 24,551 → 24,342 chars (~6,137 → ~6,085 est. resident tokens).

Found by /doctor's derivable-content check; it was the only block in any of the 11
checked-in CLAUDE.md files worth cutting — the rest is gotchas, rationale and agent
directives that no session could reconstruct.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Audit of the Qodana report for job 37392: 54 problems verified individually —
15 real, 22 false positives, 14 by-design, 3 cosmetic.

- Removed the inert WritableDirectoryFilter (FlatLaf's SystemFileChooser.FileFilter
  has no accept() to override; the writability rule already lives at the call sites).
- Reattached seven orphaned KDoc blocks that had left outcomeFor, shouldRecheckCrash
  and env undocumented, and repaired five links that resolved to nothing (Dokka
  "Couldn't resolve link" 12 -> 0).
- Removed four pieces of dead code.
- Rewired modFileEndings/zipCheck to a single source of truth rather than deprecating
  them, pinned by an identity-asserting guard committed red first.
- Bumped Qodana 2025.1 -> 2026.2: the old linter bundles kotlinc 2.1.10 against this
  project's 2.3.20, which is what produced all 18 phantom KotlinUnreachableCode hits.
  Not verified end to end locally (Qodana OOM'd at the 1.93 GiB Docker VM cap) —
  confirm the new problem count against the next CI report.

Suites green: api 280 (1 skip), clientside 87, app 76, grinder 233 (19 skip),
plugin-example 3. No existing assertion changed anywhere.
RELEASE: 9.0.0-alpha.1
Some checks failed
clientside-report-reusable.yml / RELEASE: 9.0.0-alpha.1 (push) Failing after 0s
Create GitHub Pre-Release after GitLab tag mirror / Preparations (push) Successful in 11s
Test / build (push) Failing after 20m1s
Create GitHub Pre-Release after GitLab tag mirror / JAR and media (push) Failing after 27s
Create GitHub Pre-Release after GitLab tag mirror / PreRelease (push) Failing after 10s
Create GitHub Pre-Release after GitLab tag mirror / News on Discord (push) Has been skipped
050a50a51b
build(deps): bump org.junit.platform:junit-platform-launcher from 6.1.0 to 6.1.2 in /serverpackcreator-plugin-example
Signed-off-by: Griefed <griefed@griefed.de>
Signed-off-by: Griefed <griefed@griefed.de>
Signed-off-by: Griefed <griefed@griefed.de>
The Qodana job has failed since the 2026.2 bump (b14d30d45), one second into
step_script:

    Failed to create effective configuration.
    failed to run config-loader-cli: failed to start command: fork/exec
    .../qodana/cache/qodana-jbr/qodana-jbrsdk-25.0.2-linux-x64-b329.72/
    .../bin/java: permission denied

2026.2 builds its "effective configuration" by running libs/config-loader-cli
in a separate JVM, and downloads its own runtime for it into
<cache-dir>/qodana-jbr. That path is derived from --cache-dir; measured against
the pinned image, there is no flag or env var to redirect it or to reuse the
JBR the image already ships at /opt/idea/jbr:

    docker image inspect jetbrains/qodana-jvm-community:2026.2   -> JAVA_HOME=/opt/idea/jbr, USER=0
    grep -a -oE "QODANA_[A-Z0-9_]+" /opt/idea/bin/qodana          -> no JBR/JAVA override
    qodana scan --help                                            -> only --cache-dir, --clear-cache

So an executable necessarily lives inside the directory .gitlab-ci.yml caches,
and GitLab's cache round-trip does not preserve the executable bit
(gitlab-runner#27496, #1782 - both open). The container runs as root, which
bypasses ownership but still needs one x bit to execve, hence EACCES. 2025.1
never hit this: it had no config-loader-cli step and put no executable in the
cache dir. /builds is not noexec - the restored cache key can only have been
written by an earlier *successful* 2026.2 run (cache:when defaults to
on_success) that exec'd that same path.

Measured locally against a minimal fixture (Apple Silicon, so aarch64 rather
than x64; the mechanism is arch-independent):

  1. empty cache      -> JBR downloaded, bin/java is -rwxr-xr-x, config loads.
                         6 of 133 files in the JBR carry an x bit
                         (bin/{java,keytool,jrunscript,rmiregistry},
                         lib/{jexec,jspawnhelper}), and NO other file anywhere
                         else in the cache dir does - config-loader-cli-0.0.38.jar
                         is cached too but is run *by* java, not exec'd.
  2. x bits stripped  -> "fork/exec .../bin/java: permission denied", byte-for-byte
     from files only     the CI failure, exit 1.
  3. this before_script -> 0644 -> 0755, "openjdk version 25.0.2" prints, and the
                         next scan reaches "Loaded Qodana Configuration".

Scoped to qodana-jbr rather than the whole cache because measurement 1 shows
that tree holds every executable in it; blanket-chmodding the cache would mask
the next instance instead of surfacing it. -R rather than a bin/ predicate
because lib/jexec and lib/jspawnhelper are outside bin/. The if/else and the
|| true closing the find pipeline are load-bearing: GitLab prepends
set -eo pipefail, so a line ending on a false test would fail the job - the
empty-cache branch was verified to exit 0 under it.

The ls -l lines are the diagnostic, not decoration: if a future log shows
-rwxr-xr-x before the chmod and -version still fails, the premise is wrong and
/builds is noexec, which needs --cache-dir moved out of $CI_PROJECT_DIR instead.

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>
fix(ci): restore the executable bit on the cached Qodana JBR

See merge request Griefed/ServerPackCreator!692
Fix mod exclusion edgecase, hopefully

See merge request Griefed/ServerPackCreator!691
RELEASE: 9.0.0-alpha.2
Some checks failed
clientside-report-reusable.yml / RELEASE: 9.0.0-alpha.2 (push) Failing after 0s
Create GitHub Pre-Release after GitLab tag mirror / Preparations (push) Successful in 12s
Test / build (push) Failing after 19m59s
Create GitHub Pre-Release after GitLab tag mirror / JAR and media (push) Failing after 29s
Create GitHub Pre-Release after GitLab tag mirror / PreRelease (push) Failing after 8s
Create GitHub Pre-Release after GitLab tag mirror / News on Discord (push) Has been skipped
5c00ee702c
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>
Closes the documentation findings of the claude-ci-qodana-jbr-cache audit.

H-1 (`358675fbf` labelled `refactor(ci)` while stopping 13 of 20 jobs from
starting a dind service) cannot be fixed as the audit recommended: the commit was
already merged into origin/develop (f16dff7ff) and origin/alpha by the time the
audit ran, so relabelling would mean force-pushing two shared branches. Recorded
in CLAUDE.md's mislabelled-commit list instead, which is the remedy this project
already applied to 5f138ef8a and 7815d5960 - now with the asymmetry stated: the
rule is cheap before a merge and unfixable after it.

M-1: BACKLOG.md gains §2026-08-04 with B26 (remove the Docker-endpoint
diagnostic), B27 (decide whether .dockerized should exist), B28 (the dind socket
collision, which lives in the runner config and not in this repo) and B29 (record
the first green 2026.2 Qodana problem count).

Numbering starts at B26, not B23: **B25 is already in use** - the manifest
snapshot lag, cited from CLAUDE.md:63 and serverpackcreator-api/CLAUDE.md:17 -
as are B4, B5, B11, B21 and B22 from CLAUDE.md:254, none of which were carried
over when this file was emptied. Caught by grepping every B-number in the repo
before committing; the new section documents the dangling IDs so the next
session does not collide with them either.

L-3: REFACTOR-LOG.md gains the 2026-08-04 entry, appropriate now that Griefed
has confirmed a full green pipeline. It keeps the two findings that are not
derivable from the diff: that Qodana >= 2026.2 necessarily puts an executable
inside the cached directory with no opt-out, and that exec is the only reliable
test of the bit, because a Docker Desktop mount reports 0755 for a host-side
0644 file and answers [ -x ] "executable" while execve still fails.

CLAUDE.md's 2026-08-02 Qodana entry gains the second fix the bump needed before
CI could run it at all. The problem count is still not recorded - it is readable
off the green report now, tracked as B29, and is not invented here.

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>
Behaviour-preserving, and the enabling change for the next two commits: start()
boots a Spring context, so the argument composition cannot be asserted while it
lives inline. Moved verbatim into WebService.springArguments(args,
configLocationArgument) - including the branch that overwrites the last argument,
which is a bug and is fixed separately so the guard can be seen going red first.

Also renamed the local `lastIndex` to `configLocationArgument`. It held the
--spring.config.location argument, not an index; the name is what made the
overwrite look intentional.

:serverpackcreator-app:compileKotlin passes. No test changed, because none covered
this path - that is the next commit.

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>
Signed-off-by: Griefed <griefed@griefed.de>
Signed-off-by: Griefed <griefed@griefed.de>
Signed-off-by: Griefed <griefed@griefed.de>
Signed-off-by: Griefed <griefed@griefed.de>
MongoDB Fixes

See merge request Griefed/ServerPackCreator!693
Docker and MongoDB

See merge request Griefed/ServerPackCreator!694
RELEASE: 9.0.0-alpha.3
Some checks failed
clientside-report-reusable.yml / RELEASE: 9.0.0-alpha.3 (push) Failing after 0s
Create GitHub Pre-Release after GitLab tag mirror / Preparations (push) Successful in 22s
Test / build (push) Failing after 19m36s
Create GitHub Pre-Release after GitLab tag mirror / JAR and media (push) Failing after 10s
Create GitHub Pre-Release after GitLab tag mirror / PreRelease (push) Failing after 7s
Create GitHub Pre-Release after GitLab tag mirror / News on Discord (push) Has been skipped
e3ab298844
Signed-off-by: Griefed <griefed@griefed.de>
Signed-off-by: Griefed <griefed@griefed.de>
fix: Correctly check range

See merge request Griefed/ServerPackCreator!695
RELEASE: 9.0.0-alpha.4
Some checks failed
clientside-report-reusable.yml / RELEASE: 9.0.0-alpha.4 (push) Failing after 0s
Create GitHub Pre-Release after GitLab tag mirror / Preparations (push) Successful in 23s
Create GitHub Pre-Release after GitLab tag mirror / JAR and media (push) Failing after 9s
Create GitHub Pre-Release after GitLab tag mirror / PreRelease (push) Failing after 6s
Create GitHub Pre-Release after GitLab tag mirror / News on Discord (push) Has been skipped
Test / build (push) Failing after 19m20s
6878287949
build(deps): bump org.jetbrains.kotlin:kotlin-test-junit5 from 2.3.21 to 2.4.10 in /serverpackcreator-plugin-example
Signed-off-by: Griefed <griefed@griefed.de>
Signed-off-by: Griefed <griefed@griefed.de>
Signed-off-by: Griefed <griefed@griefed.de>
Signed-off-by: Griefed <griefed@griefed.de>
Signed-off-by: Griefed <griefed@griefed.de>
Signed-off-by: Griefed <griefed@griefed.de>
Signed-off-by: Griefed <griefed@griefed.de>
Closes https://github.com/Griefed/ServerPackCreator/issues/1238 Closes https://github.com/Griefed/ServerPackCreator/issues/1239 Closes https://github.com/Griefed/ServerPackCreator/issues/1235 Closes https://github.com/Griefed/ServerPackCreator/issues/1234

Signed-off-by: Griefed <griefed@griefed.de>
The modscan rewrite and its follow-ups changed behaviour four times without a
single new test case. Two regressions shipped as a result; neither turned the
suite red. This adds the pins that would have caught them.

- whitelistRescuesAutoDiscoveredClientsideMod guarded on Assumptions.assumeTrue,
  so an empty auto-exclusion result SKIPPED instead of failing. Replaced with a
  hard assertion. This is the failure mode CLAUDE.md records from B13-B20.
- autoDetectedClientsideModsStayDisabledWithoutUserExclusions: new. Pins the
  case auto-detection exists for - auto-discovery on, no user exclusions - in
  both directions (aaaaa.jar disabled, ddddd.jar kept), matching what
  ModScannerTest.tomlTest asserts at the scanner level.
- quiltArmReturnsEachJarExactlyOnce: new. The Quilt arm scans twice and merges;
  the merge must key on the jar, not the declared mod id. Asserts the two
  returned lists are disjoint, which the existing partition check cannot catch
  because each list is de-duplicated separately before being returned.

Verified red, per commit, not just written:

  git checkout 7004f3c88^ -- .../serverpack/ModListCompiler.kt
    autoDetectedClientsideModsStayDisabledWithoutUserExclusions FAILED
      "...must remain disabled with no user exclusions; disabled=[]"
    whitelistRescuesAutoDiscoveredClientsideMod                 FAILED
      "Auto-discovery must detect at least one clientside mod in forge_tests..."
    quiltArmReturnsEachJarExactlyOnce                           PASSED

  quiltArmReturnsEachJarExactlyOnce passes there because the auto-exclusion
  defect masks the de-duplication one: everything lands in serverMods, so the
  disabled list is empty and disjointness holds trivially. Isolated by keying
  the aggregation back on modID while keeping the auto-exclusion fix:
    quiltArmReturnsEachJarExactlyOnce                           FAILED
      "A jar must not be both included and disabled; both=[bbbbb.jar]"
    autoDiscoveryReachesScannerBranchPerLoader                  FAILED
      "Quilt/1.20.1: ... expected: <5> but was: <6>"

bbbbb.jar is the mechanism in one line: it declares quilt_loading_screen in
quilt.mod.json and has no fabric.mod.json, so the failed Fabric scan falls back
to the filename - the two ids never match and the jar is entered twice.

Signed-off-by: Griefed <griefed@griefed.de>
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Every fabric.mod.json and quilt.mod.json among the committed fixtures declares
an "environment", so the default a scanner falls back to when the field is
ABSENT had no coverage at all. That default is a decision, not an accident - a
mod that does not declare its side is assumed server-side so it is never
dropped from a pack - and it had already regressed once.

The rewrite left QuiltScanner's catch-block adding nothing to its list of
sidenesses, and an empty list falls through to CLIENT, so a Quilt mod that
simply did not declare an environment was excluded from the pack. bf226c2ac
fixed it; nothing pinned it.

Fixtures are built inline rather than committed. The committed jars stay as
they are - they are real-world captures and that messiness is their value (see
fabric_tests/fffff.jar, whose descriptor carries a literal newline inside a
JSON string). What they cannot express is the absence of a field, so these
cases write one descriptor into a real jar in a @TempDir: the JSON under test
is visible in the diff and no binary enters the repository.

Both scanners are pinned in both directions - environment=client, environment=*
and omitted - so the fallback reads as a default rather than as a coincidence.

Verified red, not just written:

  git checkout bf226c2ac^ -- .../modscanning/QuiltScanner.kt
    quiltModWithoutAnEnvironmentIsServerSide FAILED
      "A Quilt mod declaring no environment must be SERVER
       ==> expected: <SERVER> but was: <CLIENT>"
    the other three                          PASSED

The single failure is the point: the Fabric cases pass against the old
QuiltScanner because only Quilt carried the defect.

Signed-off-by: Griefed <griefed@griefed.de>
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A jar the scanner cannot read - a truncated download, a non-archive with a .jar
name - must still be kept rather than silently dropped, and must carry the
filename as its id.

The id matters beyond diagnostics: it is what downstream matching joins on. It
was "N/A" for every unreadable jar until 2ec5ff202, so any two of them compared
equal and matched each other in the dependency lookup. The filename is not a
real mod id and nothing in the type says so, which is exactly why it needs a
pin rather than a comment.

Also pins that a scan returns one entry per input file whatever the outcome.
The compiler builds its include-list solely from what the scanners hand back,
so a jar dropped mid-scan is a jar missing from the server pack, and a jar
entered twice is one that can land in both returned lists.

Verified red, not just written:

  git checkout 2ec5ff202^ -- .../modscanning/ScanResult.kt
    anUnreadableJarIsServerSideAndCarriesItsFilenameAsId FAILED
      "...must fall back to its filename as id, not to a shared placeholder
       ==> expected: <brokenmod> but was: <N/A>"
    the other five                                       PASSED

Signed-off-by: Griefed <griefed@griefed.de>
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>
Turns the previous commit's red pin green.

The scanner-selection `when` gains an `else` that logs a WARN naming the
offending value and enters every file as an unscanned SERVER mod. That restores
the contract the pre-rewrite code had - where the include-list was seeded with
every file and exclusions were removed from it - while making the situation
visible instead of silent.

Keeping every mod rather than throwing is deliberate: this is reachable from an
ordinary PackConfig whose modloader was never recognised (the setter silently
ignores unknown values, leaving the field empty), and a pack the user can trim
is recoverable where an empty one reads as a successful run that did nothing.

  unrecognisedModloaderStillYieldsEveryMod PASSED

Suites: :serverpackcreator-api:test 292 tests, 0 failures, 1 skipped (the
fish-absent skip in ScriptTemplateContentTest, unchanged);
:serverpackcreator-app:test green.

Signed-off-by: Griefed <griefed@griefed.de>
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Updates the api suite count 280 -> 292 and records what the branch found, per
the definition of done.

Also corrects a factual error in serverpackcreator-api/CLAUDE.md: it claimed
unknown modloaders "default to Forge". They do not. PackConfig.modloader's
setter assigns only on a match, so an unrecognised value leaves the field at
whatever it already held - which starts as "". That empty string is what
reached ModListCompiler's scanner-selection `when`, and is why the missing
`else` was reachable from an ordinary config rather than only from an embedder
passing something odd.

Signed-off-by: Griefed <griefed@griefed.de>
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Every finding in the audited range is now closed:

- H-1 WITHDRAWN. Griefed's call: the deleted types were already unused and
  break in 9.x regardless, and the API policy governs source-compatibility
  WITHIN a major version, so removal at a major boundary is what it permits.
  The finding mistook a deliberate break for an accidental one. One release-
  note line is the only outstanding action.
- M-1, M-2, M-4 FIXED on claude-modscan-test-hardening (280 -> 292 tests).
- M-3 MOOT, superseded by N-1.

Adds N-1, found while remediating: the Quilt copy-loop at ModListCompiler.kt
149-154 is unreachable. Both scanners return exactly one entry per input file
whatever the outcome - each catch adds a bare ScannedMod - and both are called
with the same file list, so the `find` never returns null. Confirmed: the log
line fires 0 times across the fixture. It became dead when 7004f3c88 re-keyed
the join from modID to file.name; under the old key it did fire, and that
firing is what produced the duplicates the commit set out to stop.

Records why L-2 must NOT be fixed with a data class, so the next reader does
not re-propose it: equality by file would make two entries with conflicting
sideness silently interchangeable, which is the merge the Quilt arm performs
deliberately.

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>
Turns the previous commit's red pin green. Audit finding I-6.

Drops `&& disabledMod.sideness == Sideness.SERVER` from both the `while` guard
and the `removeIf` predicate. A mod auto-disabled by a scanner is CLIENT by
construction, so that clause meant the rescue could only ever fire for mods the
scanner had judged server-side and the user had excluded by name - never for
the auto-detected clientside mods it exists to protect.

The loop still terminates: each iteration whose guard holds removes at least
one entry from disabledMods, and nothing is ever added back to it.

  aClientsideModDependedOnByAServerModIsRescued PASSED
  theDependencyRescueFollowsAChain              PASSED

Suites: :serverpackcreator-api:test 294 tests, 0 failures, 1 skipped (the
fish-absent skip, unchanged); :serverpackcreator-app:test 80, green.

Signed-off-by: Griefed <griefed@griefed.de>
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
I-6 closed: the dependency rescue now reaches auto-detected clientside mods.
That was the one inherited condition with a behavioural consequence, so what
remains from 0a12d41d0 is cosmetic.

Suite count 292 -> 294. Notes the transitive case explicitly, since a
single-pass rescue keeps the leaf of a chain excluded and still looks correct.

Signed-off-by: Griefed <griefed@griefed.de>
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
35cb7278f added `import ...SupportedModloaders.quilt` while rewriting the Quilt
arm, but the `when` still matches the string literal "Quilt" and the symbol is
referenced nowhere in the file.

This is the second stray import in the modscan range - 0df7b2835 removed an
`import sun.util.calendar.CalendarUtils.mod`, a JPMS-unexported JDK internal
picked up from a local variable named `mod`. A build-level unused-import gate
would close the category rather than the instances; noted, not done here.

Signed-off-by: Griefed <griefed@griefed.de>
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The log statement read `quiltScan[i].file.name` AFTER `quiltScan[i] = match`,
so it reported the replacement rather than the entry being replaced. It was
correct only by accident of the predicate directly above guaranteeing the two
names are equal - which is exactly the coincidence that stops holding the
moment the merge key changes, and the key in this block has already changed
once (modID -> file.name, 7004f3c88).

Reads `match.file.name` and logs before assigning, so the statement no longer
depends on an invariant established elsewhere.

Signed-off-by: Griefed <griefed@griefed.de>
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The majority of a real Quilt pack is Fabric mods carrying no quilt.mod.json,
and the committed quilt_tests fixture contains no such jar - so the branch that
matters most on the Quilt arm had no coverage. (Recorded as audit M-3.)

A fabric-only mod must take its verdict from the Fabric scan, since the Quilt
scan cannot read it and falls back to SERVER, and must still appear exactly
once across the two returned lists.

Green on arrival: this pins behaviour that is already correct. It exists to
guard the next commit, which removes the unreachable copy-loop at the end of
the Quilt arm - this is the case that loop looks like it handles.

Also extracts jarContaining() from fabricJar() and adds quiltJar(), so a test
can build either descriptor.

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 when 7004f3c88 re-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>
Five hand-written `it.file.name == mod.file.name` comparisons become three
named helpers - forJarOf, holds, removeJar - comparing the File itself.

Behaviour-preserving: every File in play comes from one filteredWalk with
recursive = false (:103), so all entries sit in a single directory and their
names are unique - name-equality and file-equality decide identically. The
change removes the dependency on that fact, which lived 40 lines away from the
comparisons that relied on it.

Deliberately NOT solved with equals/hashCode or a data class, and the helper
says why so it is not re-proposed: two entries for one jar can carry different
Sideness, and value-equality would let a Set or distinct() silently keep
whichever landed first and drop the other verdict. The Quilt arm's merge of
those verdicts is a decision, not a de-duplication.

Also flattens the exclusion branch: the nested `if` inside the `else if` became
a third arm, so the three outcomes - disable, already disabled, keep - read as
three cases instead of two levels.

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>
Removes all 8 `!!` assertions and all 4 mutable `currentModID` fields from the
scanners, and makes ScannedMod/ModDependency immutable.

The pattern was: construct ScannedMod(file), park the mod id in an INSTANCE
field, compute sidenesses, then assign each property back onto the object.
ApiWrapper holds the scanners as singletons, so that field is shared state -
two concurrent scan() calls would interleave through it - and it forced a `!!`
at every read because the field is nullable by declaration even where the code
has just set it.

Now each property arrives through the constructor:

  ScannedMod(modJar, modId, sidenessOf(sidenesses), dependencies)

- Fabric, Quilt, ForgeToml read the id before the helper, so it is simply
  passed in as a parameter.
- ForgeAnnotation discovers the id *inside* the helper - it is whichever
  annotation first carries one, and later annotations are read relative to it -
  so the helper returns it as part of a Triple and threads it explicitly to
  getNestedModsSides/getAdditionalModIDsAndSidenesses. The one place it can
  legitimately still be null is handled with an `if`, not an assertion, and
  falls back to the same defaults an unreadable jar gets, which is what the
  previous `!!`-throws-into-the-catch did.

Extracts sidenessOf(), which was written out identically in all four scanners,
and documents the rule it encodes: SERVER unless nothing said so, because
dropping a mod that belongs on the server breaks the pack while keeping a
superfluous one costs megabytes. That is also why an empty list reads as CLIENT
and why each scanner appends SERVER where it could not determine anything.

Two more in the same block, both in ForgeTomlScanner: `dependencies[modId]!!`
after a containsKey becomes a single nullable lookup, and an inner
`val dependency` that shadowed the loop variable of the same name is renamed.

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 file was named after ScanResult, which 0df7b2835 deleted. It now holds
ScannedMod, ModDependency and Sideness, so it is named after the first of them.

File rename only; no content change.

Signed-off-by: Griefed <griefed@griefed.de>
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The 12 public declarations in this file carried no KDoc at all, and two of their
contracts are load-bearing and non-obvious:

- sideness defaults to SERVER, so a jar nothing could be determined about is
  KEPT. Dropping a mod that belongs on the server breaks the pack; keeping a
  superfluous one costs megabytes. Same reasoning governs sidenessOf().
- modID defaults to the FILENAME for an unreadable jar. That is not a real mod
  id, and anything joining on it should expect that - which is exactly the trap
  that produced duplicate Quilt entries when the merge was keyed on it.

Also records why ScannedMod has no equals/hashCode, on the type rather than
only at the call site in ModListCompiler, so the next reader meets the reason
before proposing a data class: two entries for one jar can disagree on
sideness, and value-equality would let a Set or distinct() silently keep
whichever landed first.

Documentation only; no behaviour change.

Signed-off-by: Griefed <griefed@griefed.de>
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Two problems, one of them worse than the audit finding that prompted this.

The recorded one: the class and method docs still described the deleted
ScanResult contract - "returns a ScanResult carrying exclusions and
dependencies" - and the test was still named
scanReturnsAScanResultWithExclusionsAndDependencies. Renamed to
scanReturnsScannedModsCarryingSidenessAndDependencies and reworded.

The one found while fixing it: the section references were fabricated. The file
cited "README §2 Quickstart", "§3 Composition root", "§4 PackConfig", "§5
Validating", "§7 Version metadata", "§8 Scanning mods" and "§9 Settings", and
claimed the guide "teaches roughly a dozen snippets". README.md has none of
those sections. It carries exactly TWO Kotlin API snippets, both under
"6. API -> Example": initialising ApiWrapper, and check-then-generate. (Its
other two kotlin fences are Gradle dependency blocks.)

That matters because this file's whole justification is being the compiler-gate
for the README - a reader trusting the citations would go looking for prose
that does not exist, and would not notice the README had drifted.

Now each case says what it actually is: the two that do mirror README snippets
cite "§6 API -> Example", and the rest are marked as guarding the adjacent
surface those snippets lead an embedder into, which is a legitimate job but not
the same one.

Documentation and one test rename; no assertion changed.

Signed-off-by: Griefed <griefed@griefed.de>
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
All findings are now closed; only the 9.x release-note line for H-1 remains.

Records the two things from the tidy-up worth not relearning:

- ScannedMod must NOT become a data class. Two entries for one jar can disagree
  on sideness, and value-equality would let a Set/distinct() keep whichever
  landed first and drop the other verdict - the merge the Quilt arm makes
  deliberately. Compare on file.
- The Quilt copy-loop was unreachable, not a fallback. Both scanners return one
  entry per input file, so its lookup never missed; it fired only under the old
  modID key, where it was the duplicate-producing mechanism itself.

And N-2, a second fabricated-reference problem of the same family as the
PackConfig "defaults to Forge" claim: ReadmeExamplesTest cited seven README
sections that do not exist and claimed the guide teaches "roughly a dozen
snippets", where README.md carries exactly two Kotlin API snippets. Being the
compiler-gate for the README is that file's whole justification, so its own
citations pointing at nothing is the kind of error that compounds quietly.

Suite count 294 -> 295.

Signed-off-by: Griefed <griefed@griefed.de>
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
CLAUDE.md's own preamble says per-sprint narrative history belongs in
claude-docs/REFACTOR-LOG.md, and the Definition of done repeats it: "Append the
blow-by-blow to claude-docs/REFACTOR-LOG.md, not here." Four dated blocks had
accumulated in the Refactor state section anyway, costing ~2,270 est. tokens in
every session this repo loads.

Three of them were already duplicated verbatim in the log:

  2026-07-31  -> ## audit/backlog cleanup, Phases 1-2 / Phases 3-6
  2026-08-02  -> ## Qodana report audit and its fallout
  2026-08-04  -> ## CI: the Qodana 2026.2 bump could not run

The fourth, 2026-08-14 (modscanning hardening), was not in the log at all - it
was written straight into CLAUDE.md earlier today, which is the convention
violation this commit also fixes. It is appended to REFACTOR-LOG.md first, so
nothing is lost, with its heading normalised to the log's style and its test
count corrected from "280 -> 292" to "280 -> 295" (the text predated the
tidy-up branch, which added three more).

Verified no content was dropped by grepping distinctive phrases from every cut
block against the log: variables.txt, KotlinUnreachableCode, qodana-jbr,
gitlab-runner#27496, ok_zoomer, fabricated-reference.

Two references the cut would otherwise have broken:

- the api row's "(modscanning hardening, 2026-08-14 - see below)" pointed at
  deleted text; now points at claude-docs/REFACTOR-LOG.md
- "Current status (2026-08-02)" was stale against a table already reading 295
  tests; now 2026-08-14

CLAUDE.md 31,968 -> 22,838 chars (~7,992 -> ~5,709 est. tokens), a 29% cut in
always-resident context. What remains in Refactor state is current-state only:
the status table, size reductions, remaining hotspots, open issues and current
phase. Conventions, the API compatibility policy, Module map and Build & test
commands are untouched - their derivable spine is inseparable from the
non-derivable gotchas that are the actual value.

Signed-off-by: Griefed <griefed@griefed.de>
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The README covered everything from prerequisites onward but never said how to
get the code, and there was no single at-a-glance path through it. Six steps
now run from an empty directory to a browsable result table; the numbered
sections below expand each one.

Two things the quickstart has to get right that a naive version would not:

- It clones `-b develop`. The grinder is NOT in the `main` release branch, so
  a default clone produces a checkout with no grinder module in it at all.
- It leads with the one-shot run (a single Modrinth URL) rather than the
  continuous crawl, because that proves Docker, the runtime image, the loader
  install and the boot path end-to-end in minutes instead of hours.

Also states up front what a reader otherwise finds out late: only HIGH
confidence is decisive, and everything written lands under ~/.spc-grinder.

Verified against the code rather than the prose: the module builds and its
suite is unchanged after the modscanning API rewrite (233 tests, 19 skipped,
0 failures - exactly the state CLAUDE.md records). The grinder references none
of the removed API; it reaches modscanning only through -clientside's
MetadataScanner, which was updated in 0a12d41d0. Note the grinder's own
Sideness (clientside.ClientsideModels, which has UNKNOWN) is a DIFFERENT enum
from api.modscanning.Sideness (SERVER/CLIENT) - they do not collide.

Commands, ports and paths checked against source, not the old prose:
mainClass GrinderApplication, SPC_GRINDER_PORT default 8757, base dir
~/.spc-grinder, JDK 21 toolchain.

Signed-off-by: Griefed <griefed@griefed.de>
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Behaviour-preserving: a type rename, 32 references across 10 files. Every enum
constant, expected value and assertion is byte-identical - only the type name
moved, which is the reference-only case the conventions carve out as a
legitimate refactor.

The old name collided with api.modscanning.Sideness, and the collision was
live rather than cosmetic. MetadataScanner sits IN package
de.griefed.serverpackcreator.clientside - the package that declared Sideness -
and explicitly imports the API's Sideness. The import shadows the package-local
type, so in that one file `Sideness` meant SERVER/CLIENT while its nine
siblings meant REQUIRED/OPTIONAL/UNSUPPORTED/UNKNOWN. Legal Kotlin, silently
opposite meanings. It has already misled a reader into flagging
`Sideness.UNKNOWN` in the grinder as a compile break against the API enum.

DeclaredSupport says what the type is: how strongly a hosting platform claims a
mod supports ONE side, read as a pair (clientSide + serverSide), mirroring
Modrinth's client_side/server_side verbatim. The API's Sideness is SPC's own
verdict about which side a mod belongs on - one value, the whole answer.

MetadataScanner.kt is deliberately untouched: it uses the API enum.
serverpackcreator-clientside is not published to Maven, so the rename costs
nothing in compatibility. The grinder's test fixture imports the package with a
wildcard, so no import needed updating anywhere.

Suites green and unchanged: api 295/1 skip, clientside 87, app 80,
grinder 233/19 skip.

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>
`./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>
Modscanning improvements and fixes, along with a rewrite

See merge request Griefed/ServerPackCreator!696
RELEASE: 9.0.0-alpha.5
Some checks failed
clientside-report-reusable.yml / RELEASE: 9.0.0-alpha.5 (push) Failing after 0s
Create GitHub Pre-Release after GitLab tag mirror / Preparations (push) Successful in 13s
Create GitHub Pre-Release after GitLab tag mirror / JAR and media (push) Failing after 11s
Create GitHub Pre-Release after GitLab tag mirror / PreRelease (push) Failing after 7s
Create GitHub Pre-Release after GitLab tag mirror / News on Discord (push) Has been skipped
Test / build (push) Failing after 19m9s
dc5aed6035
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>
Turns the two pins from the previous commit green.

Both call-sites chose Forge's scanner by testing the Minecraft minor component
on its own, which is wrong for the newer YY.x.y versioning scheme: 26.2's minor
is 2, reading as the 1.2 era, so a modern pack was scanned with
ForgeAnnotationScanner — the scanner for 1.12-and-older. No modern jar carries
META-INF/fml_cache_annotation.json, so every jar threw, every jar fell back to
the never-drop-a-jar default of SERVER, and auto-exclusion silently stopped
working on Forge 26.x while logging one ERROR per mod.

Both now compare every component through SemanticVersionComparator against the
version Forge actually switched at (1.13) — the same call the NeoForge branch
directly below already made correctly. The magic versions become named
constants (FORGE_TOML_MINIMUM_MINECRAFT, NEOFORGE_TOML_MINIMUM_MINECRAFT) at
both sites, replacing the bare "1.20.5" literal too.

  ModListCompiler.kt:145-150 -> forgeUsesToml(minecraftVersion)
  MetadataScanner.kt:86-89   -> forgeUsesToml(minecraftVersion)

Behaviour change beyond the fix, stated deliberately: an unparseable Minecraft
version now falls back to the modern scanner at BOTH sites. MetadataScanner
already did (getOrNull ?: return true); ModListCompiler instead threw
IndexOutOfBoundsException out of compileModList on a version with no dot. The
postures are unified now so the shared dispatch in the following refactor has
one behaviour to preserve rather than two. The annotation cache exists only in
jars a decade old, so it is never the safer guess for an unknown version.

api 296 (1 skip), clientside 88 — both suites green.

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
commit f8cb89bff, 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>
Definition-of-done paperwork for the three preceding commits.

- serverpackcreator-api/CLAUDE.md: the versioning-scheme landmine claimed "the
  Kotlin side was surveyed and is clean by construction". It was not — the third
  instance was sitting in the generation path the whole time. Corrected in place
  rather than deleted, because the wrong-but-confident version is the part worth
  remembering: the earlier survey covered the boot/selection code the grinder
  work had just touched, not ModListCompiler. Adds scannerFor as a
  single-source-of-truth entry next to SupportedModloaders, and documents the
  scanner hierarchy including why JsonBasedScanner stayed out of it.
- serverpackcreator-clientside/CLAUDE.md: retires "kept in sync deliberately; it
  is not shared code" — that instruction is exactly what let one bug live in two
  files. Test count 41 -> 88 (stale).
- CLAUDE.md: refactor-state counts (api 295 -> 302, clientside 87 -> 88) and two
  new rows in the API-compatibility table — the additive scanner surface, and
  the behaviour change an embedder on Forge 26.x will actually notice (a pack
  that relied on "nothing is ever auto-excluded" will start excluding mods).
- claude-docs/REFACTOR-LOG.md: the blow-by-blow, including the Quilt merge
  equivalence argument and the measured line counts.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
BREAKING: a class extending JsonBasedScanner will no longer compile. Extend
JsonDescriptorScanner instead — same getJarJson, plus the ModJarScanner contract.

The preceding commit kept it as a standalone @Deprecated facade because it is
published API and the adopted compatibility policy asks for one major release of
grace. Griefed's call to drop it now: scanners are not a pf4j extension point,
so a plugin could subclass the helper but never register the result. The facade
protected a subclass that could not have been wired into anything.

getJarJson moves back inline on JsonDescriptorScanner, so the internal
readJarJson that existed only to keep the two from drifting goes with it.

Recorded as a break in the root CLAUDE.md compatibility table rather than
papered over, and REFACTOR-LOG notes the shape of the override: the policy
protects reachable plugin surface, and this was not.

api 302 (1 skip), clientside 88 — 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>
Test counts (app 80 -> 88) and the durable facts from the cleanup:

- serverpackcreator-app/CLAUDE.md gains VersionCheckerTest and the channel-blind
  pre-release quirk it pins, plus the LAMBDA_SUFFIX pin on MigrationManagerTest.
  The quirk is written down because it is pinned rather than fixed — someone
  will eventually read that test and wonder whether the behaviour is intended.
- REFACTOR-LOG records which four findings were NOT applied and why, so the next
  Qodana run does not re-litigate them: one does not compile, three would trade
  speaking names for positional destructuring on an eight-field data class.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The last five Qodana High findings that were not the deliberate 6.0.0
deprecations.

KDocUnresolvedReference x4, ClientsideModels.kt: DeclaredSupport's doc pointed
at [Project.clientSide] / [Project.serverSide]. There is no Project type — it is
ProjectFiles, and the links have been dangling since the type was named. Worth
more than a lint fix: that paragraph is the one explaining DeclaredSupport must
be read as a PAIR rather than as a verdict, which is the distinction the whole
confidence model rests on, and it pointed readers at nothing.

RedundantInnerClassModifier x1, MigrationManager.MigrationMessage: the class
never touches its outer instance — it reads only its own three constructor
parameters — so `inner` bought nothing but a hidden reference to the enclosing
MigrationManager. Now a plain nested class.

That changes how it is constructed, so the one test that builds one directly
moves from `manager.MigrationMessage(...)` to
`MigrationManager.MigrationMessage(...)`, and its now-unused manager local goes.
Every assertion is byte-identical — this is the reference-only carve-out
CLAUDE.md records for the refactor label, not a behaviour change. The three
construction sites inside MigrationManager itself are unqualified and unaffected.

MigrationMethods keeps its `inner`: it genuinely uses the outer instance.

app 88, clientside 88 — full build 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>
Turns the two pins from the previous commit green.

Pre-release comparison ignored everything except the number after the dot, which
produced two wrong answers:

1. A beta did not supersede an alpha of the same version. isPreReleaseNewer
   compared alpha.5 against beta.3 as 3 > 5, so an alpha user was offered a beta
   only when its number happened to be higher — alpha.2 got beta.3, alpha.5 got
   nothing at all with the same two releases published. Now channel first
   (alpha < beta < release), number only as the tie-break, via preReleaseChannel
   and preReleaseNumber.

2. The latest of a channel was not the newest version. latestBeta/latestAlpha
   kept a candidate only if it was BOTH semantically newer-or-equal AND
   higher-numbered, so 3.2.0-beta.1 lost to 3.1.0-beta.3 and was never offered.
   Both scans now use isVersionNewer: semantic version first, pre-release
   ordering only as the tie-break within one version.

Whether (2) reached a user depended on the order the repository returned
versions in, which nothing guarantees. It is reachable: with an oldest-first
list, a 3.1.0-beta.1 user is offered 3.1.0-beta.3 while 3.2.0-beta.1 exists.

The explicit "a beta is never offered an alpha of the same version" guard in
isNewAlphaAvailable is now dead — the channel ordering rules it out on its own —
so it is gone, replaced by a comment saying where the rule moved to.
aBetaIsNotOfferedAnAlphaOfTheSameVersion stays green across the removal, which
is what makes the removal provable rather than argued.

preReleaseNumber also no longer throws on a version carrying no pre-release
suffix: the old split-and-index raised IndexOutOfBoundsException, which
checkForUpdate does not catch (it catches NumberFormatException only). It yields
0 instead, and a plain release already outranks anything numbered by channel.

Swept up while in the file: KDoc on the abstract refresh() and check(), the two
members dokka reported as Undocumented. Pre-existing, not introduced here.

app 89 — full build green.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Supersedes the "pinned as-is, not fixed" note from earlier today now that the
decision was made and the fix landed.

- serverpackcreator-app/CLAUDE.md: replaces the quirk description with the rule
  that now holds (channel-first ordering, version-first latest-of-channel), and
  flags the removed isNewAlphaAvailable guard as a landmine — weaken the channel
  ordering and the "a beta is never offered an alpha" rule disappears with it,
  with only one test to say so.
- REFACTOR-LOG: the blow-by-blow, including why the second pin took three
  attempts to make honest. Two fixtures passed against the broken code — one
  because it was newest-first, one because isUpdateAvailable falls through to
  latestVersion() and masks a wrong latestBeta. Committing either would have
  produced a guard that asserted nothing.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
REFACTOR-AUDIT.md supersedes the previous closed-out audit (6a88369e6) with the
one for this branch, now carrying remediation status.

H-1 (HIGH) — FIXED by rebuilding the branch from d13252234. Four -api production
refactors had been swept into a commit labelled `test(app)` by a `git add -A`,
while the `refactor:` commit that names and explains all four contained none of
them. ec79084bd now carries only VersionCheckerTest.kt; 4e1669456 carries all
ten files its message describes. The rebuilt tree at that point is identical to
the original 25b83e8e9 tree, and the final tree differs from the pre-remediation
tip by exactly the two new test files.

M-3 (MEDIUM) — FIXED, and inserted at the right point rather than appended.
EventService and RunConfigurationService had zero coverage: their controller
tests mockk() them away entirely. d603378d9 adds 13 tests and sits BEFORE the
commit that halves their repository lookups, so they were verified green against
the two-lookup code they characterize and stayed green when the change was
replayed on top — which is what turns "almost certainly equivalent" into
evidence rather than an argument.

Test counts: app 88 -> 102, 728 total. serverpackcreator-app/CLAUDE.md gains the
durable fact behind M-3: a green controller test says nothing about its service,
because the service is mocked.

M-1, M-2, M-4 through M-6 and the three LOWs remain open and accepted; each is
marked as such in the report.

Full ./gradlew build green.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Signed-off-by: Griefed <griefed@griefed.de>
6026f3640 closes audit finding M-2 more broadly than it asked: the exception is
now logged with its type and stack trace from the shared DescriptorScanner catch,
so all five scanners gain what only ForgeAnnotationScanner used to have, and the
message directs the reader to the mod-author.

Records the measured consequence alongside it, since it is the kind of thing that
is invisible until a user's log fills up: one :serverpackcreator-api:test run
emits 159 such lines — 80 NullPointerException (jar carries no descriptor for the
scanner in use), 37 ZipException (unreadable archive), 28 ScanningException
("No dependencies specified.", i.e. an ordinary mod with no dependency block).
The Quilt arm scans every jar with both the Quilt and Fabric scanners by design,
so a Quilt pack emits one trace per Fabric-only jar and vice versa. Noted with
the narrow follow-up should it prove noisy, not acted on.

Also drops a stray "merge all " that had landed at the top of the file.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Qodana report review, modscanning generification, and the defects both turned up.

FIXES

- Forge scanner selection read the Minecraft minor component on its own, so 26.2
  (the newer YY.x.y scheme) read as the 1.2 era and every modern Forge pack was
  scanned with the 1.12-and-older annotation scanner. Auto-exclusion silently did
  nothing on Forge 26.x while logging one ERROR per mod. The defect was present
  in ModListCompiler AND, independently, in -clientside's MetadataScanner. Both
  now compare every component through SemanticVersionComparator.
- Pre-release ordering ignored the channel, so a beta did not supersede an alpha
  of the same version: alpha.2 was offered beta.3 (3 > 2) while alpha.5 was
  offered nothing at all. And latest-of-channel required a candidate to be both
  newer-or-equal and higher-numbered, so 3.2.0-beta.1 lost to 3.1.0-beta.3.

GENERIFICATION

modscanning had five scanners each carrying its own copy of the same loop, an
abstraction nobody outside -api could see, and a loader->scanner dispatch
duplicated across a module boundary. Now: ModJarScanner (the public contract),
DescriptorScanner (owns the walk-the-jars loop and the one-ScannedMod-per-input-
jar guarantee), JsonDescriptorScanner, FabricFamilyScanner (what Fabric and Quilt
genuinely share), QuiltPackScanner (the two-descriptor merge), and
ModScanner.scannerFor as the single dispatch both callers now use.

BREAKING: JsonBasedScanner is removed. A subclass must extend
JsonDescriptorScanner instead - same getJarJson, plus the scanning contract.
Recorded in the root CLAUDE.md compatibility table.

QODANA

All 58 findings resolved or consciously declined: 13 High cleared, 41 of 45
Moderates applied. Four declined with reasons recorded in REFACTOR-AUDIT.md - one
does not compile, three would trade speaking names for positional destructuring
on an eight-field data class.

TESTS

621 -> 728. Three units that had zero coverage now have it: VersionChecker,
EventService and RunConfigurationService. Every behaviour change was pinned red
in its own commit before its fix.

AUDIT

An audit of the branch (REFACTOR-AUDIT.md) found one HIGH and six MEDIUM
convention violations. H-1 (production refactors mis-staged into a test commit),
M-2 (logging behaviour changed inside a refactor) and M-3 (two untested services
changed blind) are fixed; the rest are open and accepted with reasons.
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, since 6026f3640) 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>
A scan-failure ERROR with a stack trace was being written for jars that are
simply not this scanner's business. Every scanner is handed the whole
mods-directory, and a Quilt pack is deliberately scanned by BOTH the Quilt and
the Fabric scanner, so one of the two finds nothing in every single-format jar —
a 150-mod Quilt pack could write hundreds of traces during a successful
generation, burying the failures that matter.

The absence is now explicit rather than inferred: getJarJson and getConfig check
for the entry and raise MissingDescriptorException, which DescriptorScanner's
catch maps to DEBUG. Everything else keeps the ERROR and the stack trace it
gained in 6026f3640.

Measured over a full :serverpackcreator-api:test run, scan-failure ERROR lines:

  before this branch   159   80 NullPointerException, 37 ZipException,
                             28 ScanningException
  after                 51   37 ZipException (corrupt archive),
                             14 ParsingException (malformed TOML)

Both survivors are real defects in a jar and stay loud. The 80 vanish because a
missing entry no longer NPEs, the 28 because the previous commit stopped
treating "declares no dependencies" as an error at all. Nothing is muted that
describes an actual failure.

Two API notes, both additive:
- MissingDescriptorException extends IOException, which the descriptor readers
  already declared, so an existing `catch (IOException)` is unaffected.
- DescriptorScanner.read is promoted from protected to public. The distinction
  between what it throws is the meaningful part and is only observable there —
  scan() flattens both outcomes to a default entry by design — so a caller that
  wants to know *why* a jar yielded nothing now can, and the test pins it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
- serverpackcreator-api/CLAUDE.md: two durable rules — a jar carrying no
  descriptor is not a failure and must not be logged as one (with the 159 -> 51
  measurement and what the 51 survivors are), and an absent [[dependencies]]
  block means no dependencies rather than an error. Plus why
  DescriptorScanner.read is public.
- CLAUDE.md: a compatibility-table row for MissingDescriptorException and the
  read() visibility change, both additive, and the removal of the internal
  ScanningException.
- REFACTOR-LOG: the blow-by-blow, including the part worth keeping — the
  "No dependencies specified." case was traced through before being called a
  defect, and turned out to lose real data (the parsed modId) with no reachable
  consequence, because such a mod is always SERVER and the lost id is only
  joined on for disabled mods.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Scan-failure logging: fix the noise at its source, keep real failures loud.

6026f3640 restored the exception and stack trace on the shared DescriptorScanner
catch, which was right for real failures and wrong for most of what reached it —
159 ERROR lines with stack traces in a single api suite run, the majority
describing nothing anyone could act on. Two causes, both fixed rather than muted:

- ForgeTomlScanner raised ScanningException for a mods.toml with no
  [[dependencies]] block, which is an ordinary descriptor. The raise aborted
  read() and replaced the already-parsed modId with the filename. No pack was
  ever affected (such a mod is SERVER either way, and the id is joined on only
  for disabled mods) but it discarded good data and shouted about a normal mod.
  Now an empty map; ScanningException is deleted, it had no other thrower.

- A jar carrying no descriptor for the scanner in use surfaced as an NPE,
  indistinguishable from a genuine failure. Every scanner is handed the whole
  mods-directory and a Quilt pack is scanned by both the Quilt and Fabric
  scanner by design, so this is the common case, not the exception. It is now
  explicit (MissingDescriptorException, extending IOException so the readers'
  @Throws contract is unchanged) and logged at DEBUG.

Measured over a full :serverpackcreator-api:test run, scan-failure ERROR lines:

  before  159   80 NullPointerException, 37 ZipException, 28 ScanningException
  after    51   37 ZipException (corrupt archive), 14 ParsingException
                (malformed TOML)

Both survivors are real defects in a jar and keep the ERROR and the stack trace.

Additive API: MissingDescriptorException, and DescriptorScanner.read promoted to
public because which exception it throws is the meaningful part and scan()
flattens both outcomes to a default entry by design.
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>
The catalog held 21 entries, all of them build plugins, while all 62 library
coordinates were hardcoded strings spread across five module build files —
backwards from what a catalog is for, and the reason the version splits below
could happen unnoticed.

50 distinct artifacts are now catalog entries. Only two hardcoded coordinates
remain in the tree and both are commented-out lines (jneedle, springdoc).

Verified by diffing resolved dependency graphs (runtimeClasspath +
testRuntimeClasspath) before and after, per module:

  api             0 changed lines
  clientside      0
  grinder         0
  app             0
  plugin-example  158  <-- the intended alignment, below

So the migration is provably inert for four of five modules.

Two version conflicts existed and are resolved to the majority, not to the
newest — this branch consolidates, it does not upgrade:

- kotlin-test-junit5: plugin-example pinned 2.4.10, everything else 2.3.21. That
  one pin was dragging plugin-example's WHOLE Kotlin stack to 2.4.10 by conflict
  resolution — kotlin-stdlib included — so the module compiled with the 2.3.20
  compiler against a 2.4.10 stdlib. Now 2.3.21 like the rest.
- junit-platform-launcher: plugin-example 6.1.2, everything else 6.1.0 -> 6.1.0.

Kotlin versions are now two entries instead of five hand-synced strings:
`kotlin` (the compiler plugin, 2.3.20) and `kotlinLibs` (the runtime/test
libraries, 2.3.21). They are deliberately separate and the catalog says why —
bumping the compiler is a different decision from bumping the libraries, and
that is a change for its own branch. kotlinAllOpen and kotlinJpa still carry
their own duplicate of the compiler version; folding those into version.ref
belongs with that same bump.

./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>
Definition-of-done paperwork for the five preceding commits. Adds a "Build
layout" section to the root CLAUDE.md covering where things are now declared and,
more importantly, which simplifications NOT to retry:

- repositories are centralized with FAIL_ON_PROJECT_REPOS, and buildSrc keeps its
  own because it is a separate build
- the catalog is at gradle/libs.versions.toml, and buildSrc does not inherit it
  (verified, with the exact failure recorded)
- why the Kotlin version is two entries rather than one
- only -api publishes, and why that must not move back into java-conventions
- the convention-plugin graph, which nothing previously wrote down
- a landmine on doing filesystem work in a configuration block

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>
REFACTOR-AUDIT.md replaces the previous closed-out audit (the modscanning one,
already merged into develop with its findings resolved or accepted).

No HIGH findings across the two commits. One MEDIUM: 24390e3ba removes four
properties from the MAIN application.properties -- live web-service config, not
build logic -- and nothing in the repository could have caught a mistake, since
no test asserts on those keys and no test boots a real Spring context. It is
MEDIUM rather than HIGH because the commit measures deadness (0 @Transactional,
0 JPA/JDBC types, 0 hibernate/tomcat-jdbc/h2 on the runtime classpath) instead
of asserting it, which makes the removal inert by construction -- but that is
the measure-don't-test carve-out the root CLAUDE.md grants to build logic, and
these are application properties.

Three LOW: docs bundled into the chore commit against this session's own
pattern, a latent behaviour change under a chore label, and a trailing-newline
artifact that makes the diff look like it touched a property it did not.

The report also records what was checked and found clean, so a later reader can
see the absence of findings was measured rather than assumed: the compiler-gated
README snippets were verified untouched by diff, no test file is touched by
either commit, and the bug found mid-task was surfaced before being touched and
fixed in its own commit.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Refresh stale README content. Every link was checked first and all are live --
16 internal links resolve, the 8 project-owned external ones return 200 -- so
the staleness was entirely in content.

- 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
  apart silently again. The ten that were missing: -config, --destination,
  -feelinglucky, -withallinconfigdir, --home, -lang, and the four clientside
  verbs -scan, -clientsidereport, -verifyclientside, -clientsideapply. Verified
  after the edit: 17 of 17 documented.
- "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. Reworded to what that first run is for and what actually goes
  wrong, deliberately without asserting an error message nobody reproduced.
- The Gradle-Groovy dependency snippet was fenced as kotlin while containing
  Groovy syntax.
- Dropped the obsolete `version: '3'` key from the docker-compose example.
- Fixed a link whose text read docker/docker/init-mongo.js.
- Documented SPC_CONFIGURATION_AIKAR and SPC_LOG_LEVEL, both present in the
  shipped compose-files but absent from the table.

The Kotlin API snippets are untouched and remain compiler-gated by
ReadmeExamplesTest, which passes along with ShippedResourceTrackingTest.
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>
Three tests over the configLocationArgument() extracted in the previous commit,
covering the thing that could not be asserted before: which property-files
Spring reads, and in what order.

  theConfigLocationChainListsAllEightLocationsInOrder
  theClasspathDefaultsAreNotOptional
  theOverridesFileIsReadAfterThePropertiesFile

Order is the point, not the contents. Later locations win, so the two
overrides.properties entries must stay last — that is the file the docker
image's init-spc-config script composes SPC_DATABASE_* into, and therefore where
spring.data.mongodb.uri comes from in a container. If it stopped being last an
earlier file would beat it; if it dropped out, the URI would never be read, and
per this module's landmine a missing URI is a hard startup failure rather than a
degraded connection.

The classpath defaults are asserted NOT optional: they ship inside the jar, so a
missing one is a broken build rather than a deployment choice.

Teeth checked rather than assumed — swapping overrides.properties ahead of
./serverpackcreator.properties fails the ordering test with the full before/after
lists, and it passes again on restore.

app 102 -> 105.

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>
Task actions that close over the build script cannot be serialized, so the
configuration cache refused to store an entry. Measured on
`build --dry-run --configuration-cache`:

  20 problems, 13 unique   ->   7 problems, 1 unique

and the single remaining one is third-party (:generateLicenseReport), handled
two commits later. Concretely, `gradlew test --configuration-cache` now reports
"Configuration cache entry stored" where it used to fail, and configuration time
for that entry point drops from ~4.75s to ~2.02s warm — 2.3x.

Four causes, all the same shape:

- java-conventions' processTestResources called the script-level
  escapeForProperties() inside its filter closure. Both values are known before
  the filter, so they are escaped up front and the closure captures only Strings.
- java-conventions' test and clean called the script-level cleanup(). Calling ANY
  function declared in a build script from a task action captures the script, so
  passing the directory in as a parameter was not enough — the function itself
  had to move, to buildSrc/src/main/kotlin/de/griefed/common/gradle/TestHome.kt.
  Calling into a compiled class captures nothing. The commentary about sparing
  manifests/ and not touching the Preferences node moved with it.
- -app's test.doFirst read projectDir and called Project.mkdir; it now closes
  over a File.
- -plugin-example's processResources expanded plugin.toml from the script's own
  properties. Hoisted into a local map.

Behaviour verified rather than assumed. The test-home cleanup is the risky part,
since sparing manifests/ is load-bearing — a wiped cache silently re-downloads
and eats newly-fetched versions. Planted a marker plus a manifests entry and
forced the task to run:

  marker wiped         yes
  manifests preserved  yes
  manifests entries    14 -> 14

Worth recording: the first attempt at that measurement was worthless, because
the test task was UP-TO-DATE so doFirst never ran and the marker "survived". It
needs --rerun-tasks to mean anything.

TestHome.prepare also uses `?.listFiles()` where cleanup() chained off a
nullable Java return as if it were non-null. Unreachable — mkdirs() runs first —
but it is a real difference, so it is stated rather than left in the diff.

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>
M-1 resolved: WebServiceContextTest boots the real application context, which is
exactly the remedy the finding suggested — and it was only writable because the
malformed Mongo URI had been removed first.

L-1 and L-2 accepted. Both describe commits already merged into develop, and the
honest remedy for a merged label is a note rather than rewriting shared history;
same call the root CLAUDE.md records for 358675fbf.

L-3 moot: the trailing newline that made the diff noisy is the correct state and
is already present.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Replace the web module's non-test with a real one, and pin the property-file
chain it depends on.

WebServiceTest was @SpringBootTest(classes = [WebServiceTest::class]) — a context
of exactly one class, itself — with an empty body. It could not fail for any
ServerPackCreator reason, which is why the module's CLAUDE.md said to replace
rather than extend it.

WebServiceContextTest boots the REAL application context and asserts the
controllers, the services and ServerPackCreator's own properties are wired. It
needs no database, which was checked rather than assumed: the MongoDB driver
connects lazily, so every bean is constructed and every injection point resolved
with nothing listening on 27017. Teeth verified — removing @Service from
EventService fails it with NoSuchBeanDefinitionException.

Along the way, start()'s eight-location --spring.config.location chain was
extracted (it was welded to SpringApplication.run and therefore unassertable)
and pinned in order. Order is the point: later locations win, so the two
overrides.properties entries must stay last — that is where a container's
spring.data.mongodb.uri arrives from, and a missing URI is a hard startup
failure rather than a degraded connection.
Make our own build code configuration-cache compatible, and finish the lazy-task
conversion.

Task actions that close over the build script cannot be serialized, so the cache
refused to store an entry. Measured on `build --dry-run --configuration-cache`:

  20 problems, 13 unique   ->   7 problems, 1 unique

The one that remains is third-party (:generateLicenseReport holds a Project
reference). `gradlew test --configuration-cache` now stores an entry where it
used to fail, and that entry point's configuration time drops from ~4.75s to
~2.02s warm.

The subtle part: passing a directory into the script-level cleanup() was not
enough, because calling ANY function declared in a build script from a task
action captures the script. The function had to move into compiled buildSrc code
(TestHome.kt). Behaviour verified by planting a marker and a manifests entry —
marker wiped, manifests preserved, 14 -> 14 — with the caveat that the first such
measurement was worthless because the task was UP-TO-DATE and doFirst never ran.

An audit of the branch asked for one commit to be split, and splitting it earned
its keep twice over:

- It PROVED what the audit could only reason about: with the capture removals
  alone the count is already 7/1-unique, so converting plugin-example's
  configuration-time copies contributed nothing to cache compatibility. That
  change stands on its own merits (an UP-TO-DATE processResources no longer
  rewrites the source tree) and is now its own commit.
- It DISPROVED a claim in the original message. notCompatibleWithConfigurationCache()
  on the license report was said to turn `build --configuration-cache` from
  FAILED to SUCCESSFUL. Measured with the task forced to run, with and without:
  36 problems, 6 unique, BUILD SUCCESSFUL, entry discarded — identical. It does
  nothing here, so it is dropped rather than shipped on an unearned rationale.

Also surfaced, not silently fixed: serverpackcreator-plugin-example has no
CHANGELOG.md at its root, so one of the three copies had been dead — yet
src/main/resources/CHANGELOG.md is a tracked 14 KB file that ships in the
plugin jar and nothing generates. Left alone; deleting a tracked file that
reaches users is not a build cleanup.
The build's structure was documented only in CLAUDE.md, which is written for the
assistant rather than for a person arriving at the repository — and
CONTRIBUTING.md's build section was five lines, two of which were wrong.

BUILD.md covers what a newcomer actually needs: the commands worth knowing, the
module graph and its inward dependency direction, the convention-plugin
hierarchy (which is why a module build file can be 25 lines), where repositories
and versions are declared and what enforces that, the things that surprise
people, and a troubleshooting section.

Every factual claim was checked against the build rather than written from
memory, which caught three errors:

- CONTRIBUTING.md said to build with `build --info --full-stacktrace`, missing
  the `./gradlew`, and credited a "Build All" task that does not exist —
  verified against `gradlew tasks --all`.
- The foojay toolchain resolver is applied in buildSrc/settings.gradle.kts but
  NOT in the root settings, so Gradle auto-provisions a JDK for the build's own
  code and not for the modules. Without a local JDK 21 the main build fails with
  "No matching toolchains found" instead of downloading one. Documented as a
  trap with the one-line fix, rather than changing toolchain behaviour inside a
  docs commit.
- `:serverpackcreator-app:run` does not exist — `-app` applies the Spring Boot
  plugin, not `application`, so the task is `bootRun`. Root CLAUDE.md carried
  the same wrong command and is corrected too. (`:serverpackcreator-grinder:run`
  does exist; the grinder applies `application`.)

CONTRIBUTING.md's build section is rewritten and points at BUILD.md by absolute
URL, not a relative link: CONTRIBUTING.md is one of the documents shipped inside
the -api jar and mirrored into the Writerside topics, where a relative link to a
non-shipped file would dangle. BUILD.md is deliberately NOT added to the shipped
set — it is a contributor document, not something to write into a user's home
directory.

BUILD.md's own links verified: no broken anchors, no broken file links.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
REFACTOR-AUDIT.md replaces the previous audit, whose M-1 was acted on — that
commit was split into three, one part dropped entirely after measurement
disproved its rationale, and all of it merged into develop.

No HIGH, no MEDIUM, two LOW for this single documentation commit. The report is
explicit that most of these conventions are written for code changes and do not
apply here, rather than stretching them to produce findings.

L-1: the :serverpackcreator-app:run -> bootRun correction to CLAUDE.md rode
along instead of getting its own commit. Surfaced explicitly in the body, so
only the "own commit" half of the rule is unmet.

L-2: the foojay-resolver gap is documented but not fixed, deliberately — flagged
so that documenting a papercut does not quietly become the permanent state.

Records the five claims re-verified during the audit, including re-measuring the
configuration-cache timings (4.96s -> 2.04s, matching the ~4.75 -> ~2.02 the doc
states).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Add BUILD.md, a contributor-facing map of the build, and fix what it turned up.

The build's structure was documented only in CLAUDE.md, which is written for the
assistant rather than for someone arriving at the repository, and
CONTRIBUTING.md's build section was five lines of which two were wrong.

Verifying every claim against the build rather than writing from memory caught
three errors, all now fixed:

- CONTRIBUTING.md told contributors to run `build --info --full-stacktrace`
  (missing ./gradlew) and credited a "Build All" task that does not exist.
- `:serverpackcreator-app:run` does not exist — -app applies the Spring Boot
  plugin, not `application`, so the task is `bootRun`. Root CLAUDE.md carried the
  same wrong command and is corrected too.
- The foojay toolchain resolver is applied in buildSrc/settings.gradle.kts but
  not in the root, so Gradle auto-provisions a JDK for the build's own code and
  not for the modules. Documented as a trap; the fix follows separately.

BUILD.md is deliberately NOT added to the seven documents shipped inside the
-api jar — it is a contributor document, not something to write into a user's
home directory — which is why CONTRIBUTING.md links to it by absolute URL rather
than a relative path that would dangle in the shipped copies.
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>
Gradle now provisions a JDK for the modules, not just for buildSrc, so a
contributor with no local JDK 21 can build the project.

The foojay toolchain resolver was registered only in buildSrc/settings.gradle.kts.
Reproduced by pointing the module toolchain at an uninstalled JDK 11 —
"Toolchain download repositories have not been configured" — and verified fixed
the same way with --offline, so nothing downloaded: the error becomes "Some
toolchain resolvers had provisioning failures: foojay (... offline mode)", i.e.
the resolver is registered and tried.

Not the one-liner it was scoped as. Both builds need it and they need it
declared DIFFERENTLY: the root with `version "0.8.0"`, buildSrc without a
version. buildSrc does not inherit the root's toolchain repositories, yet by the
time its settings evaluate the plugin is already on the classpath, so asking for
a version there fails. Three other arrangements were tried and each failed with
a different error; all of them are recorded in the commit body and the asymmetry
is documented in BUILD.md and CLAUDE.md so neither declaration gets tidied away.
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>
Turns the pin from the previous commit green, and fixes the reported crash:

  Downloading and using Java temurin@25
  Run Command:  java ... -Djava.security.manager=allow -jar server.jar ...
  Error occurred during initialization of VM
  java.lang.Error: A command line option has attempted to allow or enable the
  Security Manager.

Two defects, both fixed, in all three shell templates.

1. JAVA_VERSION was stale after an install. It starts as the literal
   "do_not_manually_edit", is only set by getJavaVersion, and none of the three
   installJava call-sites re-read it — install_java.sh does not set it either.
   A pack that installs its own Java therefore reached setupForge with the
   placeholder still in place. All three templates now call getJavaVersion after
   the entire Java-check block, which covers every path through it including
   SKIP_JAVA_CHECK.

2. The guard failed UNSAFE. `numeric AND >= 24` meant an unresolvable version
   fell through to the branch that passes a flag which is fatal on exactly the
   JVMs it could not rule out. Now `NOT numeric OR >= 24`: only a Java we can
   read and that predates 24 may use the ServerStarterJar path.

Fix 1 alone would close the report; fix 2 is what stops the next unresolved-
version path from doing it again. Fix 1 also matters on its own: without it,
every pack installing its own Java would now take the self-install branch even
on Java 17, losing the SSJ path it should keep.

Deliberately NOT what was first proposed — stripping the flag from
SSJ_FORGE_ARGS. That reintroduces the silent failure this code already avoids:
SSJ needs the SecurityManager to swallow the Forge installer's System.exit(0),
and without it the pack installs, prints "The server installed successfully",
exits 0 and never launches.

Verified:
- the pin passes for all three unknown shapes (placeholder, empty, non-numeric)
- theBashTemplateDropsTheSecurityManagerFlagOnJavaThatRejectsIt still passes, so
  Java 17 and 21 keep the flag and the SSJ path
- bash -n parses the template (fish and pwsh absent on this machine, so their
  guards are matched to the bash change by construction and covered by
  ScriptTemplateMatrixIT / powerShellTemplatesParse in CI)
- the reported scenario replayed end to end: no java on PATH -> install ->
  JAVA_VERSION resolves to 25 -> self-install branch, no flag

api 305 (1 skip) — full build green.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Closes audit finding M-1. The previous fix changed three templates and only bash
was tested — the fish and PowerShell guard inversions and their added
getJavaVersion calls had nothing asserting them at all.

Adds allTemplatesResolveJavaAfterTheChecksAndFailSafeWhenItIsUnknown, in the
same source-level shape as allTemplatesUseAnAlreadyInstalledFabricLauncher...,
which exists precisely because fish and PowerShell cannot be executed on every
machine. It pins two properties per template:

- the version is resolved AFTER the Java-check block, so a pack that installs
  its own Java does not reach setupForge with JAVA_VERSION still on its
  do_not_manually_edit placeholder
- the Forge/SSJ guard is the fail-safe polarity — "not numeric OR >= 24", never
  "numeric AND >= 24"

Polarity is the half no syntax check can cover: a guard inverted the wrong way
still parses, still runs, and silently reinstates the crash.

Teeth verified per shell and per property — six mutations, six failures:

  guard un-inverted      sh FAILS   fish FAILS   ps1 FAILS
  resolve call removed   sh FAILS   fish FAILS   ps1 FAILS

The second row did NOT fail on the first attempt, and the reason is worth
keeping: the assertion searched backwards from the 32-bit-warning marker, so it
matched one of the getJavaVersion calls *inside* the check block and passed with
the post-block call deleted. It now anchors on the LAST installJava — every
install call being inside that block — and requires the resolve call to fall
between it and the marker. A pin that cannot fail is exactly what this test
class exists to avoid, so it was checked rather than assumed.

api 306 (1 skip).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Closes audit finding L-2. The previous fix put the getJavaVersion call OUTSIDE
the SKIP_JAVA_CHECK conditional, so the version is now read even when a user
asked for checks to be skipped. That consequence went undisclosed, on a setting
used by people doing something unusual.

The behaviour is right and stays. variables.txt documents the setting as
disabling "the compatibility check of your Minecraft version and the provided
Java version, as well as the automatic installation" — comparing and installing,
not looking. And reading it serves precisely the user the setting is aimed at:
variables.txt tells anyone pointing JAVA at a custom path to set
SKIP_JAVA_CHECK=true, so that user has a deliberately chosen working Java, and
resolving it is what keeps them on the ServerStarterJar path for Java 17/21
instead of being pushed onto the self-install path with everyone else.

What was missing was that nothing said so and nothing guarded it. Now:

- variables.txt says the version is still READ when the check is skipped, why
  Forge needs to know, and what happens when it cannot be read.
- theBashTemplateResolvesTheJavaVersionEvenWhenChecksAreSkipped EXECUTES the
  shipped Java-check block with SKIP_JAVA_CHECK=true against a fake Java, and
  asserts both halves: the version resolves (17 from a readable Java, empty from
  an unreadable one, which the fail-safe guard then routes away from the fatal
  flag) AND the automatic installation is still skipped, which is what the
  setting actually promises.
- serverpackcreator-api/CLAUDE.md records why the call sits outside the
  conditional, so it does not get "tidied" back inside.

Teeth verified: moving the resolve call back inside the block fails the new
test, and it passes again on restore.

api 307 (1 skip) — full build green.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Covers all four commits, superseding the first pass over the same branch. Both
of that pass's actionable findings are verified fixed here: fish and PowerShell
now have source-level pins (six mutations, six failures), and the
SKIP_JAVA_CHECK read is documented and guarded against what the setting actually
promises.

The report is its own commit rather than riding along inside the test commit
that closed M-1 — which is precisely what this pass flagged, and the third
instance of `git add -A` sweeping an unrelated file into a commit whose message
describes something else. The first instance (modscanning H-1) buried four -api
production refactors and cost a branch rebuild; this one was documentation, but
the habit is the same.

Two findings remain open and accepted: 5f4bce289 bundles the version-resolve and
the guard inversion, and d2d155927 is typed docs while carrying an 81-line
executing test and a change to a shipped template.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Fix a reported crash: -Djava.security.manager=allow reached a Java 25 VM and
stopped it from starting.

  Downloading and using Java temurin@25
  Run Command:  java ... -Djava.security.manager=allow -jar server.jar ...
  Error occurred during initialization of VM
  java.lang.Error: A command line option has attempted to allow or enable the
  Security Manager.

The Java-24 guard in setupForge was correct but never saw a version number.
JAVA_VERSION starts as the literal "do_not_manually_edit" and only
getJavaVersion fills it in; no installJava call-site re-read it and
install_java.sh never set it. So the guard protected only users who ALREADY had
a suitable Java — anyone letting SPC install its own sailed past it. Neither the
grinder nor ScriptTemplateMatrixIT could have caught it: both pre-bake Java and
never take the install path.

Two fixes, in all three shell templates:

- resolve the version after the whole Java-check block, covering every path
  through it including SKIP_JAVA_CHECK
- invert the guard from "numeric AND >= 24" to "NOT numeric OR >= 24", so an
  unresolvable version fails safe instead of choosing the branch that passes a
  flag which is fatal on the JVMs it cannot rule out

Deliberately NOT the originally proposed fix of stripping the flag from
SSJ_FORGE_ARGS: SSJ needs that SecurityManager to swallow the Forge installer's
System.exit(0), and without it the pack installs, prints "The server installed
successfully", exits 0 and never launches.

Pinned red first, reproducing the reported run command. An audit then raised two
findings, both closed on the branch:

- fish and PowerShell were changed with no test. They now have source-level pins
  in the same shape as the existing cross-template assertions, teeth-checked with
  six mutations — one per shell per property — all six failing. The ordering half
  did NOT fail on the first attempt (the assertion matched a getJavaVersion
  inside the check block); anchoring on the last installJava fixed it.
- The SKIP_JAVA_CHECK consequence was undisclosed. It is kept, because
  variables.txt promises to skip comparing and installing rather than reading,
  and because the setting's own documented user — a custom JAVA path — is exactly
  who benefits from the version being read. Now documented in variables.txt and
  guarded by a test that asserts both the resolve AND that the install is still
  skipped.
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>
Turns the previous commit green. Replaces io.spring.dependency-management with a
Gradle `platform()`.

io.spring.dependency-management turns Boot's BOM into *forced* versions that beat
every transitive request. Boot's BOM manages kotlin, kotlin-coroutines,
kotlin-serialization, jackson, log4j2, junit-jupiter and mongodb, so every bump
to those in the catalog upgraded the other modules and was silently reverted in
-app. As a `platform()` the same BOM contributes ordinary constraints, which lose
to a higher request, so the catalog wins and Boot still versions everything we do
not pin ourselves.

Measured, -api vs -app resolved runtimeClasspath (shared coordinates DIFFERING):

  at the previous commit   13 of 79
  at this commit            0 of 79

  :serverpackcreator-app:test   16 failed -> 0 failed (108 tests)

Representative before -> after in -app:
  kotlin-stdlib            2.3.20 -> 2.4.10   (catalog: 2.4.10)
  junit-jupiter-api        6.0.2  -> 6.1.3    (catalog: 6.1.3)
  jackson-databind         2.20.2 -> 2.22.1   (catalog: 2.22.1)
  log4j-core               2.25.3 -> 2.26.1   (catalog: 2.26.1)
  kotlinx-serialization    1.9.0  -> 1.11.0
  slf4j-api                2.0.17 -> 2.0.18
  kotlinx-coroutines       1.10.2 -> 1.11.0

The BOM coordinate now comes from the catalog's `springBoot` rather than
`SpringBootPlugin.BOM_COORDINATES`, which is the *Gradle plugin's* version
(`springGradle`). Those had drifted apart — 4.0.2 vs 4.1.0 — leaving Boot itself
internally inconsistent: `spring-boot` and `spring-boot-autoconfigure` resolved
4.0.2 while `spring-boot-starter-web` resolved 4.1.0. All three are now 4.1.0.

`developmentOnly` gets the platform of its own because it extends nothing;
without it the versionless devtools dependency has no version to resolve.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Separate defect from the BOM mechanism, and one a `platform()` cannot fix: a
platform only out-ranks what a module actually *requests*. -app never requested
mockk at all — it arrived transitively from springmockk — so the catalog's
version had nothing to apply to.

Measured, :serverpackcreator-app testRuntimeClasspath:
  before  io.mockk:mockk-jvm:1.14.6
  after   io.mockk:mockk-jvm:1.14.11   (catalog: 1.14.11)

The catalog comment claiming mockk is single-versioned across the build had
become false when `mockk` was bumped to 1.14.11: -api declares `libs.mockk` and
got 1.14.11, -app took springmockk's transitive 1.14.6, and the two test suites
silently ran different mockk versions. The comment in -api is corrected in the
same commit because this change is what makes it true again.

General rule, recorded in CLAUDE.md: bumping a library that reaches a module
only transitively still needs an explicit declaration in that module.

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>
Replaces the security-manager branch's audit, following this repository's
practice of REFACTOR-AUDIT.md holding the current branch's report. The previous
contents remain in history at 141dc20d0.

Second pass over this branch. The first pass raised 2 HIGH, 4 MEDIUM, 3 LOW
against a 7-commit history; that history has been rewritten and those hashes no
longer exist. Pre-rewrite state preserved at branch `backup-pre-rewrite`.

Now: no HIGH, no MEDIUM. Six of the nine findings are fixed by the rewrite, two
are accepted with reasons recorded (the published-API behaviour change, which was
Griefed's explicit decision, and one disclosed doc-drift correction), and one low
finding was fixed in place.

Two deliberate deviations are documented rather than hidden: commits 1 and 3 are
intentionally red, because a failing guard and a breaking bump are only checkable
if they land before their fixes. The cost — two red commits under `git bisect`,
and on `develop` under a rebase-merge — is stated explicitly along with the
squash-merge remedy, so the trade is Griefed's to make rather than mine to make
silently.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The Spring Boot Gradle plugin and the Spring Boot libraries are the same
product, and this project versions them from two catalog entries: `springGradle`
(the plugin) and `springBoot` (the starters). They had drifted to 4.0.2 and
4.1.0.

That drift is what made the BOM inconsistent before the platform switch:
`SpringBootPlugin.BOM_COORDINATES` resolves to the *plugin's* version, so the
BOM was 4.0.2 while the catalog-versioned starters were 4.1.0, and `spring-boot`
itself resolved 4.0.2 against `spring-boot-starter-web` at 4.1.0. The BOM no
longer comes from the plugin, so the mismatch is no longer load-bearing — but
two entries naming one product at different versions is a trap left armed.

Verified at this commit: :serverpackcreator-app:test 108 tests, 0 failures;
:serverpackcreator-app:bootJar packages.

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>
Removes the plugin from buildSrc's compile classpath and its version and alias
from the catalog. This CHANGES what buildSrc resolves — an artifact leaves the
classpath — which is why it is separate from the behaviour-preserving routing in
the previous commit.

The plugin has been applied nowhere since `3ab1abed6` replaced it with a Gradle
`platform()`. Confirmed before removal: no `id("io.spring.dependency-management")`
and no `dependencyManagement { }` block survives anywhere outside comments.

Re-adding it would silently restore forced BOM versions and re-break every
catalog bump for `-app`; the landmine in `spring-conventions` explains why. That
is the reason this is a removal rather than an unused entry left lying around.

Verified: `./gradlew build` SUCCESSFUL.

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>
Behaviour-preserving. All four already held 2.4.10 after the previous commit;
this deletes `kotlinAllOpen`, `kotlinJpa` and `kotlinLibs` and points their seven
`version.ref` sites at `kotlin`:

  plugins    kotlinJvm, kotlinAllOpen, kotlinJpa
  libraries  kotlinTestJunit5, kotlinBom, kotlinStdlib, kotlinReflect

JetBrains ships the compiler, the allopen/jpa/spring compiler plugins and the
stdlib/reflect/test libraries from one release train, so four entries could only
ever drift apart — which is exactly what had happened, and what the previous
commit repaired. One entry makes the drift unrepresentable rather than merely
fixed.

Verified: `./gradlew build` SUCCESSFUL; kotlin-stdlib still resolves 2.4.10, as
it must — every version value is unchanged, only the number of places declaring
it.

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>
Replaces the second-pass report, which described a 6-commit history that no
longer exists. Follows this repository's practice of REFACTOR-AUDIT.md holding
the current branch's report; earlier passes remain in history.

Third pass raised 4 MEDIUM and 2 LOW. All six are now resolved: the three mixed
build commits were split into behaviour/pure pairs, the documentation for them
was consolidated into one `docs:` commit, and the missing `./gradlew build`
verification was added — the last of which had already caught a real regression
(Kover 0.9.1 against KGP 2.4.10).

One LOW remains open and is NOT from this branch: `dokkaGeneratePublicationHtml`
reads `build/generated` from `compileJava`/`compileTestJava` without declaring
the dependency. Reproduced deterministically 3/3, and reproduced on untouched
develop in a clean worktree, so it is pre-existing. Reported rather than fixed,
because it is out of scope here and the remedy deserves its own commit.

Verified at this commit: ./gradlew build SUCCESSFUL, 91 tasks.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`dokkaGeneratePublicationHtml` reads `build/generated` — `suppressedFiles`
points at it, to keep i18n4k's generated `Translations` out of the docs — but
never declared the Java compilations that also write there. Gradle fails the
build whenever both land in one task graph:

  Task ':serverpackcreator-api:dokkaGeneratePublicationHtml' uses this output of
  task ':serverpackcreator-api:compileJava' without declaring an explicit or
  implicit dependency.

The Javadoc publication already had exactly this `dependsOn`; only the HTML half
was missing it, so this is the second occurrence of one bug. The two are now
configured together rather than side by side, because fixing one and forgetting
the other is how it arose.

Measured, `:…-api:dokkaGeneratePublicationJavadoc` + `:…-app:…Javadoc` +
`:…-api:dokkaGeneratePublicationHtml` from a wiped build/dokka:

  before  3 of 3 runs FAILED
  after   3 of 3 runs SUCCESSFUL, 185 index.html generated

Pre-existing, not introduced by this branch: the same failure reproduces on
untouched develop (a7717e8a9) in a clean worktree. It never surfaced in normal
use because `build` runs only the Javadoc publication, via `finalizedBy` in
-api, so the HTML task was never scheduled alongside the compilations.

Fixed in `dokka-conventions` rather than in -api so every module applying the
convention gets it.

Verified: ./gradlew build SUCCESSFUL, 91 tasks.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The fourth-pass report listed the dokka undeclared-dependency gap as open and
out of scope. Griefed asked for it on this branch, so it is now fixed by
dbcb80caf and the report says so, with the before/after measurement.

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)
and e55ddfe8e (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.
Signed-off-by: Griefed <griefed@griefed.de>
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>
Signed-off-by: Griefed <griefed@griefed.de>
Signed-off-by: Griefed <griefed@griefed.de>
Signed-off-by: Griefed <griefed@griefed.de>
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>
Turns the two stall-guards from the previous commit green, **unedited**.

Every outbound call now goes through `WebUtilities.openTimedConnection` /
`openTimedStream`, which apply the configured timeouts -- the same single-source-of-truth
rule this module applies to SupportedModloaders and ModScanner.scannerFor, and for the same
reason: copies drift, and the copy without the timeout is the one that strands a user.
Routed: isReachable, downloadFile, getResponseAsString, getResponseCode,
createHasteBinFromString, and VersionMeta's two openStream sites.
getResponseAsString/getResponseCode have no callers today; routing them anyway keeps a
future caller from reintroducing an untimed call.

**The opener returns `URLConnection`, not `HttpURLConnection`, and that is load-bearing.**
The timeout setters live on `URLConnection`, so narrowing gains nothing -- and it costs
correctness: `downloadFile` is published API accepting any `URL`, a `file:` URL yields a
`FileURLConnection`, and casting that throws `ClassCastException`, which is not an
`IOException` and so escapes `downloadFile`'s error handling entirely. Caught by
`MinecraftServerManifestCooldownTest` (it downloads from a `file:` URL on purpose); now
pinned directly by `aNonHttpUrlCanStillBeDownloaded`.

The two guards added here cover what the stall-guards cannot observe:
`openedConnectionsCarryTheConfiguredTimeouts` proves the *configured* values are applied
(a connect-timeout only shows itself against an unroutable host, which no test can rely
on), and `aNonHttpUrlCanStillBeDownloaded` pins the paragraph above.

Measured: api 309 -> 319, app 108, clientside 88, 1 skipped, all green. Compiler warnings
in -api: 21 before, 21 after.

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>
Guards the WARN-not-ERROR behaviour the previous commit deliberately preserved.

Dropping the reachability pre-check moved "the host is unreachable" from a probe result
onto the IOException path. Left alone, that would have logged twelve ERRORs with stack
traces every time a user launches without a network -- which is precisely how a genuine
manifest failure gets buried. So connection failure and unparseable-manifest are caught
separately, and this pins the consequence: an unreachable host leaves a present manifest
untouched and does not throw.

No red state to show, and that is the honest reason it is its own commit rather than
bundled with the change: the behaviour it guards was *preserved*, not introduced, so
there is no version of the code on this branch where it fails. It is a characterization
test for a decision that is easy to undo by accident.

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>
theFallbackRefreshGivesUpOnAServerThatAcceptsButNeverResponds  FAILED (>15s)

Found by auditing this branch, which claimed "every outbound call now goes through one
opener" -- and does not. `UpdateConfig.updateFallback` still calls
`updateUrl.openStream()` with the JDK's infinite default timeouts
(`UpdateConfig.kt:117`).

This one is **earlier** on the startup path than the manifest checks already fixed:
`ApiProperties`' own `init` block calls `loadProperties` (`ApiProperties.kt:1337-1340`),
which calls `updateFallback()` at `:1025`. So merely *constructing* `ApiProperties`
fetches the update URL, before `stageOne` has finished and long before `stageTwo` reaches
`VersionMeta`. Not theoretical -- a GUI run during this work logged
`INFO (ApiProperties.kt:1026) - Fallback lists updated.` twice.

Same shape as `WebUtilitiesTimeoutTest`: a loopback server that accepts and never
answers, a bound far above any configured timeout, and a bounded wait on a daemon thread
so an infinite one fails this test rather than hanging the suite.

Deliberately written against the **existing** three-argument constructor so the fix can
turn this exact guard green without editing it. That is the property the first
timeout pin on this branch failed to have: its `mockk(relaxed = true)` fixture answered
0 for the timeouts -- the JDK's "wait forever" -- so it stayed red against the fixed
code until the fixture was changed, and `checkout pin && apply fix` shows red -> red
rather than red -> green. Not repeating that here.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`WebUtilities.openTimedConnection` now delegates to a top-level
`URL.timedConnection(connectTimeout, readTimeout)`. Behaviour-preserving: same two
setters, same `URLConnection` return type and the same reason for it, same call sites.

Why extract rather than let the next caller reach for `openTimedConnection`: the
settings groups **cannot** use it. `WebUtilities` is constructed *from* `ApiProperties`,
so a group living inside `ApiProperties` that depended on `WebUtilities` would close a
construction cycle. Without a shared function the alternative is a second copy of
"create connection, set two timeouts" -- which is exactly the equal-valued-copy trap
this module documents for `SupportedModloaders`, `modFileEndings` and `zipCheck`, and
the thing the "never open a connection outside the opener" landmine is meant to prevent.

Now the mechanism lives in one place and the *values* arrive by two routes: through
`WebUtilities` from `ApiProperties` for ordinary callers, and directly from a group's own
`NetworkConfig` for the settings groups.

New exported surface in a published module, deliberately: one top-level extension,
documented, additive.

The whole api suite is green apart from `UpdateConfigTimeoutTest`, which is the red pin
from the previous commit and is turned green by the next one.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Turns the previous pin green, **unedited** -- `checkout pin && apply fix` shows
red -> green here, which is the property audit finding F1 records the first timeout pin
on this branch as lacking.

`UpdateConfig.updateFallback` opened `updateUrl.openStream()` with the JDK's infinite
default timeouts. It now goes through `URL.timedConnection` with this group's own
`NetworkConfig` values, so it is bounded by the same configurable settings as every other
outbound call.

This is the earliest network call in the process: `ApiProperties`' `init` calls
`loadProperties` (`:1337-1340`), which calls `updateFallback()` (`:1025`), so it ran while
`ApiProperties` was still being constructed -- before `stageOne` finished, and before
`stageTwo` reached the manifest checks that were bounded first. A silent host blocked API
construction itself.

`NetworkConfig` is instantiated inside `UpdateConfig` from the `PropertyStore` it already
holds, rather than added as a constructor parameter. Not a second source of truth:
`NetworkConfig` keeps no state and reads the store on every access, so both instances
answer identically. It also keeps the signature stable, which is what let the pin from
the previous commit go green without being touched -- the trap F1 describes.

Measured: api 337 -> 338, app 127, clientside 88, all green.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
anUpdateCheckGivesUpOnAServerThatAcceptsButNeverResponds  FAILED (>45s)

The second half of audit finding F3. `VersionChecker.getResponse`
(`VersionChecker.kt:328`) opens its connection with the JDK's infinite default timeouts,
so a host that accepts and then goes silent blocks the update check with no bound. It is
how both the GitHub and GitLab checkers reach their APIs, and the GUI runs a check at
startup -- the run captured during this work shows `GitHubChecker` logging its version
list there.

`getResponse` is `protected`, so the guard exposes it through a canned subclass, the same
approach `VersionCheckerTest` already uses to exercise the comparison logic offline.

The 45 s bound is deliberate and is the interesting part. `VersionChecker` has no settable
timeout until the fix adds one, so this guard cannot shorten what it measures -- and a
guard may not depend on the thing it guards. It therefore has to sit comfortably *above*
the shipped 15 s default read-timeout, because a bound equal to that timeout races between
"gave up as configured" and "waited forever" and would decide the outcome by scheduling.
The cost is ~15 s for this one test once the request is bounded.

Written against the existing no-argument constructor so the fix turns this exact guard
green without editing it -- the property audit finding F1 records the first timeout pin on
this branch as lacking.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Turns the previous pin green, unedited. `VersionChecker.getResponse` goes through
`URL.timedConnection` instead of a bare `openConnection()`, so a silent release-API host
fails the check instead of blocking it. The GUI runs a check at startup, so this was a
user-facing unbounded wait.

Threading the configured values needed a decision. `VersionChecker` is abstract with a
no-argument constructor and no `ApiProperties`; giving it one would change both subclass
constructors and every construction site. Instead it exposes `connectTimeout` /
`readTimeout` as `var`s, and `UpdateChecker` -- which does hold `ApiProperties` -- sets
them before `refresh()`, which is what issues the first request. That also keeps the pin's
no-argument fake compiling, which is what let the guard go green untouched.

Their defaults come from new `NetworkConfig.DEFAULT_CONNECT_TIMEOUT` /
`DEFAULT_READ_TIMEOUT` / `DEFAULT_DOWNLOAD_READ_TIMEOUT` constants rather than repeated
literals, and `NetworkConfig`'s own `fallback*` values now read those constants -- so the
shipped defaults still exist exactly once. Writing `5_000` in `-app` would have been the
equal-valued-copy trap this project documents three times over.

With this, F2 and the `VersionChecker` half of F3 are closed: the two genuinely unbounded
network calls the audit found have bounds. The remaining unrouted `openStream` /
`openConnection` sites are `plugins/ServerPackCreatorPlugin.kt:68` and
`utilities/common/ClassUtilities.kt:60`, both reading from a `jar:` URL rather than the
network -- documented next, since the landmine currently implies no exceptions exist.

Measured: api 338, app 127 -> 128, clientside 88, all green.

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>
Behaviour-preserving. `ConfigEditorViewModel` gains `isServerDownloadable` and
`packName`, both plain delegation for now; `ConfigEditor.checkServer` and the
check-timer call those instead of reaching into ApiWrapper themselves.

The view-model now takes `ConfigurationHandler` and `ServerPackHandler` alongside
`VersionMeta`. All three are `-api` types, so this stays inside the module rule the
class already followed -- no Swing, no Spring, unit-testable without a display --
and it is what lets the next commits pin how often each is consulted.

`packName` resolves the title exactly as the timer did: the manifest read sets the
name on the throwaway PackConfig it is handed, and either that or the returned name
wins over the directory name (`probe.name ?: declared ?: File(dir).name` is the
same three-way choice the timer's if/else-if/else expressed).

Two consequences of the move, both mechanical:
- the timer no longer builds a `PackConfig` per tick per tab. It only ever read
  `.name` off it, and the object was discarded -- `compareSettings()` calls
  `getCurrentConfiguration()` again for itself.
- `ConfigCheckTimer`'s `apiWrapper` parameter and its `java.io.File` import became
  unused and are gone, along with the argument at TabbedConfigsTab.kt:75. Leaving a
  dead constructor parameter behind would only invite a future caller to use it.

No existing test's assertion, argument or expected value changed.
`ConfigEditorViewModelTest` gains two mockk collaborators to satisfy the
constructor -- a reference-only update, every assertion byte-identical, which is
the carve-out the conventions name explicitly.

Measured: api 326 / app 108, all green. Compiler warnings in -app: 35 before, 35
after (the survivor at ConfigEditor.kt:714 is the pre-existing CoroutineStart.ATOMIC
DelicateApi opt-in).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`ModpackManifestParser.checkManifests` built its six candidate paths inline. They
are now `manifestCandidates(destination)`, which checkManifests consumes, with a
facade on ConfigurationHandler.

Behaviour-preserving: same six files, and the list is ordered as the `when` below
it consults them (minecraftinstance.json, manifest.json, instance.json, the
parent's instance.json, mmc-pack.json, the parent's instance.cfg). The original
`val` declaration order differed from the consult order, which was harmless there
but would have been misleading in a published list, so the list follows the branches
rather than the old declarations.

Why expose it: a caller needs to know whether a modpack's manifests have *changed*
without re-parsing them. The GUI's config-editor is about to -- `checkManifests`
parses the launcher manifest into a Jackson tree, and a real CurseForge
`minecraftinstance.json` is multi-megabyte (this repo's own fixture in
misc/launcher-manifests/curseforge/ is 2.7 MB), which the editor currently re-does
on every debounce tick, per tab. The alternative was for the app to hardcode the
same six paths, i.e. a second source of truth that drifts -- the exact failure mode
this repo already documents for SupportedModloaders and the ModListCompiler
constants.

`ManifestCandidatesTest` pins the set and the order, that absent files are still
reported (a memo has to notice a manifest that is about to be created), and that
the ConfigurationHandler facade reads the parser rather than re-declaring the list.

Measured: api 326 -> 329, app 108, clientside 88, all green. -api warnings 21 -> 21.

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>
`allSuggestions()` did two jobs with different obligations: it produced the parsed set, and
it handed callers something they were free to mutate. It is now
`TreeSet(parsedSuggestions())` over a new `internal parsedSuggestions()`.

Behaviour-preserving -- the parse is byte-identical and still runs on every call, and
`allSuggestions` still returns a fresh mutable copy, which is load-bearing: every
production caller mutates the result and persists it (`ConfigEditor.saveSuggestions` adds
the current field value, `InclusionsEditor.saveSuggestions` adds and `removeIf`s).

`internal` because the guard that follows has to observe *reuse*, and reuse is invisible
through `allSuggestions`: it copies, so equal-but-distinct sets come back whether or not
the parse was repeated. Separating the two is what makes the next commit's pin assert the
right thing.

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>
Closes audit finding G1. The cache key was built as
`"$minecraftVersion\0$modloader\0$modloaderVersion"` -- two literal NUL bytes where spaces
were intended, at offsets 5786 and 5797. Git classified the file as **binary**, so
`e6c529754`'s diff reads `Bin 5266 -> 8399 bytes` and every future diff of it would too.

Not a runtime defect: the separator was applied consistently on write and read, and NUL
cannot occur in a version string, so lookups were correct and collision-proof. The damage
was to reviewability -- an invisible control character in source that nobody wrote
deliberately, invisible to a reader and confusing to the IDE, Qodana and git alike.

Fixed by removing the string key rather than repairing the separator: the set is now keyed
on `Triple<String, String, String>`. Collision-free by construction, no delimiter to pick
or get wrong, and the intent is legible.

Also closes G3: `ModpackManifestParser.manifestCandidates` and its `ConfigurationHandler`
facade were added to a module published to Maven Central without a row in the root
CLAUDE.md compatibility table -- the only mention was in the *consumer's* module notes.
The row is added, describing what it returns, why non-existent files are included
deliberately, and that nothing existing changes.

G2 needs no code: that guard's assertion was rewritten by its own fix commit -- worth
knowing, since the committed red pin at `7fa1bb39a` asserts something no correct
implementation can satisfy -- but the assertion in the tree today is the right one and its
teeth were verified by deliberately defeating the cache.

Measured: api 338, app 128, all green.

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>
`getDirectoryFiles` resolved `File(source).absolutePath` **inside** the loop over every
walked path, though it does not depend on the file being visited.

Behaviour-preserving; the value is identical on every iteration.

Measured, and deliberately not oversold: at 50,000 walked files this is 3.0 ms -> 0.5 ms. A
rounding error. It is fixed because constructing the same File fifty thousand times is
indefensible, not because it is slow -- and it is its own commit rather than riding along with
the archive fix, because "measured in the same sitting" is not a shared concern.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`QuiltPackScanner.scan` searched the whole Fabric result list for each Quilt result -- one
linear scan per entry, so O(n^2) `File.equals` over a mods directory.

Behaviour-preserving, and the mechanism matters: built with `putIfAbsent` rather than
`associateBy`, because `find` returned the **first** match and `associateBy` keeps the last.
The one-entry-per-jar contract means they cannot differ today, but first-wins is what is being
replaced and there is no reason to change it silently.

Measured: 4.71 ms -> 0.14 ms at 500 mods. Trivial, stated as such.

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>
Turns the guard from the previous commit green. `clientsideModsRegex` and
`modsWhitelistRegex` build and return a new TreeSet per read, via a shared
`regexVariantOf(entries)`, instead of clearing and refilling one shared field.

Behaviour change on published API, which is why it is `fix:` and gets a row in the
root CLAUDE.md table. Same values, same signature -- but a caller no longer receives
an alias of the config's own state, so it can neither be emptied underneath them nor
observed mid-clear by another thread. The GUI reads settings from a `parallelStream`
walk over its open tabs, so the concurrent case is reachable.

The two properties change from `var ... private set` to a computed `val`. Verified
safe first: the private setter is never assigned anywhere in -api or -app, so it was
dead, and both readers (`clientSideMods()`, `ApiProperties.kt:1276`) immediately
`.toList()` the result, so nothing depended on the aliasing.

Measured: api 336 -> 337, `./gradlew build` green, -api warnings 21 -> 21, -app 35 ->
35. (Note for anyone repeating that check: a bare
`:api:compileKotlin :app:compileKotlin --rerun-tasks` in one invocation reported 0
warnings for both because the tasks did not actually recompile -- run them
separately.)

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`dependencyCheck` and `dependencyReplace` were `get() = "...".toRegex()`, so each
*read* compiled the pattern anew — and they are read inside the per-dependency loops
(`getDependencies`, `additionalDependenciesDepend`, `additionalDependencyDepends`).
Now `val`s. Behaviour-identical: `Regex` is safe to share, since matching creates its
own matcher.

Also removes a duplicate. A private `additionalDependencyRegex` held the *identical*
literal to `dependencyReplace` and was what the two `additionalDependency*` checks
actually used — so the pattern existed twice with only one copy documented, meaning an
edit to the documented one would have changed nothing at those call sites. Exactly the
equal-valued-copy trap this module already records for `modFileEndings` and `zipCheck`.
Now one declaration, read by all four sites.

Scope note: this path only runs for Minecraft 1.12 and older
(`ModScanner.scannerFor` sends anything newer to `ForgeTomlScanner`), so the audience
is small and no performance claim is made for it. It is here because the duplication
is a correctness hazard and the getters were free to fix while reading the file.

Measured: api 337, clientside 88, all green. -api warnings 21 -> 21.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Definition-of-done paperwork for Phase 3.

Root CLAUDE.md: three behaviour-change rows (the FilterMatcher, with its honest note
that the performance half is small and the bug fix is the point; the archive read-once
change and its defaulted openZip parameter; the fresh-set-per-read regex lists and why
that is a correctness fix rather than a perf one). Refactor-state: api 329 -> 337.

serverpackcreator-api/CLAUDE.md: three landmines -- keep invariants out of the
mods x list-entries loop, read an archive's central directory once (with the zip4j
addFile-writes-no-directory-entries fixture trap), and never hand out the config's own
mutable state from a getter.

REFACTOR-LOG.md: the narrative, and the table that matters most -- each candidate was
measured *before* being implemented, and the numbers reordered the phase. The plan's
headline item for Phase 3, the per-comparison exclusionFilter read, is worth ~3 ms:
the reasoning (330,000 synchronized Hashtable lookups) was sound but Hashtable.get is
fast and its monitor uncontended, so the arithmetic did not translate into time. The
archive re-parses at 79.9 ms each turned out to be the only real win. Recorded rather
than dropped, because the wrong estimate had already been stated twice.

Also records what was deliberately left alone: the dependency-rescue loop, an O(n^2*d)
smell worth ~10-20 ms, not worth churning the most delicate logic in that file for.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Closes audit findings H3 and H4, both omissions in the compatibility table rather than
code defects.

H3 — `ForgeAnnotationScanner.dependencyCheck` / `dependencyReplace` went from
`get() = "…".toRegex()` to `val`. Source-compatible and the right change (the getter
recompiled inside per-dependency loops), but these are public members of a published
module and the semantics shifted in exactly the way this table already records for
`modFileEndings` and `zipCheck`: the value is no longer created per read, so callers share
one instance and an identity comparison across two reads now succeeds. Recorded, together
with the removal of the private duplicate that held the identical literal.

H4 — `getAllFilesAndDirectoriesInModpackZip` dropped from two `catch` blocks to one when
it became a single pass. Before, a failure fetching files still returned the directories;
now a failure returns nothing and logs once instead of twice. Barely reachable, since both
old calls opened the same archive, but it is a behaviour change on an error path and the
commit that made it described only the optimisation.

Two audit findings on this branch are about commit hygiene and cannot be fixed without
rewriting shared history, so they stand as recorded: `78eb879a2` is labelled `test(api)`
while shipping the `openZip` seam (production), and `e7da71ade` bundles three unrelated
changes -- the zip fix plus two rounding-error hoists in different files. Both were
disclosed in their own messages; the lesson for next time is that "measured in the same
sitting" is not a shared concern.

`./gradlew build` green.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
statsAreTalliedFromOneScanAndThreeCounts  FAILED  expected: <7> but was: <0>

`AmountStatsService.stats` does four full-collection loads to answer one request:
`serverPackRepository.findAll()` for the tally, then `findAll()` on the server packs
*again* purely for `.size`, plus `findAll().size` on the modpacks and the
run-configurations. Three of the four exist only to learn a number the database can
count itself.

Each one is heavier than it looks. `ServerPack.runConfiguration` is an eager `@DBRef`,
and a `RunConfiguration` eagerly resolves its own `@DBRef` lists -- start arguments,
clientside mods (~550 entries on the default list) and whitelist. So a scan of the
server packs fans out across four collections, and this is a public endpoint
(`/api/v2/stats`).

The guard reads the counts from `count()` and the tallies from a single `findAll()`,
so today's implementation reports 0 modpacks where 7 were counted -- the number is
coming from a scan.

Repository call-shape is deliberately part of the contract here. That differs from
`RunConfigurationServiceTest`, which left lookup counts unpinned as an implementation
detail; for this service the call shape *is* the defect.

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 statsAreTalliedFromOneScanAndThreeCounts green. The three `findAll().size`
calls in AmountStatsService become `count()`; the tally keeps its single
`findAll()` over the server packs, which it genuinely needs.

Four full-collection loads to answer one request become one plus three counts. Each
avoided load matters more than its row count suggests: `ModPack.serverPacks`,
`ServerPack.runConfiguration` and `RunConfiguration`'s three list fields are all
eager `@DBRef`s, so a scan fans out across four collections and materialises the
~550 ClientMod documents behind every run-configuration on the default list.
`/api/v2/stats` is public.

Measured: app 120, all green -- including StatsControllerTest, untouched.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`saveUploadedFile` inlined its SHA256 duplicate check as a findAll-and-scan loop.
Now `existingUploadOf(sha256): Optional<ModPack>`, with the throw kept at the call
site so the exception message and its `available.id` argument are unchanged.

Behaviour-preserving: still the same scan, still first-match-wins, same
StorageException. Extracted because it is the unit the next commits pin --
`saveUploadedFile` itself also wants GridFS, a storage system and the API's
ConfigurationHandler, none of which duplicate-detection depends on, so it cannot be
exercised without standing all of that up.

Measured: app 120, all green.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`ModPackRepository.findBySha256` plus `@Indexed` on `ModPack.sha256`, so a modpack can be
found by content hash with a single indexed lookup.

Unused by production in this commit -- the duplicate-check still scans -- and that is
deliberate: the guard that follows needs this surface to compile, and shipping it inside a
`test:` commit would misrepresent that commit's diff. Its own `feat:` commit says what it is.

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>
Definition-of-done paperwork for Phase 4.

serverpackcreator-app/CLAUDE.md: a section on the embedded mod-lists -- why the three
single-field @Documents were pure overhead and must not come back, the ~550-to-2
round-trip reduction, the `In`-means-contains-any landmine, the fact that the frontend
JSON shape is part of the same contract, and `web/migration/` as the pattern to copy
(join-free, idempotent, element-wise, ApplicationReadyEvent, MongoTemplate not the
repository, and why it costs the suite nothing on localhost but might on a remote host).

REFACTOR-LOG.md: the narrative for 4a and 4b, the two bugs that fell out (the
`In` duplicate-match, and the null-hash comparison), and the honest note that four
tests were *deleted* because the behaviour they pinned ceased to exist -- with the
comma-splitting coverage they also carried preserved under new names.

Also records what was dropped from the plan: projections for the two cleanup
schedules. Their cost was the eager @DBRef fan-out on findAll(), which the flattening
removed at the source, leaving machinery for a midnight cron with nothing to win.

Refactor-state: app 118 -> 127, and a note on the frontend row that types/api.ts
mod-lists are now string[].

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The four operations a migration needs — read a collection, replace a document, test for a
collection, drop one — move behind a `MigrationStore` interface, with `MongoMigrationStore`
as the real implementation. `RunConfigurationListMigrationRunner` takes the interface.

Behaviour-preserving with one deliberate exception, stated because it is a real difference:
`MongoMigrationStore.findAll` materialises the collection into a list instead of streaming the
cursor. Writing while iterating a live cursor can hand back a document twice if it moves,
which was only harmless because this particular rewrite is idempotent. Materialising removes
that coupling, so a future migration that is *not* idempotent cannot be broken by it. Nothing
else changes: same order, same `_id`-targeted replace, same drop conditions.

Why it exists: the runner's safety decisions — rewrite before dropping, never drop when
nothing was rewritten, keep going when one drop fails, do not take the boot down when the
database is unreachable — are what can lose a user's data if wrong, and they were unobservable
while the collaborator was `MongoTemplate`. Same reasoning as `ModpackZipInspector`'s injected
`openZip`. The guards follow in their own commit.

`MongoTemplate` rather than a repository, necessarily: a migration exists precisely because
the mapped type can no longer read the stored shape.

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-1 audit findings N1 and N2, both introduced by the previous round's history
restructuring.

N1 -- the rewrite changed every hash, leaving **54 dead citations** across CLAUDE.md, the two
module CLAUDE.mds, REFACTOR-LOG.md and REFACTOR-AUDIT.md. They resolved only because the four
superseded branches still held those objects locally; deleting them would have killed every
reference. The refactor log exists to be read "when you need the *why* of a past decision", so
a citation that resolves to nothing defeats its whole purpose.

Every one is now the commit's **subject** instead, which survives a rebase, a cherry-pick and
a squash. Verified: 0 dead hashes remain in the durable docs.

Two look-alikes were deliberately left alone: `61f97194` and `6afc2700` at
REFACTOR-LOG.md:1263 are Java object-identity hashes quoted inside a test-failure message from
earlier, unrelated work -- not commits.

N2 -- a landmine documented a defect that the same remediation had just fixed. It said
`checkout <first pin> && apply <its fix>` shows "red -> red, not red -> green" and concluded
"copy those, not the first one". After the reordering the first pin *does* go red -> green
untouched, so the advice pointed readers away from what is now the most thorough example on the
branch. The mockk lesson it carried is the valuable part and is kept -- a relaxed mock answers
0 for an Int, which is the JDK's "wait forever", so the fixture hands the code under test the
very defect the guard exists to catch -- and it now ends with the rule that actually helps:
land the settings group first so a new-API pin has real properties to stub.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Closes iteration-2 audit findings P1, P2 and P3.

P2 -- all three `NetworkConfig` setters stored the sanitised value but kept and logged the
raw one, so `connectTimeout = -5` stored 5000 while announcing "-5 ms". They now sanitise
once and use that value for the store, the field and the message.

**Honestly scoped: this was never readable.** The getters recompute from the store, so no
caller could obtain the bad value -- the only casualty was a log line telling an operator the
opposite of what took effect, plus a field transiently holding a value the class had just
rejected. It follows that it cannot be pinned: the log is the sole observable and asserting
on log output is brittle. `assigningANegativeTimeoutReportsTheStoredValue` therefore pins
what *is* assertable -- that the store and a read-back agree after a rejected assignment --
and its doc says plainly which half it does not cover. Recorded because a guard that looks
like it covers a defect, and does not, is worse than no guard.

P1 -- the refactor-state table said api 337 / app 127; actual is 339 / 135. The gap was
exactly the audit remediation's own tests (`UpdateConfigTimeoutTest`,
`VersionCheckerTimeoutTest`, `RunConfigurationListMigrationRunnerTest`, and now this one),
each counted correctly in its own commit message and never carried forward to the summary
that a session actually reads first.

P3 -- `serverpackcreator-app/CLAUDE.md` cited `ConfigEditor.kt:80` for the debounce trigger;
line 80 became a MigLayout column spec when the view-model constructor gained two arguments
in this very branch. Replaced with the symbol names, which do not move.

And the finding behind all of them, now a convention rather than a fourth fix: three
consecutive audits turned up the same defect class and almost nothing else -- a fact quoted
in prose going stale the moment the code moved (54 rebased-away hashes, a landmine describing
a flaw since fixed, a line number shifted by the commit citing it, suite counts left behind
by the tests just added). `CLAUDE.md` now says to cite commit *subjects* over hashes, *symbol
names* over `File.kt:123`, and what a guard asserts over how many tests exist -- and, where a
number genuinely earns its place, to say what produced it so a reader can re-run rather than
trust it.

Measured: api 338 -> 339, app 135, clientside 88, frontend 31, `./gradlew build` green.

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 R1, turning the red pin of the previous two commits green.

`spring.data.mongodb.auto-index-creation=true` is the switch that makes `@Indexed` mean
something. Without it `MongoMappingContext` keeps its constructor default of `false` and
`ModPack.sha256`'s index is never created, so the upload duplicate-check's lookup is a
COLLSCAN while its KDoc states "a single indexed lookup rather than a scan" as fact.

The property is set here, in the app's own `application.properties`, rather than the index
being created in code: that keeps `@Indexed` the single declaration. Creating it explicitly
as well would put one decision in two places that can drift, and the annotation is already
where a reader looks.

Scoped honestly -- the commit this repairs was still a real improvement, just not the one it
claimed. It stopped loading every document into the JVM and stopped dragging the eager
`@DBRef` graph behind each one, which was the dominant cost. What it did not do, until now,
is let the server skip documents.

red -> green: theShippedConfigurationCreatesDeclaredIndexes FAILED before, PASSED after; the
entity half passed throughout and stays to keep the switch honest about what it creates.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Red pin for iteration-4 audit finding R2. Lands failing, on purpose.

`fix(app): look an upload's hash up by index instead of scanning every modpack` replaced a
loop over `findAll()` -- which threw `StorageException` on the *first* match and so tolerated
any number of duplicates -- with `findBySha256(...): Optional<ModPack>`. A derived query
declared to return `Optional<T>` raises `IncorrectResultSizeDataAccessException` when more
than one document matches, and `ModPackController` catches only `StorageException`. So a
duplicate pair turns every later upload of that hash into an uncaught 500 where the old code
answered with a populated error body.

Two stored modpacks come to share a hash through a race between concurrent uploads of one
file (both scan, both find nothing, both save), and through any database predating the check.

Asserted through Spring Data's own `PartTree` -- the parser that turns a method name into a
query -- rather than by matching the name against a string: `First` limiting the result set is
the property that matters, the name is only how it is spelled. The finder is located by
reflection so the guard survives the rename that fixes it.

The multiplicity itself cannot be pinned here: reproducing the exception needs a real MongoDB,
and this module has no embedded one. What is asserted is the mechanism that makes it
impossible, which is the honest half.

Observed red: theHashLookupIsLimitedToOneResult FAILED (findBySha256, expected <true> but was
<false>), theHashLookupStillReportsAbsence PASSED.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Closes iteration-4 audit finding R2, turning the previous commit's pin green.

`findBySha256` becomes `findFirstBySha256`. Spring Data's `First` keyword limits the query to
one result, so it can no longer raise `IncorrectResultSizeDataAccessException` when two stored
modpacks share a hash -- and "first match wins" is exactly the semantics of the
`findAll()`-and-return-on-first-match loop this query replaced.

Why it mattered: `ModPackController` catches `StorageException` and answers with a populated
error body. `IncorrectResultSizeDataAccessException` is not one, so it escaped to the container
as a 500, on *every* later upload of that hash rather than once. Duplicates are reachable
through a race between concurrent uploads of one file, and through any database predating the
check.

The repository KDoc now states the multiplicity contract, because the keyword is the only thing
enforcing it and a future "tidy-up" would drop it as noise.

`ModPackDuplicateCheckTest`'s five stub references are renamed with it. Reference-only, per the
carve-out in CLAUDE.md: not one assertion, argument or expected value changed -- verified by
diffing, the edit is `findBySha256` -> `findFirstBySha256` and nothing else.

red -> green: theHashLookupIsLimitedToOneResult FAILED before, PASSED after. App suite 139
(135 at iteration 4's start, plus this iteration's four guards), 0 failures.

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>
Closes iteration-4 audit finding R4, and lands the iteration-4 report itself.

`docs: cite commit subjects instead of hashes, and correct the stale timeout landmine` applied
the "cite names, not snapshots" convention to `claude-docs/REFACTOR-LOG.md` and
`serverpackcreator-api/CLAUDE.md` -- and left it unapplied in the committed, root-level document
that *recorded* the convention.

Of 42 hash-shaped tokens in `REFACTOR-AUDIT.md`, **39 were dead commit hashes**. Every one of
them resolved only because `perf-safety-snapshot` still pointed at it -- and this iteration
established that branch is fully superseded and safe to delete, at which point the report's
whole evidence base becomes unreachable and garbage-collectable.

All 39 are now commit subjects. In verdict tables the `type(scope):` prefix is dropped, because
the adjacent Type column already carries it; in prose the full subject is used. Five things
deliberately keep their hash, each because it is not a rebase-able reference:

  7abd7c85c            the branch base, on `develop`, and a range endpoint
  61f97194, 6afc2700   Java version strings, not refs -- the report already said so
  @186d20a3, @74ab779f Java identity hashes quoted from a failure message, now annotated as such

Three citations were commands rather than references (`git checkout <hash> && apply <hash>`,
a `git grep` at a pin's parent, a `git rebase --onto`). Those hashes were exactly what died, so
the commands were already broken; they are reworded to name the commits instead.

Also corrected in this iteration's own text: the first draft of R4 said "41 not reachable,
two of which do not resolve to a commit". Two of those never were commit hashes, so the honest
count is 39 -- the same defect class the finding is about, caught in the finding.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The app row read 135; the suite is 140 after this iteration's five guards
(`ModPackIndexCreationTest` 2, `ModPackHashQueryTest` 2, `RunConfigurationCollectionNameTest` 1).

Done as its own commit and immediately, rather than at the end of the session, because a suite
count left behind by the tests that were just added is the exact defect iteration 2 recorded as
P1 -- and the convention that came out of it says numbers earn their place only when a reader can
re-run them. This one comes from the JUnit XML under `serverpackcreator-app/build/test-results/`.

`./gradlew build` green: api 339 (1 skip), app 140, clientside 88, grinder 233 (19 skip).

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-5 audit finding S1, turning the previous commit's pin green, and replaces the
approach taken by `fix(app): create the indexes the web module declares`.

`spring.data.mongodb.auto-index-creation=true` is reverted -- and left in the file as a comment
saying why, because it is the obvious thing for the next person to add. It made `MongoTemplate`'s
bean creation create the indexes during context refresh, so a reachable database became a
condition of starting up: ~30s wait in `createIndexes`, `MongoTimeoutException`, refresh
cancelled. `docker/docker-compose.yml` starts the app alongside its `db` service, so that race is
the normal first boot.

`DeclaredIndexCreator` does it on `ApplicationReadyEvent` instead, which is the trade
`RunConfigurationListMigrationRunner` already makes and documents: an unreachable database delays
the work rather than blocking the boot. Failures are logged and swallowed -- a missing index makes
a query slower, an exception here would take down an application that is already serving, and the
next start retries because creating an existing identical index is a server-side no-op.

`@Indexed` stays the single declaration. Definitions are resolved from the mapping context through
Spring Data's own `MongoPersistentEntityIndexResolver`, so no index is restated in code and adding
one to an entity needs no change here.

`IndexStore` exists for the same reason `MigrationStore` does: *which* indexes are requested and
*when* are the decisions worth guarding, and neither is observable while the creator talks to
`MongoTemplate` directly. One method, so the recording double is a few lines.

`ModPackIndexCreationTest` is deleted rather than adjusted. Its two guards asserted the abandoned
approach (that the shipped property is `true`) and that the resolver yields `sha256` --
`DeclaredIndexCreatorTest.theDeclaredUploadHashIndexIsCreated` covers the second strictly better,
asserting the creator asks for `sha256` *on the `modPack` collection*, resolution included.

**Disclosed rather than glossed:** with no database reachable, each context boot in the suite now
pays a second ~30 s driver timeout (the creator's, on top of the migration runner's read).
`DeclaredIndexStartupTest` therefore matches `WebServiceContextTest`'s properties exactly so
Spring's test-context cache reuses one context instead of booting a second -- measured, 2m41s ->
1m41s for `:serverpackcreator-app:test`, against ~1m10s before this iteration. Shortening the
driver's server-selection timeout in test resources was tried and does *not* work: the effective
URI comes from the generated test home, not from `src/test/resources`. Left as it is rather than
chased.

red -> green: theShippedConfigurationDoesNotCreateIndexesDuringRefresh FAILED before, PASSED
after. `./gradlew build` green: api 339 (1 skip), app 143, clientside 88, grinder 233 (19 skip).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Two landmines and the iteration-5 report.

The first is the one that cost this iteration: `spring.data.mongodb.auto-index-creation=true` is
the obvious way to make `@Indexed` mean something, and it makes a reachable MongoDB a condition
of starting up. It is now documented in `serverpackcreator-app/CLAUDE.md` with the measurement,
and the property sits commented out in `application.properties` with the same reason, because
otherwise it gets re-added by exactly the reasoning that added it the first time.

The second is why the first got committed green: `src/test/resources/application.properties`
shadows the shipped file, so no test exercises the shipped web configuration --
`WebServiceContextTest` boots "the real context" over the test copy. Recorded with what to do
instead: read the shipped file explicitly *and* assert the behaviour in a booted context.

Also recorded: keep new `@SpringBootTest` properties identical to `WebServiceContextTest`'s, or
Spring's context cache boots a second context and each boot pays the driver's server-selection
timeouts (measured 2m41s vs 1m41s).

Refactor-state table: app 140 -> 143, and its guard list now names the four persistence guards
that survived the iteration rather than the deleted `ModPackIndexCreationTest`.

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>
app 143 -> 145 (the two `WebServiceContextTest` wiring guards), web-frontend 31 -> 32 (the
rendered-mod-list guard), both read from the suites rather than counted by hand: JUnit XML under
`serverpackcreator-app/build/test-results/`, and Vitest's own summary.

The frontend row now says what its new guard is *for*, since that is the part worth knowing: the
pass-through assertion it replaces stayed green with the card reverted to the pre-branch object
shape, rendering `undefined`.

`./gradlew build` green: api 339 (1 skip), app 145, clientside 88, grinder 233 (19 skip),
plugin-example 3, frontend 32.

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 previous commit's red pin.

`FALLBACK_DATABASE_URI` carried literal backslashes -- `mongodb\://user\:password@localhost\:27017/…`
-- because the `.properties` escaping had been written into the *value*. `Properties.store` already
escapes colons on write and `Properties.load` reverses it on read, so the value was escaped twice: a
generated home read `mongodb\\\://…`, three backslashes for one colon, and loaded back as
`mongodb\://…`, which `com.mongodb.ConnectionString` rejects outright.

**Every fresh web installation started from a URI the driver cannot parse**, before the user had
configured anything.

Self-healing for existing installs, with no action required: a stored value carrying literal
backslashes fails the scheme check, is replaced by this clean fallback, and is written back correctly
escaped by `Properties.store`.

Seven test fixtures carried the same double- and triple-escaped form and are normalised to single
escaping, which is what the format actually specifies. Verified by unescaping each one the way
`Properties.load` does: all four `spring*.mongodb.uri` fixtures across `-api`, `-app` and `-clientside`
now yield a `mongodb://` URI. That includes the `-api` fixture deliberately left on the legacy key --
it was triple-escaped, so it had been exercising the fallback path rather than the legacy-adoption path
it exists for.

Compatibility row added: the constant is published `-api` surface, so an embedder comparing it against
a hard-coded backslashed copy stops matching, while one passing it to the driver gets a value that
works.

`./gradlew build` green: api 313 (1 skip), app 110, clientside 88, grinder 233 (19 skip),
plugin-example 3.

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>
Turns the previous commit's pin green. Griefed confirmed 9.0.0 as the next version, so the migration
has a real target rather than a guessed one.

`MigrationMethods.NinePointZeroPointZero` reports that the database-URI property moved from
`spring.data.mongodb.uri` to `spring.mongodb.uri`, and reading `WebserviceConfig.databaseUri` inside it
carries the stored value across.

**The message is the point, not the mechanism.** The carry-over already happens on every read, on every
build type -- it has to, because migrations run release->release only and would otherwise miss every
dev, alpha and beta user. What only a migration can do is tell the operator, and what they need telling
is the part we cannot fix for them: their own `overrides.properties`, a container environment or a
deployment script that still writes the old key is silently ignored by Spring.

Fires only when `hasLegacyDatabaseUri` is true, so an installation that never used the old key hears
nothing. That negative case is pinned as well -- a migration that announces itself to everyone is noise,
and noise gets ignored.

`hasLegacyDatabaseUri` is a new read-only member on `WebserviceConfig`, deliberately narrow: unlike
`databaseUri` it touches nothing. A read that normalised the store would destroy the very signal it is
being asked about. Compatibility row added.

The URI itself is never logged -- it routinely carries a password. The message names the two *keys*.

Translation added to `Translations_en_GB.properties` only. pt_BR and zn_GB fall back to the base rather
than being given English text masquerading as a translation.

`./gradlew build` green.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The trim was approved, then deferred on sequencing grounds once the numbers were measured rather than
carried over from the branch they were first noticed on.

`develop`'s root `CLAUDE.md` is **38,185** characters -- *under* the ~40,000 large-memory floor. The
49,395 figure this was raised on belonged to `claude-performance-improvements`. So there is no warning to
fix on develop today; there will be once the open branches land (projected ~56,078, with
`claude-mongo-boot4-property` crossing the floor on its own at 41,394).

Deferred because restructuring the API behaviour-change table and the refactor-state table now would
collide with the ~14k of new rows `claude-performance-improvements` adds to those exact regions --
turning a mechanical move into a whole-region merge conflict over behaviour-change records, which is the
content where a careless resolution costs most. Griefed's call: merge first, then trim.

Filed with the full plan so it can be picked up cold, including the one thing not to assume: the
preferred destination for the build-layout block was a paths-scoped `.claude/rules/build-layout.md`, and
while `.claude/rules/` and a `paths` frontmatter key both appear in the installed CLI, scoped loading was
never verified end to end. The entry says to confirm it or fall back to `claude-docs/BUILD-LAYOUT.md`,
and to keep the two most dangerous build one-liners in the root file either way -- so the warning
survives even if the moved file never loads.

Numbered B35 rather than B30: develop tops out at B29, and B30-B34 exist on
`claude-performance-improvements`, whose B33/B34 are resolved by `claude-mongo-boot4-property` and should
be dropped in that merge.

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>
Spring Boot 4.0.0 retired `spring.data.mongodb.uri`, the key ServerPackCreator wrote. A retired key is
not bound and does not warn, so Boot used its own default `mongodb://localhost/test` -- ignoring every
configured host, credential and database, and every SPC_DATABASE_* container variable, on shipped
versions (main is on Spring Boot 4.0.3).

This is the answer to the question claude-docs/DOCKER-MONGO-INVESTIGATION.md left open, and it retires
that document's middle triage row: a localhost:27017 connection error did not mean the reporter's
overrides.properties was missing.

Reading stays backward-compatible via LEGACY_DATABASE_URI_KEY, and MigrationMethods.NinePointZeroPointZero
reports the rename to the operators it affects. Two pre-existing bugs fixed alongside: the fallback URI
carried literal backslashes and so was not a URI at all, and the scheme check accepted 'mongodb:'.

Verified end-to-end against MongoDB 8.0.5, not only by unit test.
Backlogs B35, the root CLAUDE.md trim, deferred behind the remaining merges on sequencing grounds:
restructuring the API behaviour-change and refactor-state tables now would collide with the ~14k of new
rows claude-performance-improvements adds to those same regions.

Corrects the premise it was raised on -- develop's CLAUDE.md is 38,185 chars, under the ~40,000 floor;
the 49,395 figure belonged to the performance branch.
The published OpenAPI spec was hand-maintained and had drifted to 25 documented paths against 44 real
ones, with two schemas describing classes that no longer exist. springdoc 3.1.0 (the Spring Boot 4 line)
is wired in as developmentOnly and the spec is regenerated from the live controllers.

Also excludes org.springdoc from the license report: developmentOnly keeps it out of the jar, but not off
compileClasspath/runtimeClasspath, so it had been adding itself to the shipped LICENSE-AGREEMENT files.
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 B35. Claude Code warns when a single loaded memory file exceeds ~5 % of the context window,
floor ~40,000 characters. After the four merges root `CLAUDE.md` was **56,078**; it is now **33,425**
-- a 40 % reduction with ~17 % headroom.

**The API behaviour-change table moved to `claude-docs/API-BEHAVIOUR-CHANGES.md`** -- 20,218 chars, the
single largest block, ~5,200 tokens paid by every session. The *policy* stays in root; only its evidence
moved, because that table answers "will this break an embedder?" when writing release notes rather than
informing ordinary work. Verified as a move and not a rewrite: all **24** rows byte-identical and in the
original order, checked programmatically rather than by eye.

**The refactor-state table's per-test enumerations are gone** (api 1,392 -> 307 chars, app 1,456 -> 386,
grinder 1,168 -> 484). They enumerated test class names -- which `ls src/test` answers -- and they
contradicted this file's own rule to cite what a guard asserts over how many tests exist. Each row now
states the durable thing instead: the *guard style* to follow when adding one, which is the part a
newcomer genuinely cannot derive.

**Nothing was cut without checking where else it lived.** Every backticked symbol in those rows was
tested against the owning module's `CLAUDE.md`. What came back "only in root" was either a test-class
name or a fact documented better elsewhere: `IncorrectResultSizeDataAccessException` in
`ModPackRepository`'s KDoc, and the CurseForge crawl's two design-killers as LANDMINE #1 and #2 in the
grinder's `source/CLAUDE.md` -- with real numbers where root had a paraphrase. Root now points at those
rather than restating them, and the pointer's elided path was expanded so it actually resolves.

**The build-layout section deliberately stayed, against the plan.** Moving it to a paths-scoped
`.claude/rules/build-layout.md` would save ~2,900 more tokens, but scoped loading was never verified end
to end here, and that block holds the expensive landmines -- the Boot BOM `platform()` trap cost 16 app
tests once, and the Kotlin/coroutines metadata skew failed silently for weeks. The target was met without
gambling those on an unverified loading mechanism, so the gamble was not taken. It is the obvious next
~2,900 tokens for whoever confirms `paths` scoping works.

Also refreshed in passing: the status date, and the frontend suite count (31 -> 32) the merge left behind.

B35 deleted from `BACKLOG.md` and recorded in `REFACTOR-LOG.md`, per that file's own convention.
Documentation only -- no source touched.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Closes B35. Root CLAUDE.md 56,078 -> 33,425 chars, back under the ~40,000 large-memory floor with ~17 %
headroom, by moving the API behaviour-change table to claude-docs/API-BEHAVIOUR-CHANGES.md (all 24 rows
byte-identical) and cutting the refactor-state table's per-test enumerations, which listed derivable test
class names against this file's own rule.

Every symbol in the cut text was checked against the owning module's CLAUDE.md first; what lived only in
root was either a test-class name or a fact documented in more detail elsewhere, and root now points at
those. The build-layout landmines deliberately stayed, since scoped .claude/rules loading is unverified
here and that block is the expensive kind to lose.
Characterization tests for B32, written first because the method is about to stop materialising the file
it measures. They pass against today's code, which is the point: the change must not move any answer.

`hasteBinPreChecks` applies **two independent limits** -- 10 MB of *bytes* and 400,000 *characters* --
and they are not the same measurement. UTF-8 spends up to four bytes on a character, so a file can be
well past the byte figure while being far under the character one.

The discriminating guard is `aMultiByteFileIsJudgedByCharactersNotBytes`: 200,000 `€` is 600,000 bytes
and 200,000 characters, so it must be **accepted**. Any "just read File.length()" shortcut -- the obvious
way to avoid the allocation -- fails exactly there, which is why the fixture asserts its own byte length
as a precondition rather than trusting the encoding.

The rest fix the boundary and the edges: 400,000 characters is already too many while 399,999 is fine,
a file past 10 MB is rejected however few characters it holds, and a directory is rejected rather than
throwing (the current code reaches that answer via an IOException it catches, so it is worth pinning
before the read changes shape).

No production change here.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Closes B32. `hasteBinPreChecks` called `fileToCheck.readText().length < 400_000` *after* the file's byte
size was already known -- allocating the entire file as a String, up to ~20 MB of `char` for a 10 MB log,
purely to count characters.

It now counts through an 8 KB buffer and stops at the first character past the limit, so the answer costs
16 KB of `char` whatever the file size. Characterization tests were written first and every answer is
unchanged.

Not replaced by a byte check, which is the obvious shortcut and is wrong: the method applies **two
independent limits**, 10 MB of bytes and 400,000 characters, and UTF-8 spends up to four bytes per
character. 200,000 `€` is 600,000 bytes but only 200,000 characters and must still be accepted --
pinned by `aMultiByteFileIsJudgedByCharactersNotBytes`, which asserts the fixture's byte length as a
precondition rather than trusting the encoding.

The one sound shortcut is kept: every character occupies at least one byte, so a file shorter than the
limit in bytes cannot hold that many characters, and the common case never opens the file.

**`isFile` guards that shortcut because a test caught it not being guarded.** `File.length()` on a
directory returns a small unspecified number, so the first version short-circuited to `true` and made the
caller accept a directory -- where the old code reached `false` by throwing inside `readText()` and
catching it. `aDirectoryIsRejected` failed, which is precisely why it was written before the change rather
than after.

Both magic numbers are now named constants; one of them had been written `10000000.0`.

api suite green.

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>
Clears B30, B31 and B32, the last deferred items from the startup/network performance work.

B31: VersionMeta refreshes its manifests on a background job instead of during construction -- measured
~399 ms median -> ~47 ms, and far more offline. Freshness is preserved where it is observable: the GUI's
version dropdowns are built once and never repopulated, so ConfigEditor and
ConfigurationHandler.checkConfiguration await the refresh. That constraint was missing from B31's entry.

B30: If-None-Match beside If-Modified-Since, so the Forge manifest (121,492 B of the 213,885 B still
transferred per startup) can answer 304. The ETag is stored with the manifest's byte length and offered
only while that matches, because a stale pairing would suppress a real update permanently.

B32: hasteBinPreChecks streams its character count instead of materialising up to 20 MB of char.

Only the CI items B26-B29 remain in the backlog.
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>
B26-B29 cannot be closed from the repository: three need one pipeline's output and B28's root cause is in
the runner's config.toml. This makes the next pipeline able to close three of them at once, and records
what was established locally so it is not re-derived.

The Docker diagnostic now prints the runner identity, because this file declares no tags: and so nothing
pins these jobs to one runner -- which is the hole in deciding B27 from configuration alone. Verified and
recorded in B27: .gitlab-ci.yml sets no DOCKER_HOST or DOCKER_TLS_CERTDIR anywhere, and the docker alias
is never used as an endpoint, which excludes one of its two branches.

.dockerized deliberately NOT deleted: seven jobs extend it and B27 warns that guessing wrong breaks the
release pipeline's push jobs.
`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>
CI/CD moves to Forgejo. git.griefed.de is Forgejo 16.0.3, not GitLab; .gitlab-ci.yml and its 22 jobs are
deleted and .forgejo/workflows takes over as canonical CI and the origin of every release.

Necessarily one commit: .forgejo/workflows is all-or-nothing (forgejo#9203), and Forgejo had been running
the .github workflows as a fallback, so a staged port would have left a window with no releases.

GitHub keeps a smoke test and the four issue-driven clientside workflows. Releases are mirrored outward by
explicit API calls, because Forgejo push-mirrors replicate refs but not releases.

B26-B29 dropped: they described GitLab dind and a GitLab-Pages Qodana report, infrastructure that no
longer exists. The backlog is now empty.
Closes iteration-1 audit findings C1-C6. All three HIGH findings share one shape: a credential or an
action reference that is syntactically valid and points at the wrong system, so YAML validation -- all
this migration had been checked with -- could never catch them.

C1, and the one that would have hurt most: the **PGP signing key was passed as `-PsigningKey=...`**. An
armoured private key is multi-line; spliced into a shell variable and word-split into gradle arguments it
is truncated at the first newline, so `useInMemoryPgpKeys` would receive a fragment. Signing fails, or
half-succeeds and dies at OSSRH validation *after* three other repositories have been published to. It now
travels as `ORG_GRADLE_PROJECT_signingKey`, which `findProperty` reads and which is how GitLab supplied it
-- and which keeps the key out of the process argument list, where `-P` exposed it to anything able to
read /proc/<pid>/cmdline.

C2: `update-readme.yml` handed `secrets.GITHUB_TOKEN` to two actions that query **GitHub's** API for
sponsors and contributors. On Forgejo that is the automatically provided *Forgejo* token. Right name,
wrong forge -- and it fails quietly, committing an empty sponsor list over a populated one. Both now use
`GH_TOKEN`, which this migration introduced for exactly this distinction and then failed to use here.

C3: the rolling `continuous` tag was still moved by `richardsimko/update-tag` with `secrets.GITHUB_TOKEN`
-- a GitHub action moving a GitHub tag, bare-referenced so it would not have resolved anyway. It matters
more than it looks: the release is created against tag `continuous` and the source archives are fetched
from `/archive/continuous.zip`, so a tag that never moves means every dev build ships fresh assets under a
stale tag with source archives of a different commit. Now moved on Forgejo with git.

C5: four third-party actions were left bare while four others were fully qualified. A bare reference
resolves against `DEFAULT_ACTIONS_URL` (`https://data.forgejo.org`), a mirror of *common* actions, not of
arbitrary one-person repositories. The "proven to resolve on this instance" argument is sound for
`actions/*` and `docker/*`; it is not the same claim for `jmgilman`, `tiyee`, `nogsantos` and
`richardsimko`. All qualified now, so nothing depends on an instance setting nobody here can read.

C4: the non-tag docs image is restored. GitLab had three Writerside Docker jobs and the third carried the
*inverse* rules, publishing `serverpackcreator-help:<short-sha>` on ordinary pipelines. Collapsing three
jobs into one had silently dropped that case while the commit message presented it as simplification. The
tag computation now covers all three, and VERSION follows the computed value rather than the raw ref name.

C6: the two GitLab capabilities that genuinely are gone -- the generic-package upload plus its asset link,
and `release_job`'s changelog-linking description -- are named in CLAUDE.md, so they read as decisions
rather than oversights.

Verified after the fixes: no dangling `needs`/`outputs`/step-id references across all 13 workflow files,
no bare third-party actions, no `secrets.GITHUB_*` outside an explanatory comment, all YAML parses. No
source touched, so the suite is unaffected.

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>
Three audit iterations over the Forgejo CI migration, each finding a class the previous could not.

Iteration 1 (reading): the PGP signing key was passed as -PsigningKey, and an armoured key is multi-line,
so signing would have been handed a truncated fragment; two GitHub-API actions were given Forgejo's token;
the rolling tag was moved on the wrong forge by an unresolvable action; four third-party actions were
bare; the non-tag docs image had been silently dropped.

Iteration 2 (executing the steps): a final release's notes contained every prerelease's notes too --
measured 5,685 chars against a true 2,834 for 8.1.1 -- and five curl steps lacked set -e, so a failed
asset upload produced an incomplete release with a green run.

Iteration 3 (adversarial, between jobs): the GitHub mirror would have created the release tag itself at
main's HEAD; the GitLab mirror would have 404'd until mirroring caught up; devbuild's new
delete-then-recreate needed the concurrency guard it did not have; a re-run could not repair a partly
failed release; and the mirror ran neither last nor after the VirusTotal notes.

All fixed and re-validated. ./gradlew build green; no source was touched by any of the three.
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>
The migration introduced `secrets.FORGEJO_ACTOR` and `secrets.FORGEJO_TOKEN`,
across devbuild.yml (3 references), release-build.yml (5), release-generate.yml
(2) and update-readme.yml (2). Neither secret can be created.

Forgejo validates Actions secret and variable names against
`^(?!FORGEJO_|GITEA_|GITHUB_)[a-zA-Z_][a-zA-Z0-9_]*$`, so the whole FORGEJO_
prefix is rejected -- the same rule that made GITHUB_TOKEN unusable and got us
GH_ACTOR/GH_TOKEN. I had applied that reasoning to GitHub's names and not to
Forgejo's own, which is the more obvious half of the rule.

`FORGEJO_TOKEN` is worse than merely unavailable: it is the documented name of
the token Forgejo generates per job, exposed as both `secrets.FORGEJO_TOKEN` and
`secrets.GITHUB_TOKEN`. Left as it was, every reference would have silently
resolved to that automatic token instead of failing -- repo-scoped, expiring with
the job, unable to write to the package registry, and unable to push past branch
protection. Maven publishing and semantic-release's tag push would both have
failed at a point where the cause looked like a permissions problem rather than a
naming one. `secrets.FORGEJO_ACTOR` has no automatic counterpart and would simply
have been empty, so the Gradle repository would have got a null username.

Secrets are now FJ_ACTOR / FJ_TOKEN, parallel to the existing GH_ACTOR / GH_TOKEN.

The ENV VAR names stay FORGEJO_ACTOR / FORGEJO_TOKEN -- the restriction is on the
secret store, not on a step's environment -- so
serverpackcreator.publishing-conventions keeps reading them with System.getenv
and buildSrc needs no change. The mismatch is explained where the mapping happens
in release-build.yml, since it reads like a typo otherwise.

Verified: zero `secrets.FORGEJO_` references remain in .forgejo/workflows, all
eight files still parse as YAML, and no `secrets.GITHUB_`/`GITEA_` reference was
introduced.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
release-generate.yml authenticated origin with
`git remote set-url origin https://$ACTOR:$TOKEN@git.griefed.de/...`, which
writes the credential into .git/config on the runner. From there git hands it
back out: `git remote -v` prints it, a failed push prints it in the error, and so
does any later step that reports the remote. Forgejo masks a secret's exact value
in log output, but this is the pattern the June audit already rejected --
claude-docs/WORKFLOW-AUDIT.md, H-class -- and update-readme.yml was deliberately
written the other way, with the token in an Authorization header and a comment
saying so. The migration reintroduced in one workflow what it preserved in the
other.

Now a per-host `http.https://git.griefed.de/.extraheader` carries HTTP Basic, and
origin keeps a bare URL. Scoped to that host so the header cannot ride along to
any other remote, and --global because semantic-release pushes from its own
process rather than through this step's repository config.

Also moved GIT_USER and GIT_MAIL into `env:` instead of interpolating
`${{ secrets.* }}` straight into the shell command, matching update-readme.yml.
They are names, not credentials, but a value interpolated into a command is a
script-injection surface whatever it holds -- also a finding in that audit -- and
there is no reason for these two to be the exception. Added `set -eu`, so a
failure to configure the credential stops the job instead of surfacing later as an
unauthenticated push.

Behaviour is otherwise unchanged: same repository, same credential, same
committer identity. Verified no `https://...:$TOKEN@` pattern remains in any of
the eight workflows and that the file still parses.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The migration needs 20 secrets across 8 workflows, and nothing recorded what any
of them is, which scopes it needs, or which job stops working without it. That is
reconstructable only by reading every workflow, which is the wrong task to be
doing while a release is half-finished.

claude-docs/CI-SECRETS.md covers each secret's purpose, where to obtain one and
the exact scopes it needs (FJ_TOKEN: write:package + write:repository; GH_TOKEN: a
classic PAT with write:packages + repo, because fine-grained tokens do not cover
ghcr or maven packages; GITLABCOM_TOKEN: api alone covers both the maven upload
and the release API; SONATYPE_*: the portal user-token pair, not the login).

It also records what degrades rather than fails, so a partial setup is possible:
test and docker-test need no secrets at all, WEBHOOK_URL is optional, and
release-build's seven jobs read disjoint sets, so one missing secret usually costs
one job. That per-job table was verified by attributing every secrets.* reference
in release-build.yml to its enclosing job rather than by reading intent -- which
corrected two claims in my first draft, since `mirror` and `virustotal` also need
FJ_TOKEN (to read the release notes back, and to append the scan links). FJ_TOKEN
turns out to be the one secret four jobs share.

Four traps are written down because each has already cost something: SIGNING_KEY
must travel as ORG_GRADLE_PROJECT_signingKey or it truncates at the first newline;
DOCKERHUB_USER is silently reused as the ghcr login AND the namespace of both tag
sets, which only works while the Docker Hub and GitHub names coincide; FJ_TOKEN
must be able to push past branch protection or semantic-release cuts no tag; and a
credential in a remote URL is the H-class finding WORKFLOW-AUDIT.md already raised,
so push steps stay on the extraheader pattern.

Verified both directions: every secrets.* reference in .forgejo/workflows appears
in the doc, and the doc names no secret no workflow reads.

CLAUDE.md gains a pointer, with the FORGEJO_/GITEA_/GITHUB_ prefix rule stated
inline -- that is the fact needed *before* editing a secrets.* reference, not after.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Signed-off-by: Griefed <griefed@griefed.de>
qodana.yml passed QODANA_TOKEN into the scan step, and CI-SECRETS.md listed it as
a secret to create. There is no free Qodana Cloud tier and this project does not
subscribe, so that secret can never be populated -- the workflow was documenting a
dependency on a service we do not have.

Nothing is lost by removing it. A token is required only for the paid linters and
for uploading to Qodana Cloud; it is optional for the Community linters, and this
job runs qodana-jvm-community. The rest of the job never needed Cloud: it counts
problems out of the SARIF the linter writes locally, publishes the HTML report as a
workflow artifact, and has Discord link the Forgejo run. That was already the shape
chosen when GitLab Pages went away -- the token was the one line still assuming a
Cloud account.

Both the workflow header and the scan step now say why there is no token, because
an absent env var reads like an omission and the obvious "fix" is to add it back.

CI-SECRETS.md loses the QODANA_TOKEN row, its degradation entry becomes "nothing
required" (WEBHOOK_URL stays genuinely optional), and the traps section records the
paid-vs-Community distinction so the question does not get re-litigated. Re-ran the
both-directions check: every secrets.* reference in .forgejo/workflows is still
documented, and the doc still names no secret no workflow reads -- 19 rows now.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The doc said the install4j license is "used with install4j 12.0.2". `Bump
install4j to 13` moved the setup-install4j steps and the catalog to 13.1 the same
day, so the sentence was stale within hours of being written -- a textbook case of
the convention against quoting a snapshot in prose, committed by the same pass
that wrote the convention down.

It now points at where the version is declared (the `version:` on the
setup-install4j step, kept in step with `install4j` in libs.versions.toml) instead
of repeating it, so the next bump cannot invalidate it.

Added while checking: the key has to be valid for the major version in use.
ej-technologies issues an upgraded key for a major release, free if that release
falls inside the license's support period, so a bump can quietly need a new secret
even though nothing about the workflow changed. Both jobs would fail at the media
step, which looks like a build problem rather than a licensing one. Worth knowing
before the next release rather than during it.

Also checked the rest of the file for the same defect: no other version number
appears in it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Auditing perf-safety-snapshot before dropping it surfaced these. Its BACKLOG.md
carried a note the emptied file lost, warning that several B-numbers were still
cited from the CLAUDE.md files after their entries were deleted. Checking those
citations found two that had become false, not merely dangling.

**B25 is closed, and both CLAUDE.md files still said it was open.** The root file
claimed "the snapshot currently lags its own parent manifest (backlog B25)" and
the api module's said the same. Measured against the shipped resources: the
release named in minecraft-manifest.json's `latest.release` now HAS a matching
mcserver/ file, and the directory holds 659 entries against the 643 the entry
described -- the `updateManifests` retarget advanced it, closing B25 as a side
effect without anyone updating the prose. Both now state what is true and name the
check (`latest.release` has a matching mcserver file) instead of a count that ages,
per the convention against quoting snapshots.

**The bare B33 citation was unresolvable.** "It also surfaced B33, which no test
could have" pointed at an entry that was correctly dropped when the Spring Boot 4
property-key fix closed it, leaving a reader nothing to look up. It now says what
B33 was -- the web application writing to MongoDB's default `test` database
instead of the configured one -- so the sentence carries its own meaning.

**BACKLOG.md regains the numbering note.** It reads as a fresh file with a reset
counter; it is not. B1-B34 have all been issued, some still cited after their
entries went away, so a new item taking B26 would silently repoint an existing
citation. The note fixes the next ID at B35 and records that
`git log -S'B<n> —' -- claude-docs/BACKLOG.md` recovers what any past ID meant --
which is how B25's and B33's text was recovered for this commit.

Left alone: `serverpackcreator-grinder/CLAUDE.md`'s "(B1)" is a historical
attribution for work that shipped, not a claim about an open item, and the new note
tells a reader how to resolve it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Covers eda82e20f^..HEAD -- the eight non-merge commits after the CI migration's
iteration-3 audit. One HIGH: the install4j 13 bump breaks every Gradle
invocation, `./gradlew help` included, because install4j-gradle:13.1 carries
Kotlin 2.3.0 metadata and buildSrc's precompiled script plugins compile with
Gradle 8.14.4's embedded 2.0.x. Two MEDIUM against the same commit (no
measurement recorded, which is what would have caught it; two deliberately
distinct version refs collapsed onto one) and two LOW of my own, one already
closed.

Also records what was checked and found clean, so it is not re-audited: the
audit-file merge is byte-identical on both halves, the credential rename leaves no
reserved-prefix reference and no GITHUB_/GITEA_ one, the Qodana claim was verified
against JetBrains' docs rather than assumed, B25's closure was verified against the
shipped resources, and the regenerated spc.install4j is structurally identical
either side of the bump.

The report is the state at audit time. H1's remediation, which turned out to be a
third option better than either the report proposes, lands in the next commit.

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 previous commit's landmine drew the rule too narrowly: "a plugin needs the
marker only when a precompiled script plugin applies it." Acting on that, the
obvious next step was converting `licenseReport` the same way -- it is the one
other versionless `id(...)` in the root build script, and grepping every `id(...)`
and `kotlin(...)` call in the precompiled script plugins says nothing applies it.

It fails. Dropping the marker breaks `:buildSrc:compileKotlin` with ten
`Unresolved reference` errors -- `jk1`, `ReportRenderer`, `ProjectData`,
`ModuleData` -- because `LicenseAgreementRenderer.kt` is ordinary buildSrc Kotlin
source implementing jk1's renderer interface, not a plugin application. Checking
applications alone under-reports what buildSrc's compile classpath is for.

The corrected rule: a plugin needs the marker when buildSrc needs it at COMPILE
time, which happens two ways -- a precompiled script plugin applies it by
versionless id, or buildSrc source compiles against its API. install4j is neither,
which is why alias works there and why that fix stands. Verified the premise
directly rather than by analogy: buildSrc/src contains no install4j reference at
all, and its only third-party imports are `com.github.jk1.license*`.

Recorded as a do-not-re-litigate landmine, because it looks like an obvious
cleanup, the reasoning for doing it is superficially sound, and it costs a failed
build to find out. licenseReport keeps its marker.

Measured: `./gradlew generateLicenseReport` FAILED at :buildSrc:compileKotlin with
the marker removed; `./gradlew help` SUCCESSFUL once restored. The report output is
byte-identical to before the experiment -- 322 dependency directories,
LICENSE-AGREEMENT 6031 lines, md5 2db9aa4caf91836b9cd75ffd957a8825 -- so nothing
shipped was disturbed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`Bump install4j to 13` moved `com.install4j:install4j-runtime` from 12.0.4 to
13.1, and that artifact is in the shipped LICENSE-AGREEMENT -- entry 17 of 44. The
regeneration could not run at the time because the same commit broke every Gradle
invocation, so the tracked copies still described 12.0.4.

Two consequences, both from the first `generateLicenseReport` that could execute:
the version line updates, and 13.1 carries an **embedded license** that 12.0.4 did
not, which is the 636 added lines. This is a change to what ships to users, not
just to a build output.

Scoped, not swept: the diff contains exactly one version change
(`install4j-runtime` 12.0.4 -> 13.1) and no other Group/Name/Version line moved.
The dependency count is 44 before and after, and the two tracked copies -- the
`licenses/` output and the resource compiled into `-app` -- are byte-identical to
each other again.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Re-audits iteration 8's range plus the four commits that answered it. H1 is
closed: full build SUCCESSFUL in 6m 8s, 827 tests across five modules, zero
failures, and `tasks --all` still lists `install4j` and `media` so the plugin is
applied rather than quietly dropped.

Two MEDIUM, both mine. `20cd6edcc` reached for `git config --global` when
`--local` would do -- a commit whose entire purpose was narrowing where a
credential is readable settled on the widest scope that worked. And CLAUDE.md's
refactor-state table understates the api suite by 11 tests, verified as real rather
than an artefact of leftover result XML.

One LOW worth keeping: the marker landmine generalised correctly-measured evidence
into a false rule, and only the attempt to act on it revealed that. The measurement
was verified; the generalisation drawn from it was not.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`20cd6edcc` moved semantic-release's credential out of the remote URL and into an
Authorization header, which was the point -- but wrote it with `git config
--global`. That lands in the runner's home config: readable by every later step in
the job, and applied to every repository on that runner rather than to this
checkout. A commit whose stated purpose was narrowing where a token is readable
picked the widest scope that worked.

`--local` is enough. It writes to the checkout's own .git/config and
semantic-release runs with that repository as its working directory, so the push
still authenticates while the blast radius drops from the runner to one clone.
`update-readme.yml` does better still with `git -c`, which touches no file at all,
but that cannot reach a git process semantic-release spawns for itself -- noted
inline so the asymmetry between the two workflows does not read as an oversight.

Also corrects CLAUDE.md's refactor-state table, which said the api suite was 343
tests when it is 354. Confirmed real, not leftover result files: all 59 XML files
in serverpackcreator-api/build/test-results/test were written by the last build.
The other five rows check out. The column now says where the numbers come from and
that a reader should confirm the files are from the run in question -- this figure
has drifted three times in this branch alone, including in my own commit messages.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
"Make it work, make it right, make it fast", with the fuller variation and the
three points behind it: premature optimization, technical debt, iterative
improvement. Requested by Griefed; placed first in ## Conventions because it
governs the order the rest are applied in.

Grounded in this project rather than quoted abstractly. The Knuth point has
receipts here: B30 was a genuine 121,492-byte-per-startup saving that measured at
~0 ms, because the twelve manifest checks run concurrently and the slowest gated
the batch -- so it was the wrong target, and the right one (B31, ~392 ms off the
blocking path) was only visible once something was running to measure. The
technical-debt point is why BACKLOG.md demands a stated reason and cold-start
context per deferral instead of being a wish-list.

Also written down is how it squares with TDD and "no shortcuts", because read
carelessly it licenses the exact failure this file already documents: a performance
branch whose tests were written by the same pass that changed the code and passed by
construction. The ordering is about which concern comes first, not permission to
skip pinning -- "make it work" is what the characterization test asserts, and the
other two are the steps that test then protects.

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>
`.claude/` became a tracked directory when `.claude/rules/` landed, and nothing
kept the per-developer files out of it. The hazard is specific:
`.claude/settings.local.json` is where local permission `allow` rules go -- the
file `/doctor` writes when pre-approving commands -- so a stray `git add .claude`
would commit one developer's permission posture into the repo for everyone.
`CLAUDE.local.md` is the same shape: personal notes that load in every session and
are deliberately not shared.

Both are now ignored, with a comment saying that `.claude/rules/` and
`.claude/skills/` are checked in ON PURPOSE -- otherwise the obvious "tidy-up" is
to ignore `.claude/` wholesale, which would silently un-share the build and CI
landmines added in the previous commit.

Verified with `git check-ignore -v`: the two rule files match no rule, and
`.claude/settings.local.json` matches at .gitignore:465.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
BUILD.md told contributors that `./gradlew build` is "what CI runs" and that "CI
publishes only :serverpackcreator-api", but never said where CI is. Anyone
following that would look in `.github/workflows` and find a smoke test plus four
clientside workflows -- no build pipeline, no release job -- because Forgejo
ignores that directory entirely once `.forgejo/workflows` exists. The most
confusing possible answer, reached by the most obvious route.

Two entries added under "Where to look next": `.forgejo/workflows/` as the
canonical CI, with the warning that `.github` will mislead you, and
`claude-docs/CI-SECRETS.md` for the secrets, scopes and per-job dependencies.
The second is scoped honestly -- it matters for a fork that builds releases or a
red pipeline, and not at all for building locally, which is what BUILD.md is for.

Also corrects a claim I had written twice. Both this file and
`.claude/rules/ci-workflows.md` called them "the four issue-driven `clientside-*`
workflows". Checked the triggers: three are `issues:`-driven, and
`clientside-report-reusable.yml` is a `workflow_call:` helper the others invoke.
Verified every path referenced by the new entries exists.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`docs: load the build and CI landmines only when they apply` claimed the split cut
resident context from ~10,051 to ~6,810 est. tokens per session. That number is a
character count of files on disk divided by four. It answers "how big is this file";
it does not answer "does this load" -- and the entire value of the change rests on
the second question.

The mechanism was taken from a tool description, never checked. Checked now:
claude-code#16299 -- path-scoped rules in .claude/rules/ loading into context
globally regardless of their paths: frontmatter -- is OPEN, reported against 2.0.76
with a repro, labelled area:core and perf:memory, no maintainer response and no
known workaround. If that regression is still live on 2.1.239, the migration saved
nothing at all: the same text loads every session from a different file, 1,798
characters larger than before.

Both rule files now say so at the top, with the issue linked and `/memory` in a
fresh session named as the way to settle it. This session cannot: the files were
created mid-session, so its context snapshot predates them.

The caveat also separates the two risks, because they are not the same size. The
accounting risk is real and unresolved. The correctness risk is small: the two known
bugs bracket the outcome rather than straddling it -- #16299 makes path-scoped rules
load globally (benign here, the landmines just stay always-on), and #22170 makes them
load never but only under ~/.claude/rules/, while these are project-level, which is
that issue's documented workaround. Neither failure mode silently drops a project
rule, so the landmines are not at risk of disappearing. Only the saving is at risk of
being fictional.

Not reverting on this. If #16299 is live we have lost nothing but a slightly larger
byte count; if it is fixed, the saving is real. What was wrong was stating it as
measured.

Verified both frontmatter blocks still parse after the edit (5 and 3 paths).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`.github/workflows` holds four `clientside-*` files, but only three are driven by
GitHub issues -- `clientside-report-reusable.yml` is a `workflow_call:` helper the
other three invoke. Verified by reading the `on:` block of each.

The claim originated in the root CLAUDE.md and was copied into three files before
anyone checked it. Two were corrected while adding BUILD.md's CI pointers; this was
the one left, and iteration 10's audit found it. Same defect class as "cite names,
not snapshots", in its copy-paste form: an unchecked fact propagates faster than it
gets verified.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
One HIGH: the lazy-loading migration asserted a context saving derived from a
char count rather than from what actually loads, against a mechanism whose
path-scoping has an open regression report. Remediated in the two commits before
this one.

Two process findings recorded rather than fixed, because both are commit-boundary
judgements that are now history: the migration carried an unrelated whitespace
repair (bundled at Griefed's explicit request, and disclosed in its message), and
the BUILD.md commit carried a claim-correction discovered while verifying its own
addition.

Also lists what was checked and found clean, so it is not re-audited: every glob in
both rule files resolves to real files, both frontmatter blocks parse, nothing was
lost in the split (the three files are 1,798 chars larger, all frontmatter and
pointers, with heading count 12 either side), the gitignore rules match exactly the
intended paths, and the api-docs note was deliberately left resident.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Iteration 10's H1 flagged the lazy-loading migration for claiming a context saving
it had never measured, and it was right to. It then prescribed the wrong
verification: run `/memory` in a fresh session. That test cannot fail. A
correctly-scoped rule is SUPPOSED to be absent from a session that has not touched
a matching file, so absence and breakage produce identical output. Griefed ran it,
reported not seeing the files, and that was the pass condition being read as the
fail condition.

The discriminating test takes one file read. Reading gradle/libs.versions.toml,
which matches build-layout.md's `gradle/*.toml` glob, caused Claude Code to inject
that rule file's entire contents into the session mid-turn, having demonstrably not
been present before. That rules out both known failure modes at once: not loading
globally (#16299) and not failing to load (#22170, which affects ~/.claude/rules/
only — these are project-level, that issue's own workaround).

So the ~3.6k est. tokens per session is real and the landmines do reach a session
that edits a build file. Both rule files now lead with the verified result AND the
method, including the explicit warning that a bare `/memory` in a clean session
proves nothing — so nobody repeats the round trip we just made. ci-workflows.md
states honestly that its own globs were not exercised directly, only the mechanism.

REFACTOR-AUDIT.md keeps iteration 10's H1 text — the file is an append-only evidence
log and the reasoning is what led to the fix — with an in-place marker so no one acts
on a stale finding, plus iteration 10a recording the resolution and the two lessons:
"ask a real runtime" has to name a test whose outcomes differ, and the absence of a
signal was nearly read as a defect one iteration after the audit wrote down that
exact trap.

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>
8.14.4 -> 9.7.1, the current release. Verified twice rather than assumed, because a
major Gradle bump against four third-party plugins is exactly where "it configured,
ship it" goes wrong:

  isolated worktree, fresh checkout   BUILD SUCCESSFUL 6m 28s   827 tests, 0 failed
  this tree, after `clean`            BUILD SUCCESSFUL 2m 59s   827 tests, 0 failed

Suite counts identical to 8.14.x — api 354 (1 skipped), app 149, clientside 88,
grinder 233 (19 skipped), plugin-example 3 — so no behavioural drift. Every plugin
tolerated it: Spring Boot 4.1.0, dokka 2.1.0, kover 0.9.9, siouan frontend 10.0.0,
install4j 13.1, jk1 license-report 3.0.1, nexus-publish 2.0.0, the foojay resolver
and i18n4k 0.11.2. The deprecation banner now reads "incompatible with Gradle 10",
so what 8.14.x was warning about is behind us.

ONE DEPRECATION REMAINS AND IT IS NOT OURS: `Project.getProperties`, which fails in
Gradle 10, reported once while configuring `:serverpackcreator-api` from inside
plugin application. Our own last caller went in the previous commit; the stack shows
only Gradle internals between the call and `applyImperative`, so it cannot be pinned
without more digging. Candidates are i18n4k 0.11.2, dokka 2.1.0 and kover 0.9.9. It
needs a plugin update before Gradle 10, not a change here.

THE MIGRATION TRAP, WHICH COST TWO WRONG DIAGNOSES: `:serverpackcreator-app:test`
died with a bare `java.io.EOFException` in 6 seconds, no result files, nothing naming
a cause. Build output written by the previous Gradle is unreadable to the new one.
I first blamed my own concurrent builds, then suspected the deprecation fixes; it was
neither — a fresh worktree passed while a used tree failed, and `./gradlew clean`
fixed it. Now a BUILD.md troubleshooting entry, because the symptom points nowhere
near the cause.

Also version-qualifies the install4j marker landmine, whose premise this bump
invalidates. It said the marker route "breaks every task in the build"; that was true
on 8.14.4's embedded Kotlin 2.0.x against install4j-gradle 13.1's 2.3.0 metadata.
9.7.1 embeds Kotlin 2.4.0 and reads it fine — tested by restoring the marker, where
`:buildSrc:compilePluginsBlocks` succeeded in 20 s having failed in 3 s before. The
mechanism still stands for any future plugin whose metadata outruns the wrapper's
embedded compiler, so the landmine now says that instead of naming one dead case. The
`alias` route stays: still correct, no longer load-bearing.

Griefed's uncommitted 8.14.5 bump was the same one line and is superseded.

`gradlew` and `gradlew.bat` are regenerated by the `wrapper` task itself, run twice
so the scripts and jar are produced by the version they launch. 9.7.1's launcher
drops the CLASSPATH indirection in favour of a direct jar launch, which is why the
diff is larger than a version string.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Merge branch 'claude-gradle-9-deprecations' into develop
Some checks failed
Continuous / Build JAR (push) Failing after 9m27s
Continuous / Build AppImage (x86_64) (push) Has been skipped
Continuous / Build AppImage (aarch64) (push) Has been skipped
Continuous / Build Install4J Media (push) Has been skipped
Continuous / Continuous Pre-Release (push) Has been skipped
Docker Test / build image (push) Failing after 1m1s
Documentation / Writerside webhelp (push) Failing after 6m10s
Documentation / Help image (push) Has been skipped
Qodana / scan (push) Failing after 2m41s
Test / build (push) Failing after 15m14s
Qodana / notify (push) Successful in 18s
da5bdaf103
fix(ci): stop running jobs inside tool containers, and drop the inert permissions
Some checks failed
Continuous / Build JAR (push) Failing after 10m51s
Continuous / Build AppImage (x86_64) (push) Has been skipped
Continuous / Build AppImage (aarch64) (push) Has been skipped
Continuous / Build Install4J Media (push) Has been skipped
Continuous / Continuous Pre-Release (push) Has been skipped
Docker Test / build image (push) Failing after 17s
Documentation / Writerside webhelp (push) Failing after 12s
Documentation / Help image (push) Has been skipped
Qodana / scan (push) Successful in 21s
Test / build (push) Failing after 12m21s
Qodana / notify (push) Successful in 11s
5bc0434b75
The Qodana job failed on Forgejo with
`exec: "node": executable file not found in $PATH` for checkout, cache AND
upload-artifact. Root cause, verified rather than guessed: Forgejo Actions is
act-based, and act runs every JavaScript action by exec'ing `node` INSIDE the job
container. GitHub's hosted runners inject a node binary into container jobs; act
does not. `command -v node` in jetbrains/qodana-jvm-community:2026.2 returns
nothing, so all three actions exited 127 and every later step was skipped.

docs.yml had the identical defect and had already failed the same way in run 133 --
`jetbrains/writerside-builder` as a job container with checkout and upload-artifact.
It only triggers on tag push, so left alone it would have surfaced at the first
release rather than in a test run.

Both now run the tool as a `docker run` from an ordinary runs-on job: the runner
image has node, and the tool image supplies only the tool.

A SECOND, INDEPENDENT BUG in the same job, which the first one masked: the step ran
`qodana-jvm-community`, which is not a binary in that image. The entrypoint is the
Qodana CLI at /opt/idea/bin/qodana and it needs the `scan` subcommand. That step
would have failed even with node present; it never got the chance because checkout
died first, so the log showed it as a 0s skip.

The replacement command line was run locally against this repo before being
committed. It opens the project, resolves SDKs and starts inspecting -- so the form,
the flags and the /data/* mount conventions are proven. It then hit code 137, out of
memory, on this machine's Docker allocation. That is a local limit, not a defect in
the invocation, but it is a real risk on the runner too: if the scan OOMs there, the
job needs more memory rather than a different command.

Also removes all seven `permissions:` blocks from .forgejo. Forgejo does not
implement the field and warns once per job that it is ignored. This is a correction,
not a cleanup: WORKFLOW-AUDIT.md's H2 was "every workflow now has a top-level
permissions: contents: read", and I reported the migration as carrying that hardening
across. It never did -- those blocks were inert from the first commit, and saying so
is worth more than leaving them to look like protection. Capability scoping on
Forgejo is per-job Authorized Integrations, configured on the instance.
.github/workflows keeps its blocks; GitHub honours them.

Both behaviours are now landmines in .claude/rules/ci-workflows.md, with the
uncomfortable corollary: a workflow that PARSES is not a workflow that RUNS. Both
defects survived three audit iterations that checked YAML validity, action pinning,
path globs and secret names -- none of which can tell you whether the runner can
execute a step.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
fix(ci): make the sibling-container steps work under docker-in-docker
Some checks failed
Continuous / Build JAR (push) Failing after 10m16s
Continuous / Build AppImage (x86_64) (push) Has been skipped
Continuous / Build AppImage (aarch64) (push) Has been skipped
Continuous / Build Install4J Media (push) Has been skipped
Continuous / Continuous Pre-Release (push) Has been skipped
Docker Test / build image (push) Failing after 2m29s
Documentation / Writerside webhelp (push) Failing after 4m2s
Documentation / Help image (push) Has been skipped
Qodana / scan (push) Successful in 3m45s
Test / build (push) Failing after 12m22s
Qodana / notify (push) Successful in 7s
d21caeb881
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>
fix(ci): use Forgejo's patched artifact actions, which do not refuse a non-GitHub host
Some checks failed
Continuous / Build AppImage (x86_64) (push) Has been cancelled
Continuous / Build AppImage (aarch64) (push) Has been cancelled
Continuous / Build Install4J Media (push) Has been cancelled
Continuous / Continuous Pre-Release (push) Has been cancelled
Docker Test / build image (push) Has been cancelled
Documentation / Writerside webhelp (push) Has been cancelled
Documentation / Help image (push) Has been cancelled
Qodana / scan (push) Has been cancelled
Qodana / notify (push) Has been cancelled
Test / build (push) Has been cancelled
Continuous / Build JAR (push) Has been cancelled
88302483ed
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>
feat(ci): expire test.yml's artifacts instead of keeping them for 90 days
Some checks failed
Continuous / Build JAR (push) Successful in 9m40s
Docker Test / build image (push) Successful in 10m3s
Documentation / Writerside webhelp (push) Successful in 38s
Qodana / scan (push) Successful in 5m56s
Test / build (push) Successful in 11m19s
Continuous / Build AppImage (x86_64) (push) Successful in 1m23s
Continuous / Build Install4J Media (push) Successful in 7m11s
Qodana / notify (push) Successful in 7s
Documentation / Help image (push) Successful in 1m45s
Continuous / Build AppImage (aarch64) (push) Has been cancelled
Continuous / Continuous Pre-Release (push) Has been cancelled
5b32620dc0
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>
feat(build): cross-package the aarch64 AppImage, so it needs no arm runner
All checks were successful
Documentation / Writerside webhelp (push) Successful in 1m45s
Qodana / scan (push) Successful in 7m43s
Continuous / Build JAR (push) Successful in 12m1s
Docker Test / build image (push) Successful in 13m46s
Qodana / notify (push) Successful in 13s
Documentation / Help image (push) Successful in 3m16s
Continuous / Build AppImage (x86_64) (push) Successful in 2m40s
Continuous / Build AppImage (aarch64) (push) Successful in 3m35s
Test / build (push) Successful in 10m56s
Continuous / Build Install4J Media (push) Successful in 8m59s
Continuous / Continuous Pre-Release (push) Successful in 5m43s
Test / build (pull_request) Successful in 14m46s
Docker Test / build image (pull_request) Successful in 15m13s
ee766bb1ae
devbuild.yml's `build-appimage-aarch64` asked for `ubuntu-24.04-arm` and got "No
matching online runner with label". The alternative to registering an emulated arm64
runner is not to need one: nothing aarch64 has to *execute* in that job. The JDK is
downloaded and unpacked, the JAR arrives from build-jar, and the bundled java is only
tested with `[ -f ]`. The single arch-bound process is appimagetool.

So build-appimage.sh now separates the two architectures it had conflated:

  HOST_APPIMAGE_ARCH  which appimagetool binary is fetched -- the one that runs
  BUILD_ARCH          which JDK is bundled, which runtime is embedded, output name

`--arch x86_64|aarch64` selects the target; without it the target is the host, so
every existing invocation behaves exactly as before. APPIMAGETOOL_BIN is now named
after the host arch rather than the target, or a cross build would look for a file
the URL never produced.

ARCH=<target> is what makes it work: appimagetool embeds the runtime for the
architecture named there rather than for its own. Verified rather than assumed --
the aarch64 tool with ARCH=x86_64 emitted "ELF 64-bit LSB pie executable, x86-64",
and identically with an explicit --runtime-file, so that flag is not needed. ARCH is
however not optional: without it appimagetool guesses from the AppDir's ELFs.

End-to-end, in a native arm64 ubuntu:24.04 container (no emulation anywhere), the
mirror image of what CI will do, against a clean worktree plus a stub JAR:

  container arch: aarch64
  misc/build-appimage.sh --arch x86_64 9.9.9
  -> fetched appimagetool-aarch64.AppImage        (host's, ARM aarch64 ELF)
  -> fetched the x64 JDK                          (jdk-21-x86_64/bin/java: x86-64 ELF)
  -> ServerPackCreator-9.9.9-x86_64.AppImage      (200,333,816 bytes, x86-64 ELF)
  -> script reported "Architecture: x86_64", exit 0

Argument handling exercised directly, since the arch resolution runs before the
Linux-only guard: default target equals host with no cross-packaging notice;
--arch x86_64 on aarch64 prints the notice; `--arch aarch64 9.9.9` still parses the
positional version; an unknown arch and a valueless --arch each fail with their own
message.

The header's "no Docker, no cross-compilation" claim is corrected, not deleted -- it
is still Docker-free, it is no longer host-arch-only.

Not carried out, for the record: the emulated-runner route. It would have needed a
`ubuntu-24.04-arm:docker://ghcr.io/catthehacker/ubuntu:act-24.04?platform=linux/arm64`
label on the runner (that image family does publish linux/arm64) plus QEMU binfmt
handlers on the runner host. The label alone pulls an arm64 image it cannot execute.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Merge pull request 'HUUUUGE HONGALABONGAMAHOOOOGS' (#670) from develop into alpha
Some checks failed
Generate Release / semantic-release (push) Failing after 2m50s
Continuous / Build JAR (push) Successful in 11m11s
Docker Test / build image (push) Successful in 14m27s
Qodana / scan (push) Successful in 12m24s
Test / build (push) Successful in 13m36s
Continuous / Build AppImage (x86_64) (push) Successful in 2m52s
Continuous / Build AppImage (aarch64) (push) Successful in 4m18s
Qodana / notify (push) Successful in 25s
Documentation / Help image (push) Successful in 4m47s
Documentation / Writerside webhelp (push) Successful in 1m14s
Continuous / Build Install4J Media (push) Successful in 9m23s
Continuous / Continuous Pre-Release (push) Successful in 5m40s
39691ae7ca
Reviewed-on: #670
`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 onto 6cd6e9af3, 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>
Both guards are red at this commit, and their failures are the two halves of the reported defect:

- `theDevEnvironmentFallbackSkipsAnUnwritableWorkingDirectory` — the resolver adopts a working directory it
  cannot write to, which is the `/` a systemd unit without `WorkingDirectory=` hands a source build.
- `anUnusableHomeDirectoryFailsWithAnActionableError` — fails with `java.io.FileNotFoundException`, the very
  exception the reported service crash died on, instead of naming the home it could not use.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Turns the two red guards green, and with them the reported service crash: a locally built artifact is a source
build (`version=dev`), a source build fell back to the process working directory, and systemd starts a unit in
`/` unless the unit file says otherwise — so the home resolved to `/`, `log4j2.xml` could not be written, and
the unguarded read that follows it killed the process with `FileNotFoundException: /log4j2.xml`, a message that
names neither the home nor where it came from.

Three changes, all on that path:

- `PathsConfig` takes the working directory as a home candidate only when it may write there. The next
  candidate, the user's home, is what a service then gets.
- `ApiProperties.init` probes the resolved home before using it and fails with the path, the `-D` that
  overrides it, the `/` explanation and the Preferences node holding the stored value. The probe writes a file
  rather than trusting `canWrite()`, which lies about directories on Windows.
- `setLoggingLevel` reports an unreadable or unwritable `log4j2.xml` instead of throwing. Recording a log level
  is a setting, not a reason to take down the caller — and the write above it was already tolerant, which is
  precisely the asymmetry that made an unwritable home fatal.

Behaviour change for embedders (see claude-docs/API-BEHAVIOUR-CHANGES.md): the `logLevel` setter no longer
propagates an IOException, and constructing ApiProperties against an unusable home now throws
IllegalStateException.

api suite: 356 tests, 1 skipped, 0 failures.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Same statements, same place in the sequence, now behind a named function so a guard can call it. Enabling
change for pinning *when* the daemon claims its SPC environment.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Red at this commit: the node claim sits *after* main()'s first log line, and no home claim exists at all.

Both matter because ApiProperties is registered as log4j's ConfigurationFactory, so the first log statement in
the process constructs one — the reported crash's stack starts in GrinderApplication.getLog, before main had
wired anything. Asserted against the source, since a JVM whose logging is already initialised cannot observe
the ordering.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Turns the ordering guard green and closes the reported service failure at its source. The daemon claimed its
Preferences node but never its home, so SPC resolved one itself — and a source build (every locally built
artifact) falls back to the process working directory, which systemd sets to `/`. Verified 2026-08-22 by
running the installed distribution from `/`: it died on `FileNotFoundException: /log4j2.xml` before reaching
Docker.

- `pinSpcHomeDirectory` names the base as SPC's home unless the operator set `-Dde.griefed.serverpackcreator.home`
  themselves. As a system property it outranks the stored preference without replacing it, so a host that already
  remembered the bad `/` is repaired by an upgrade rather than needing the preference cleared by hand.
- Both claims moved above main()'s first log statement, which is what actually builds the ApiProperties.
- `SPC_GRINDER_HOME` makes the base configurable, since it is now SPC's home as well; documented in §5, which
  the README-drift guard requires.
- The startup line now states the home, the one path it was silent about.

The three guards on `pinSpcHomeDirectory` arrive with it: they name the function, so they cannot compile before
it exists. The ordering guard — the half that pins the defect — landed red in the preceding commit.

grinder suite: 237 tests, 19 skipped (gated integration tests), 0 failures.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The grinder README's §8 unit file was missing `WorkingDirectory=`, and §7 claimed SPC's logs live under
`~/.spc-grinder` while nothing made that true — the two halves of the reported failure, one in the how-to and
one in the code. Now: the unit sets `WorkingDirectory=` and `SPC_GRINDER_HOME` with the reason each is there,
§7 states that the base *is* SPC's home and names the `-D` that moves it, and §9 has a row for both symptoms
an operator will search for.

Also landmined where the next reader will trip: that ApiProperties is log4j's own ConfigurationFactory, so the
first log statement in a process constructs one (-api and -grinder CLAUDE.md), and the two API behaviour
changes the fix carries (claude-docs/API-BEHAVIOUR-CHANGES.md).

Suites after the change: api 356/1 skipped, grinder 237/19 skipped, app and clientside unchanged — all green.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Running the grinder as a systemd service died on `FileNotFoundException: /log4j2.xml` before reaching Docker.
Two defects lined up: a source build resolves its home to the process working directory, which systemd sets to
`/`, and the daemon pinned its Preferences node but never its home — and did that after its first log
statement, which is what actually constructs an ApiProperties (it is log4j's ConfigurationFactory).

Reproduced from `/` with the installed distribution, and re-run there after the fix.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Red at this commit, and the measurement is the finding (audit iteration 12, L1): 34 of 64 concurrent probes on
one writable directory reported it unwritable, and a directory merely containing something called `poke` reports
unwritable deterministically.

`testFileWrite` probes by writing a file called `poke` — one fixed name for every caller and every process.
That was survivable while the answer only made a GUI file-chooser refuse a directory. It stopped being
survivable when `ApiProperties.requireUsableHomeDirectory` put the probe on the construction path behind a
throw, one commit earlier on this branch: each false answer is now a process that dies at startup naming a home
that is fine. Concurrent SPC processes sharing a home is the documented normal condition here, and the grinder
probes twice per start.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Turns the red guards green and closes audit iteration 12's only HIGH — a defect this branch created two commits
earlier: `requireUsableHomeDirectory` put `testFileWrite` on every ApiProperties construction behind a throw,
and `testFileWrite` probed by writing a file called `poke`, one fixed name for every caller and process.

Measured before: 34 of 64 concurrent probes on a writable directory answered "unwritable", each of which is now
a process that refuses to start; and a directory merely containing something called `poke` answered the same,
deterministically. After: 0 of 64, because the probe name is generated per call.

`Files.createTempFile(dir, ".spc-write-probe", null)`, removed in a `finally` so a failed probe cannot leave
litter in a user's home — the old path could, and with a fixed name that litter broke the next probe.

Contract unchanged: false on failure, IllegalArgumentException on a non-directory. api and app suites green; the
app matters here, since the four GUI file-chooser call sites are there.

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>
docs: audit iteration 12 — the systemd home-resolution branch, and its resolutions
Some checks failed
Documentation / Writerside webhelp (push) Successful in 1m51s
Continuous / Build JAR (push) Successful in 12m34s
Qodana / scan (push) Successful in 13m2s
Docker Test / build image (push) Successful in 14m55s
Continuous / Build AppImage (x86_64) (push) Successful in 2m45s
Continuous / Build AppImage (aarch64) (push) Successful in 2m56s
Documentation / Help image (push) Successful in 7m13s
Qodana / notify (push) Successful in 2m4s
Continuous / Build Install4J Media (push) Failing after 5m57s
Continuous / Continuous Pre-Release (push) Has been skipped
Test / build (push) Successful in 17m5s
3e873af88e
Self-audit of the seven commits merged by 8008c4160, appended to claude-docs/REFACTOR-AUDIT.md: one HIGH (the
writability probe's fixed name, now fixed), one MEDIUM (the ordering guard's window, now bounded), four LOW, and
the list of things verified clean so nobody re-litigates them — chiefly that the working-directory injection is
behaviour-preserving, that no pre-existing assertion changed on the branch, and that SPC's home sharing a
directory with the grinder's state does not collide.

Also here: the root CLAUDE.md snapshot date moved with the table it heads (L5), the grinder CLAUDE.md records the
now-expected doubled 'Loaded properties from …' line (L3), and GrinderApplication says why the base is created
where it is (L4 — comment only, no behaviour).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The report server has accepted a `host` since it was written, defaulting to
loopback, and `main` has never passed one. Nothing said it should, so the
daemon binds 127.0.0.1 and no containerised reverse proxy can reach it —
it dials the host over the bridge gateway, which a loopback socket refuses
before any HTTP happens.

Two guards, red, plus one executable pair that already passes:

- ReportBindWiringTest (RED) — main reads SPC_GRINDER_HOST and hands it to
  ReportServer as `host`, and defaults it to loopback. The join is the part
  no unit test can execute, since main boots Docker, so it is stated against
  main's own text, bounded to main's body.
- ReportServerBindAddressTest (PASSES) — connects from a real non-loopback
  IPv4: the default refuses there (ConnectException), a configured address
  answers 200. This is the mechanism the wiring guard cannot execute, and it
  reproduces the reported symptom directly; observed against 192.168.8.113.
  Skips where the host has no non-loopback IPv4.

mainBody() moves from GrinderSpcEnvironmentTest to GrindTestFixtures as
grinderMainBody() so both wiring guards share one window; no assertion
changed in the move.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
SPC_GRINDER_HOST, defaulting to 127.0.0.1, passed to ReportServer as `host`.
The port has always been configurable; the address never was, so the report
bound loopback unconditionally and no containerised reverse proxy could reach
it — such a proxy dials the host over the Docker bridge gateway, and a
loopback socket refuses that at the TCP layer. Symptom is a 502 from the proxy
while the report answers fine over an SSH tunnel.

The default does not change: the report is unauthenticated — `/`, `/status`
and `/export.csv` all answer unconditionally — so exposure stays a deliberate
act. README §5 *Exposing the report* recommends the gateway address over
0.0.0.0 for that reason, and §9 gets the symptom row.

Also: the startup banner logs `bind=`, and the "Report:" line now prints the
bound host instead of a hardcoded "localhost", which under a non-default bind
was a URL the operator could not reach — and the journal is where they look.

Turns the two guards from the previous commit green. The regex in
ReportBindWiringTest is widened to match across newlines, since the
construction it inspects is now wrapped.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Suite is 241 after the two bind guards. The skip count is a range on purpose:
ReportServerBindAddressTest needs a real non-loopback IPv4 to cross an
interface boundary, and skips on a host without one — observed both ways on
this machine as its wifi flapped, 19 skips with the interface up and 21 with
it down. A fixed number in that column would be wrong half the time.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Behaviour-preserving: reportUrl(bindHost, port) returns exactly what the
interpolation in main() returned. Lifting it out is what makes it reachable
from a test at all — the line lives in main(), which boots Docker and cannot
be executed by the suite.

Existing guards unchanged and green (ReportBindWiringTest,
GrinderSpcEnvironmentTest); no assertion moved.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Audit iteration 13, L1 and L2. Making the bind address configurable made the
logged URL follow it, which is right for a concrete address and wrong for the
two other shapes a legitimate bind can take.

Red on three of four:

  port lost for bind '::1': http://::1:8757 ==> expected: <8757> but was: <-1>
  ipv6LiteralsAreBracketed  expected: <http://[::1]:8757> but was: <http://::1:8757>
  aWildcardBindIsReportedAsLoopback expected: <http://127.0.0.1:8757> but was: <http://0.0.0.0:8757>

The IPv6 case is the one worth noting: URI.create does not throw on the
unbracketed form, it silently parses the port as -1, so nothing would ever
have surfaced this at runtime.

concreteIpv4AddressesAreLeftAlone passes already — the ordinary path is
pinned before it is touched, so the fix is provably confined to the two
broken shapes.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Audit iteration 13, L1 and L2. A wildcard bind means "every interface", which
is not a destination, so it is now reported as the loopback the report is
certainly answering on; an IPv6 literal is bracketed per RFC 3986, without
which URI parses the port as -1 and says nothing.

Turns the previous commit's three red guards green. concreteIpv4Addresses-
AreLeftAlone was green before and after, so the ordinary path — the only one
either the default or the documented gateway recommendation ever takes — is
demonstrably untouched.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Audit iteration 13, L3 and L4. No expectation changes; all four guards green
before and after.

L3 — four new !! went in against the Kotlin convention, all of them only
because assumeTrue and assertNotNull do not smart-cast. nonLoopbackIpv4()
now returns String and throws TestAbortedException itself, which JUnit
reports as a skip exactly as the assumption did; the two regex lookups use
elvis into Assertions.fail, which returns Nothing.

L4 — theDefaultIsReachableOnLoopbackOnly asserted ConnectException while
claiming "unreachable". A host that DROPs rather than REJECTs delivers that
same verdict as a connect timeout, and the narrower type would have failed
on a box where the guarded property holds. Widened to IOException, the
common supertype of both.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Audit of 97f487e0e..HEAD. Thirteen commits were iteration 12's and are
re-affirmed rather than re-litigated; scrutiny fell on the four new ones.
No HIGH. Two MEDIUM, six LOW; all fixed except M2 (already-merged history)
and L5 (recorded — every fix costs more than the flake).

Also backfills REFACTOR-LOG.md, which M1 caught two branches stale at
2026-08-17: entries for the 2026-08-22 systemd home-resolution branch and
for this one. Iteration 12 had not caught that about itself.

Grinder suite 245, zero failures, 38 classes.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
deploy/spc-grinder.service lists all 16 variables the service reads — the 15
SPC_GRINDER_* plus CURSEFORGE_API_KEY — commented out with their real
defaults. SPC_GRINDER_HOME is the one deliberately left active: its code
default follows the home of User=, so pinning it means changing the account
cannot silently relocate the daemon's state.

Two things in it are load-bearing rather than decorative:

- WorkingDirectory=, because systemd starts a unit in / and that is the
  FileNotFoundException: /log4j2.xml this service first died on.
- No PrivateTmp=, ProtectHome= or other namespacing. The grinder bind-mounts
  host paths into its containers and the *docker daemon* resolves those in
  the host namespace, so a path this unit can see but the daemon cannot fails
  at container creation, pointing nowhere near systemd. NoNewPrivileges and
  ProtectSystem=full namespace nothing and are safe.

deploy/install-grinder.sh does the four steps: image, installDist, install to
/opt/spc-grinder, service account with home and docker group. Notes:

- It refuses to run as root. The Gradle build has to run as the invoking user
  or it leaves root-owned files in build/; the privileged steps call sudo
  themselves, with one sudo -v up front rather than four scattered prompts.
- The distribution's bin/ and lib/ go to /opt/spc-grinder/, which puts the
  launcher at /opt/spc-grinder/bin/serverpackcreator-grinder as ExecStart
  expects. lib/ is replaced wholesale, not merged: the launcher pins an
  explicit jar list so a stale jar is never loaded, but a version bump renames
  one and merging would accumulate every version ever installed.
- A running service is stopped before its jars are replaced and started again
  afterwards. Replacing jars under a live JVM surfaces hours later as a
  class-loading failure with nothing tying it back to the install.
- The unit is copied only with --install-unit, and never enabled or started;
  those commands are printed instead.

.gitignore needed a scoped exception: `deploy/` was already ignored as a
build-output directory by the JDeveloper/IDEA template block, which silently
swallowed both files on the first attempt to commit them.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Same job ReadmeConfigurationTest does for the README table, for the unit: a
knob added to the service and forgotten in the unit is invisible to whoever
deploys from it, and a default quoted there that the code no longer uses is
worse than no comment — it reads as authoritative.

Teeth confirmed by mutation rather than assumed, since all four passed on
first run and this project has twice shipped a guard that asserted nothing.
Four mutations, four distinct failures:

  drop #Environment=SPC_GRINDER_BATCH=25  -> does not mention SPC_GRINDER_BATCH
  add  #Environment=SPC_GRINDER_BOGUS=1   -> declares variables nothing reads: [SPC_GRINDER_BOGUS]
  PORT 8757 -> 9999                       -> wrong default for SPC_GRINDER_PORT
  comment out WorkingDirectory=           -> no active WorkingDirectory=

Unit restored byte-identical afterwards (verified by diff) and all four 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>
Audit iteration 15. The iteration-13 fix brackets any address containing a
colon, which double-brackets one an operator wrote in bracketed form — and
that form is legitimate: verified against the JDK's HttpServer, which binds
"[::1]" happily and reports 0:0:0:0:0:0:0:1.

Red:  expected <http://[::1]:8757> but was <http://[[::1]]:8757>

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Audit iteration 15, L1 and L2 — both defects introduced by iterations 13 and
14's own fixes, which is what the third pass was for.

L1 — reportUrl bracketed any address containing a colon, so a bind an operator
wrote as "[::1]" came back as http://[[::1]]:8757. The bracketed form is
legitimate: HttpServer binds it and reports 0:0:0:0:0:0:0:1. Turns the
previous commit's guard green; the other four cases were green before and
after.

L2 — iteration 14's own L3 fix added a line to the header block, which pushed
the last usage line out of the fixed `sed -n '2,21p'` range, so --help
silently stopped documenting --skip-image. Replaced with an awk that prints
the contiguous comment block however long it grows. A fixed line range was
wrong within one commit of being written, which is this project's "cite names,
not snapshots" rule showing up in a shell script.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
No HIGH. One MEDIUM recorded, two LOW fixed — and both LOWs were defects that
iterations 13 and 14's own fixes introduced, which is the case for running a
third pass at all.

M1 is against my own work and stays recorded rather than corrected:
ad7aff574 is labelled refactor: while widening an existing assertion from
ConnectException to IOException. The conventions call that the stop-and-flag
signal outright, and the reference-only carve-out does not apply — no symbol
moved, the expectation changed. Same disposition as 358675fbf already in this
file: the commit is on develop, the body is honest, only the type lies, and
rewriting merged history to relabel it costs more than it returns.

Grinder suite 250, zero failures, 39 classes.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Targeted review of deploy/install-grinder.sh and deploy/spc-grinder.service,
verified against real runtimes rather than reasoned about: shellcheck,
systemd-analyze verify, and useradd in Debian containers.

Two HIGH. Nothing provides or checks Java, and the Gradle launcher cannot
start without JAVA_HOME or java on PATH — which systemd does not supply;
the installer builds with Gradle so the operator's own PATH hides it until
the first systemctl start. And an existing account named by SERVICE_USER is
added to the docker group without confirmation, which is root-equivalent.

Three MEDIUM: Group=grinder can name a group useradd never created
(reproduced with USERGROUPS_ENAB no — gid lands on 100(users)); a failure
after the service is stopped leaves it stopped; --install-unit installs a
unit it has just warned is mismatched.

Read-only, per the audit rule. No source touched.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Audit iteration 16, H1. Red:

  the unit does not mention JAVA_HOME — the launcher reads it and systemd
  will not supply it

The three variables are read by the Gradle launcher rather than by any
Kotlin, so no env(...) call names them and the existing phantom-variable
guard would have rejected them; they are whitelisted as launcher-read for
exactly that reason.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
All ten, verified on real Linux in Debian and Fedora containers rather than
reasoned about. shellcheck clean at -S style, systemd-analyze verify reports
no unit defects.

H1 — nothing provided or checked Java, and the launcher cannot start without
JAVA_HOME or java on PATH. The unit gains a JVM section naming the exact
error and systemd's actual PATH, plus JAVA_HOME/JAVA_OPTS/
SERVERPACKCREATOR_GRINDER_OPTS. The installer checks against systemd's PATH
via `env -i`, not the caller's — the operator has java from a profile the
service never reads, which is what made this invisible. Observed firing in a
container with no JDK.

H2 — an already-existing account was added to the docker group silently.
That group is root-equivalent. An account the script *creates* is still
added automatically; one that already existed now requires --grant-docker.
Verified: refused, and `id -nG grinder` unchanged; granted with the flag.

M1 — Group=grinder can name a group useradd never created. Reproduced with
USERGROUPS_ENAB no (gid landed on 100(users), no grinder group), so the unit
would fail to start on an unresolvable group. The script now reads Group=
out of the unit and creates it if missing. Verified on that same host: group
created, unit's Group= resolves.

M2 + L1 — `set -E` was inert with no ERR trap, and a failure after the
service was stopped left it stopped. Both fixed by the same pair of traps:
ERR reports the failing line, EXIT restarts the service if the install died
after stopping it.

M3 — the unit/install consistency check ran after everything was installed,
and --install-unit then installed the mismatched unit anyway. Moved into
preflight and made fatal when it would install. Verified: an override plus
--install-unit now dies before anything is built.

L2 — chmod a+rX adds read and cannot remove write, so a permissive umask
carried group-writable modes into the service's own binaries. go-w first.
Verified 755 root-owned.

L3 — docker build --pull by default, with --no-pull for an offline rebuild.
L4 — SyslogIdentifier=spc-grinder.
L5 — Documentation lists git.griefed.de first, GitHub second.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
docs: record iteration 16's resolutions, and the JVM trap in the README
All checks were successful
Documentation / Writerside webhelp (push) Successful in 1m40s
Qodana / scan (push) Successful in 7m45s
Continuous / Build JAR (push) Successful in 11m48s
Docker Test / build image (push) Successful in 14m9s
Test / build (push) Successful in 14m48s
Docker Test / build image (pull_request) Successful in 12m44s
Test / build (pull_request) Successful in 14m32s
Documentation / Help image (push) Successful in 8m27s
Qodana / notify (push) Successful in 25s
Continuous / Build AppImage (x86_64) (push) Successful in 2m41s
Continuous / Build AppImage (aarch64) (push) Successful in 3m15s
Continuous / Build Install4J Media (push) Successful in 11m47s
Continuous / Continuous Pre-Release (push) Successful in 7m42s
845fb6381b
README §8 gains the Java requirement — it is the deployment's most likely
first failure and the least self-evident, since the JDK you build with comes
from a profile the service never reads.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Merge pull request 'EIN BLOCK, ZWEI BLOCK, DREI BLOCK, VIIIIIIIER' (#671) from develop into alpha
Some checks failed
Documentation / Writerside webhelp (push) Successful in 2m0s
Documentation / Help image (push) Has been cancelled
Qodana / notify (push) Has been cancelled
Test / build (push) Has been cancelled
Docker Test / build image (push) Has been cancelled
Qodana / scan (push) Has been cancelled
Generate Release / semantic-release (push) Successful in 2m58s
98cf873e6a
Reviewed-on: #671
RELEASE: 9.0.0-alpha.6
Some checks failed
Generate Release / semantic-release (push) Has been skipped
Docker Test / build image (push) Successful in 15m29s
Build Release / Preparations (push) Successful in 14s
Documentation / Help image (push) Successful in 2m48s
Documentation / Writerside webhelp (push) Successful in 1m20s
Test / build (push) Successful in 13m25s
Qodana / notify (push) Successful in 7s
Qodana / scan (push) Successful in 12m22s
Build Release / Docker images (push) Successful in 12m33s
Build Release / JARs, media and checksums (push) Successful in 27m34s
Build Release / Forgejo release (push) Successful in 2m54s
Build Release / VirusTotal scan (push) Successful in 2m55s
Build Release / Publish Maven (push) Successful in 6m38s
Build Release / Mirror release outward (push) Failing after 3m1s
f7ebba4e71
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>
`ContainerUser.forDirectory` resolves the work directory's owning uid:gid and
both container paths -- DockerLoaderInstaller and ContainerServerRunner -- now
run as that instead of the image's baked-in 1000:1000. `SPC_GRINDER_CONTAINER_USER`
overrides it for setups where the owner is not the right answer; a non-POSIX
filesystem or an unreadable path falls back to the image's own user, which is
the previous behaviour.

The owner of the directory is the question, not this process's uid: it is the
identity that has to be able to write there, and it stays correct if
SPC_GRINDER_WORK is relocated onto a share owned by somebody else.

The resolved identity is logged on the startup line beside bind/port/workers, so
the value can be checked without reproducing the failure.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
DockerLoaderInstaller reports `output.lines.takeLast(25)` on a failed install.
The console of the real 2026-08-23 failure is exactly the shape that defeats:
the three `Permission denied` lines sit near the top, and the last 25 lines
carry only the JVM's downstream `Error: could not open 'user_jvm_args.txt'`,
which reads as a start-script-template bug. That truncation is what sent the
diagnosis after networks, templates and loader versions in turn.

The third case pins the other half of the contract: a console with no
recognisable cause must stay silent rather than invent one.

Red: `InstallFailureDiagnosis` does not exist yet.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The failure warning now consults InstallFailureDiagnosis, which scans every
captured line rather than the 25 the warning quotes, and names the install
console's path so the full record is one `cat` away instead of a directory
walk.

Recognises the unwritable mount for now, and nothing it cannot actually
identify -- the raw tail stays the fallback.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`BrowserDownloader.download` navigates to CurseForge's `/download` page from
inside `waitForDownload`. CurseForge answers that with a file transfer, and
Chromium aborts a navigation that turns into one -- Playwright throws
`net::ERR_ABORTED`. The throw escapes the callback and tears the wait down, so
a download that had actually started is discarded.

Observed live 2026-08-23 on bwncr-neoforge, tombstone-neoforge and Structory,
each stacked at `_FrameSession._navigate`.

The second test pins the other side: a real navigation failure or a timeout must
still fail, or the downloader would silently return nothing forever.

Red: `isDownloadAbort` does not exist yet.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Three changes to the locked-file download flow, all from the 2026-08-23 grinder
run in which every CurseForge locked file failed:

- The `/download` navigation is wrapped so a `net::ERR_ABORTED` -- Chromium
  cancelling a navigation that turned into a file transfer -- no longer escapes
  the `waitForDownload` callback. A genuine navigation failure still propagates.
- Both navigations wait for DOMCONTENTLOADED instead of Playwright's default
  `load`. A CurseForge project page keeps fetching ads and trackers long after
  it is usable, so `load` turns a working page into a timeout.
- The 30s default becomes a configurable 60s. Every timeout in that run was
  exactly `Timeout 30000ms exceeded`, i.e. the default, never a page-specific
  budget.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`/as-properties` is meant to be pointed at by an SPC instance's
`de.griefed.serverpackcreator.configuration.fallback.updateurl`, so the fallback
clientside-mod list stops depending on a maintainer hand-editing the repository.

The pins parse the rendered text with `java.util.Properties` rather than
asserting on its shape, because that is precisely what the consumer
(`UpdateConfig.updateFallback`) does -- a document that merely looks right is
worth nothing. `Properties.load(InputStream)` decodes ISO-8859-1, which is why
one pin puts a non-ASCII entry through a round-trip.

Pinned: only HIGH is published (a clean boot proves nothing, so MEDIUM/LOW/
INCONCLUSIVE stay out), one entry per mod however many loaders crashed, the
whitelist passes through so the endpoint replaces the GitHub URL wholesale,
output is order-stable so polling does not churn, and the document is valid
with nothing to publish.

Red: neither `FallbackPropertiesRenderer` nor `FallbackLists` exists.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
An SPC instance can now point its
`de.griefed.serverpackcreator.configuration.fallback.updateurl` at the grinder
and receive the shipped clientside-mod list plus every mod the grinder has
proven clientside by crashing a real server with it -- so the list stops
depending on a maintainer editing the repository by hand.

Only HIGH confidence is published. A clean boot proves nothing, and a wrong
entry silently strips a mod out of every server pack built against the list, so
the gate is a floor rather than a threshold to tune.

The whitelist is passed through untouched, which makes the endpoint a drop-in
replacement for the GitHub raw URL rather than a partial one that would quietly
freeze a client's whitelist.

Two encoding details the format forces: the document is written for
`Properties.load(InputStream)`, which decodes ISO-8859-1, so entries are
`\uXXXX`-escaped and the response is served as ISO-8859-1 rather than the UTF-8
every other endpoint uses. Output is order-stable, so a poll that sees a
difference has seen an actual change.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`ContainerUser.forDirectory` no longer defaults its `override` to
`System.getenv(ENV_KEY)`; `GrinderApplication` passes it in, alongside every
other environment knob it reads.

Behaviour-preserving -- the same variable, read once, at a different call site
-- and no existing assertion changed: the tests already passed `override`
explicitly, which is what made the default dead weight.

The reason it matters is the two documentation guards. ReadmeConfigurationTest
and SystemdUnitConfigurationTest both scan the entry point's source for the
names it reads, so a knob consulted anywhere else is invisible to them and can
be added without ever reaching the README table or the shipped unit.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Playwright + Chromium was listed as a host prerequisite in the module's
CLAUDE.md and nowhere an operator reads: README §1 named only Docker and JDK
21+, and install-grinder.sh checked only those two. It now checks
$SERVICE_USER's own Playwright cache -- the caller having a browser proves
nothing, since Playwright keeps them under $HOME -- and says what to run.

Also documented: SPC_GRINDER_CONTAINER_USER (README table, new §5 *Container
identity*, systemd unit, installer summary), the /as-properties endpoint with
its do-not-point-the-grinder-at-itself landmine, and three troubleshooting rows
-- the unwritable mount, the two distinct locked-CurseForge failures, and the
firewall case for a reverse proxy that still cannot connect after the bind was
widened (timeout vs. connection-refused is the discriminator).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Landmines in the two module files (a container must run as the owner of the
directory it mounts; the /download navigation is supposed to fail), the grinder
and clientside rows in the root table with re-derived counts, and the
blow-by-blow in REFACTOR-LOG.

The reporting failure is recorded beside the bug on purpose: the tail-quoting
warning is what sent three rounds of diagnosis after the wrong subsystem, and
"every loader failed at once" is the give-away worth keeping.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Ten commits reviewed against the refactoring conventions. No HIGH findings.
Five MEDIUM (a code change inside a docs commit, two unpinned joins, a comma
that silently corrupts the published list, a silently-ignored malformed
override) and five LOW.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
M4: an entry containing a comma cannot survive the round trip. The consumer
splits the value on commas, so one such entry reaches every client as two bogus
`startsWith` matchers against real mod filenames. Filenames may legally contain
commas and stems are derived straight from them, so it needs nothing unusual to
happen -- and it is silent at both ends. Pinned: such an entry is dropped from
both lists, and the document says it dropped something, because dropping
silently is how a list quietly goes wrong.

M5: a malformed SPC_GRINDER_CONTAINER_USER is discarded and the owner used
instead. Right behaviour -- nonsense must not reach Docker -- but this is the
one knob whose purpose is overriding a resolution that already went wrong once,
so the operator has to learn it was ignored.

Red: `isUsableOverride` does not exist, and the renderer currently emits a
comma verbatim.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
M4: an entry containing a comma is dropped from both published lists instead of
emitted, and the document states how many it dropped. Emitting it corrupted the
value at the far end into two bogus prefix-matchers; dropping it silently would
just move the corruption somewhere quieter.

M5: a malformed SPC_GRINDER_CONTAINER_USER is now logged as ignored, naming the
value that was used instead. Only when one was actually set -- an unset variable
is the normal case and needs no comment. `isUsableOverride` carries the rule, so
what "usable" means is pinned rather than living inside an if.

Numeric ids only, and now documented as to why: the ids resolve against the
*container's* /etc/passwd, so a host account name either fails to start the
container or silently means somebody else inside it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Audit iteration 17's LOW findings, no behaviour change:

- ReportServer.respond chose ISO-8859-1 for /as-properties, but the renderer
  escapes everything outside printable ASCII to \uXXXX, so both encodings
  produce identical bytes. The declared charset in the header stays -- that one
  is load-bearing for the consumer -- and the branch goes.
- The continuation-line rendering appended a backslash and removed it again for
  the last entry, asking the same index question twice in one expression.
- The two lists are normalised once each rather than twice, so the header's
  counts and the rendered entries cannot disagree.
- Test hygiene: two `!!` replaced with the repo's `?: Assertions.fail(...)`
  idiom, two fully-qualified names replaced with imports.

Every existing assertion unchanged and green.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
M2 — BrowserDownloader changed three behaviours and pinned one. The other two,
DOMCONTENTLOADED over Playwright's default `load` and a 60s budget over the 30s
default that timed out on every locked file, are decided host-side before any
browser exists, so they are pinned by *building* the options rather than by
reading the source for them.

M3 — /as-properties had no guard on its production wiring. ReportServerTest
supplies its own lambda, so the endpoint could be fed an empty list and every
test would stay green while every polling client silently lost the shipped
entries. `main` cannot be executed (it builds an ApiWrapper and a Docker
client), so the join is asserted against its source, the same technique
ReportBindWiringTest uses for SPC_GRINDER_HOST. The second case pins that the
lists are read per request rather than captured at startup -- a daemon runs for
weeks, and a snapshot taken at boot defeats the point.

Red: `navigationOptions`/`downloadOptions` are not extracted yet.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Both `page.navigate` calls and the download wait now take their options from
`navigationOptions()` / `downloadOptions()` instead of constructing them inline
three times. Behaviour-preserving -- identical values, built in one place -- and
no existing assertion changed.

The point is testability: the choice of DOMCONTENTLOADED and the timeout are
made host-side, before Chromium exists, so extracting them turns two behaviours
that could only be verified by reading the source into two that are executed by
the suite.

Teeth verified for the sibling wiring guard by breaking the join and watching
FallbackListWiringTest go red on "the published clientside list must come from
SPC's own property".

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Every other guard on this endpoint checks the document against
`java.util.Properties`, which is a *model* of the consumer. This one is the
consumer: a real `UpdateConfig.updateFallback` pointed at a running ReportServer
over an ephemeral loopback port, asserting the entries land in
`GenerationConfig.clientsideMods` -- the list generation actually excludes mods
with -- that an INCONCLUSIVE finding does not, and that the whitelist survives.

No Docker and no internet needed: PropertyStore is no-arg constructible and the
server binds loopback, which is why this is a plain test rather than a gated IT.

Teeth verified by dropping the continuation backslash from the renderer: the
whole list collapses to `[, entityculling-]` and both cases go red.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Second pass over the branch. Iteration 17's ten findings verified closed.

P2-M1 (fixed here): UpdateConfig replaces a client's lists wholesale, so the
base list this endpoint publishes is only as fresh as the grinder's own SPC
instance -- an old build, or one that could not reach the repository at startup,
hands every client a staler list than they had. Now stated in README §5 and the
module landmine.

P2-M2 (open, cannot be closed from this host): the container-user fix has no
real-runtime verification. The attempt is recorded because its failure is
instructive -- with a named volume chowned to 1001:1001, a root container reads
it back as 1001:1001 and a --user 1001:1001 container reads the same inode as
0:0. That is Docker Desktop's id remapping, not kernel DAC, so neither the bug
nor the fix reproduces here and the run proves nothing. The audit carries the
two-command check to run on the Linux host instead.

P2-L1: dropped @JvmStatic from a helper with no Java callers.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
No behaviour change; every existing assertion green.

- unrepresentable() counted duplicates while normalise() de-duplicated, so one
  mod dropped on three loaders was reported as three omissions -- sending a
  reader looking for two entries that never existed.
- The DOMCONTENTLOADED rationale sat both inline and in navigationOptions()'
  KDoc. Two copies of a reason is one copy that goes stale; the inline one goes.
- Root CLAUDE.md counts re-derived from the run that produced them (grinder 276,
  clientside 93) rather than adjusted by hand.

REFACTOR-LOG records the three audit passes, including the equivalence check:
develop's unmodified test tree against this branch's production code, 339
pre-existing guards, zero failures, zero compile errors.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
develop's unmodified test tree against this branch's production code: 339
pre-existing guards, zero failures, zero compile errors, no file needing
adaptation -- every changed signature gained a defaulted parameter.

Three LOW findings, all fixed. One item stays open and cannot be closed here:
the container-user fix has no real-runtime verification, because Docker
Desktop's id remapping means neither the bug nor the fix reproduces on this
workstation.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
merge: grinder container identity, CurseForge downloads, and /as-properties
All checks were successful
Documentation / Writerside webhelp (push) Successful in 1m29s
Qodana / scan (push) Successful in 8m7s
Continuous / Build JAR (push) Successful in 12m22s
Docker Test / build image (push) Successful in 13m59s
Documentation / Help image (push) Successful in 2m51s
Test / build (push) Successful in 15m14s
Qodana / notify (push) Successful in 8s
Continuous / Build AppImage (x86_64) (push) Successful in 2m40s
Docker Test / build image (pull_request) Successful in 14m25s
Test / build (pull_request) Successful in 14m6s
Continuous / Build AppImage (aarch64) (push) Successful in 3m18s
Continuous / Build Install4J Media (push) Successful in 10m31s
Continuous / Continuous Pre-Release (push) Successful in 6m59s
f2be9d5b4e
Three production defects found from a live grinder run, plus one new endpoint.

- Containers now run as the owner of the directory they mount, not the image's
  baked-in USER 1000:1000. That mismatch appeared with the systemd migration and
  made every loader install fail with Permission denied inside the pack -- while
  the visible error, twenty lines later, was the JVM's missing @argfile.
- A failed install is diagnosed from the whole console rather than its last 25
  lines, which is where the cause was not.
- Locked CurseForge files survive their own download: Chromium aborts a
  navigation that becomes a file transfer, and that throw used to discard the
  download it had just triggered. Navigations also stop waiting for the `load`
  event, which an ad-laden CurseForge page reaches long after it is usable.
- /as-properties publishes the fallback clientside-mod list -- shipped list plus
  crash-proven findings -- for an SPC instance's fallback.updateurl to poll, so
  the list stops depending on hand-edits to the repository. HIGH confidence only.
- Deployment gaps closed: Playwright/Chromium is now a checked prerequisite,
  SPC_GRINDER_CONTAINER_USER is documented in three places, and the firewall
  case for an unreachable reverse proxy has a troubleshooting row.

Audited three times (REFACTOR-AUDIT iterations 17-19); every finding fixed
except one that cannot be closed off the Linux host, recorded there. develop's
unmodified test tree against this branch's code: 339 guards, zero failures.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The `Mirror to GitLab.com` step reported `exitcode '22': failure` in 0 s and
nothing else. Exit 22 is curl's --fail, i.e. HTTP >= 400, and the cause is not
in the workflow: gitlab.com's copy of the repository has not received a commit
since 2024-04-28. Its newest commit is `RELEASE: 5.2.1` (071e55402), 1397
commits behind main, and its newest tag is 5.2.1 -- four major lines behind the
9.0.0-alpha tags being released.

GitLab's POST /releases needs either an existing tag_name or a `ref` commit to
mint the tag from, and that repository has neither, so the step could never have
succeeded: the git push-mirror died with the GitLab->Forgejo migration, which is
the same migration that added this step. Its comment anticipated a 404 and added
`ref` to fix it -- the right fix for a mirror that is behind, useless for one
that is stopped. Checked anonymously; the project id 32677538 resolves fine, so
this was never a wrong-target or token-scope problem.

GITLABCOM_TOKEN stays: the maven job still uploads to GitLab's package registry,
which does not depend on git refs and is therefore unaffected.

Second change, same job. Every call used `curl -sf`, which sets exit 22 and
discards the response body -- the only place these APIs say what is wrong, and
the reason this failure arrived as four words. The Forgejo notes fetch and both
GitHub calls now capture the status with `-o file -w '%{http_code}'` and print
the body before exiting. Verified by executing the idiom against gitlab.com's
API: an existing project takes the success branch on 200, a missing one takes
the error branch on 404 and prints `{"message":"404 Project Not Found"}`.
`release`, `virustotal` and the release-body update still use `curl -sf`;
flagged in the rule file rather than changed here, to keep this to one concern.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The `Mirror to GitLab.com` step could never have succeeded. gitlab.com's copy of
the repository has not received a commit since 2024-04-28 -- newest commit
`RELEASE: 5.2.1`, 1397 behind main -- so GitLab's release API has neither the
tag nor a `ref` commit to mint it from, and returns HTTP >= 400 every time.
GitHub is now the only outward mirror.

GITLABCOM_TOKEN stays for the maven package-registry upload, which does not
depend on git refs.

Also in the same job: the `curl -sf` calls that turned this into a four-word log
line now capture the HTTP status and print the response body before exiting.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Stopping the unit currently relies on two things that do not hold.

Workers: `requestStop` sets a flag the worker loop reads *between* candidates,
so a worker parked inside a boot keeps going until that boot finishes -- up to
its fifteen-minute budget -- while systemd counts down to a SIGKILL that
orphans the container. Pinned: an in-flight verification is interrupted, and a
worker that ignores the interrupt is abandoned at the grace window rather than
waited out.

Containers: `close` went straight to `remove --force`, which is a SIGKILL to
PID 1 -- an in-flight Minecraft server loses its world save. Pinned by trapping
TERM inside the container and asserting the handler ran.

Two holes with no coverage at all:
- Nothing stopped a worker creating a container *after* close had swept. Once
  shutdown hooks run the JVM no longer waits for worker threads, so a worker
  between its loader install and its mod boot could start one that was never
  removed. Pinned: a closed engine refuses to create.
- A SIGKILLed JVM leaves containers running, parented by the docker daemon
  rather than the unit's cgroup, and they carried no label, no name and no
  autoremove -- so nothing could ever find them again. Pinned: a labelled
  orphan is reaped by a fresh engine, which is what the next start brings up.

Red: `GrindPool.awaitStop`, `ContainerEngine.reapOrphans` and the OWNER_LABEL
constant do not exist. The container cases are gated behind GRINDER_DOCKER_IT=1
as the rest of that file is.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Stopping the unit now signals both halves and gives them one shared 15s window
(SHUTDOWN_GRACE) before anything is killed.

Containers. `close()` issues `docker stop` with the window as its timeout --
SIGTERM, then the daemon's own SIGKILL -- instead of going straight to
`remove --force`, which was a SIGKILL to PID 1 and cost an in-flight Minecraft
server its world save. Stops run concurrently, capped at 8: the window is per
container, so ten workers stopped one after another would be ten windows and
would run past the unit's TimeoutStopSec into the very SIGKILL this avoids.

Workers. `GrindPool.awaitStop(grace)` signals, interrupts and joins with a
deadline. `requestStop` alone could not end a shutdown, because its flag is only
read between candidates -- a worker parked in a boot kept going for up to that
boot's fifteen-minute budget. A worker that ignores the interrupt is abandoned
and logged rather than waited for; nothing can force a thread to die in the JVM,
so the real force-kill is the process exiting.

Two holes closed at the same time:

- A `closed` flag makes the engine refuse to create a container once `close()`
  has begun, re-checked after the tracking-set add so a container created in
  the gap removes itself. Previously a worker between its loader install and
  its mod boot could start one behind the sweep and have it outlive the JVM.
- Every container carries the label `de.griefed.serverpackcreator.grinder`, and
  startup reaps whatever wears it. That is the only recovery from a SIGKILLed
  JVM: containers are children of the docker daemon, not of the unit's control
  group, so systemd never touches them, and with no label, no name and no
  autoremove nothing could find them again -- an orphan survived every restart
  forever. LANDMINE noted in the code: the label says "a grinder made this", not
  "this grinder", so it assumes one instance per daemon, which the unit is.

Verified against a live daemon (docker 29.7.2, GRINDER_DOCKER_IT=1): 6/6 cases,
including a container trapping SIGTERM to prove the signal arrives and is
honoured before removal, and a labelled orphan reaped by a fresh engine. The
pre-existing drain case now takes 15.6s rather than being instant -- busybox's
shell does not forward SIGTERM to `sleep`, so it uses the whole window and is
then killed, which is exactly the intended behaviour.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
README gains a "Stopping it" section with the five ordered steps and the two
facts that make them necessary: workers are threads, so systemd has nothing to
kill separately, and containers belong to the docker daemon's control group
rather than the unit's, so systemd cannot reach them at all.

TimeoutStopSec 120 -> 60, with the arithmetic stated: 15s per container, 8 at a
time, so ceil(workers/8) * 15s plus removal and JVM exit. Raise it above 16
workers; lowering it below the window is the one change that actively causes the
leak. A SIGKILL is now recoverable rather than fatal, since a start reaps
orphans by label.

Module landmines for both, plus the one-grinder-per-daemon assumption the label
carries, and the root table's grinder row and count.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Stopping the unit now signals both halves and gives them one shared 15-second
window before anything is killed, instead of force-removing containers outright
and letting a worker run out a fifteen-minute boot budget.

Containers are children of the docker daemon, not members of the unit's control
group, so systemd never touches them and the shutdown hook is the only thing
that can. Two holes closed alongside: the engine now refuses to create a
container once closing has begun, and every container carries a label so a
start can reap what a SIGKILLed JVM left running -- previously an orphan was
unfindable and survived every restart.

Verified against a live daemon: 6/6 gated cases, including a container trapping
SIGTERM to prove the signal arrives before removal.
One HIGH: GrindPool publishes its worker list only after starting the threads,
so a SIGTERM in that window finds an empty list, interrupts nobody, and reports
a clean stop that did not happen -- the branch's own guarantee, wearing a
success message.

Four MEDIUM (unpinned hook wiring, unpinned 15s window, unenforced coupling
between TimeoutStopSec and the window, an uninjectable constant) and three LOW.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Audit iteration 20, H1/M2/M3/M4.

`tracksEveryWorkerBeforeAnyOfThemCanRun` holds the invariant behind H1: a
running worker is always already tracked, so awaitStop can never signal a subset
and call it a clean stop. **Neither it nor its sibling was observed red against
the unfixed code, and the test says so** -- the interleaving could not be
provoked at 8 workers or at 64, because the first `Grinder.grind` initialises
log4j and that reliably delays worker 1 past the thread-construction loop. The
window is real and opens on every pass; entering it needs a SIGTERM inside it,
which is what makes it rare rather than harmless.

ShutdownWiringTest covers the joins nothing could execute, the same technique
ReportBindWiringTest and FallbackListWiringTest use: that the hook closes the
engine BEFORE waiting on workers (reversing them re-opens the
create-behind-the-sweep hole), that startup reaps orphans, that SHUTDOWN_GRACE
is the 15s the unit and README promise an operator, and that TimeoutStopSec
outlasts a 16-worker cleanup.

Teeth verified for all three source-reading guards by breaking each in turn:
TimeoutStopSec=20 fails on "does not outlast a 30s cleanup", removing the reap
call fails on "no longer reaps orphaned containers", and moving engine.close()
after awaitStop fails on "must be closed BEFORE waiting on workers".

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
H1. `grindAll` started its threads inside the `map` and assigned the field the
shutdown path reads only afterwards, so a SIGTERM landing in that window found
an empty list: awaitStop interrupted nobody, joined nothing, and returned true,
because an empty list satisfies "none alive". The hook logged no warning, the
JVM exited, and the workers were still running -- the exact failure this was
written to prevent, wearing a success message. Threads are now constructed,
published, and only then started.

Also from iteration 20:

- M5: the grace window becomes a constructor parameter defaulting to
  SHUTDOWN_GRACE, so its value is pinnable and an IT need not burn the
  production window to assert that a signal was sent. Production is unchanged.
- L3: a container the shutdown sweep already removed no longer logs
  `Could not remove container` at WARN. It is the expected 404, and at WARN it
  was indistinguishable in the journal from a removal that genuinely failed --
  the same class of noise that made the install diagnosis take three rounds.
- L1/L2: the IT's waitForContainer took an engine it never used (it polls the
  daemon by label), and the orphan case left its engine open with a worker still
  polling a removed container.

Re-verified against docker 29.7.2: 6/6, and the signal case now takes 1.1s.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
One HIGH: the one-shot run constructs its GrindPool inline and never stores it
in activePool, so Ctrl-C signals and awaits nothing -- the hook's requestStop
and awaitStop both no-op through a null. Containers still stop, which is why it
looks like it works.

One MEDIUM: the workers get a second full 15s window after the containers may
have spent the first, so the real worst case is 30s while the log line, the
comment and README all promise one shared window.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
P21-H1: the one-shot run builds its GrindPool inline and never stores it in
activePool, the only handle the shutdown hook has, so Ctrl-C on the end-to-end
verification path signals and awaits nothing -- both calls no-op through a null
receiver. It looks like it works because the engine still closes and the boots
collapse with their containers.

P21-M1: the workers are handed a full window *after* the containers may have
spent one, so the real worst case is 30s while the hook's log line, its comment
and README all promise a single 15-second budget -- and the unit's
TimeoutStopSec arithmetic assumes the two overlap.

Worth recording how the first guard was nearly useless: counting
`activePool.set(` occurrences made it pass, because the pass loop also clears
the reference with `activePool.set(null)` and a reset counts as a registration.
It now checks each construction against what follows it. Caught only because
the expected red did not arrive -- which is the whole reason for looking.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
P21-H1: the one-shot pool is registered in activePool like the continuous
path's. It was built inline, so the shutdown hook -- whose only handle is that
reference -- signalled and awaited nothing on Ctrl-C. Containers still stopped,
which is why the gap survived: the visible behaviour was almost right.

P21-M1: the hook now takes a deadline at entry and hands awaitStop what is left
of it, instead of a second full window after close() may have spent the first.
The worst case was 30s while the log line, the comment and README §5 all
promised one shared 15-second budget, and the unit's TimeoutStopSec arithmetic
assumed the two overlap. In practice most of the window survives to the
workers, since stopping the containers is what frees them.

P21-L1: activePool is read once. Read twice, the two calls could in principle
signal one pass's pool and wait on the next one's.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The pre-shutdown test tree against current code: 276 guards, zero failures.

Two MEDIUM. The promised single 15s window is only true up to eight in-flight
containers -- above that the stops batch, so at the deployed 10 workers the
container phase alone is 30s and the workers get nothing. And a container that
burns the whole window leaves awaitStop zero milliseconds, making its warning
guaranteed rather than informative.

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>
P22-M1: MAX_PARALLEL_STOPS 8 -> 64, and it moves next to SHUTDOWN_GRACE where
the relationship is visible. The window is per container, so a cap below the
number in flight turned one window into several -- at the deployed ten workers
the container phase alone was 30s and the workers were left with none of the
shared budget. A `docker stop` is an HTTP call that spends its time waiting and
concurrent boots are memory-bound at roughly twenty, so a cap above any real
worker count costs nothing. The arithmetic in the unit comment, the README and
the wiring guard collapses to one window; the guard now reads the real constant
instead of transcribing it.

P22-M2: the workers get a one-second floor. A container that ignores SIGTERM can
eat the whole window, and handing awaitStop 0ms meant the interrupt it had just
sent could not possibly be observed -- the "did not stop" warning was guaranteed
rather than informative. Worst case becomes 16s against a 60s stop timeout.

Full suite including the Docker ITs: 290, zero failures, no containers left on
the host.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
merge: three audit passes over the grinder's shutdown, and their fixes
Some checks failed
Documentation / Writerside webhelp (push) Successful in 1m2s
Continuous / Build JAR (push) Successful in 12m42s
Qodana / scan (push) Successful in 7m51s
Docker Test / build image (push) Successful in 14m31s
Test / build (push) Successful in 15m36s
Docker Test / build image (pull_request) Successful in 17m19s
Test / build (pull_request) Successful in 14m8s
Continuous / Build Install4J Media (push) Has been cancelled
Continuous / Continuous Pre-Release (push) Has been cancelled
Continuous / Build AppImage (aarch64) (push) Has been cancelled
Qodana / notify (push) Has been cancelled
Documentation / Help image (push) Has been cancelled
Continuous / Build AppImage (x86_64) (push) Has been cancelled
bd31464f98
Each pass found something the previous had not.

20 — the feature's own guarantee rested on a race: worker threads were started
before the list the shutdown path reads was published, so a stop landing in that
window would have interrupted nobody and reported a clean stop. Also pinned the
hook's wiring, the 15s window and the unit's stop timeout, none of which had a
guard.

21 — the one-shot run never registered its pool, so Ctrl-C on the end-to-end
verification path signalled and awaited nothing. And the workers were handed a
second full window after the containers had spent the first, against three
documents promising one shared budget.

22 — that shared window was still not real above eight in-flight containers,
because the stop concurrency was capped there; at the deployed ten workers the
container phase alone was thirty seconds. Cap raised above anything a host can
run, and the arithmetic in three places collapsed to one window.

Equivalence: the pre-shutdown test tree against current code, 276 guards, zero
failures. Full suite with the Docker ITs: 290, zero failures, nothing left on
the host.
Merge branch 'alpha' into develop
All checks were successful
Continuous / Build JAR (push) Successful in 11m41s
Docker Test / build image (pull_request) Successful in 17m1s
Docker Test / build image (push) Successful in 15m40s
Documentation / Writerside webhelp (push) Successful in 1m28s
Test / build (pull_request) Successful in 15m1s
Qodana / scan (push) Successful in 10m0s
Continuous / Build AppImage (x86_64) (push) Successful in 3m28s
Test / build (push) Successful in 13m8s
Continuous / Build AppImage (aarch64) (push) Successful in 3m29s
Qodana / notify (push) Successful in 1m24s
Documentation / Help image (push) Successful in 4m53s
Continuous / Build Install4J Media (push) Successful in 11m43s
Continuous / Continuous Pre-Release (push) Successful in 6m4s
4f7a1429a3
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>
ContainerResources.forCpus(cores) is the conversion an operator-facing
knob needs: cores are the unit they think in, docker wants microseconds
per period, and the two only relate through a period that was never
stated anywhere. cpuPeriod is now a field defaulted to the 100ms the
kernel has always used, and DockerJavaContainerEngine sends it alongside
the quota -- sending only the quota left the real cap dependent on a
daemon default nothing here would have noticed changing.

The two ends are handled at the knob rather than at container creation:
0 cores means an unset quota (docker's own "no limit", matching how 0
reads for SPC_GRINDER_CACHE_TTL_DAYS), and a positive value below the
daemon's 1ms floor is raised to it, because a quota docker refuses fails
every boot instead of throttling it. A negative count throws.

No behaviour change for existing installs: the default quota is still
200_000 against a 100_000 period, i.e. the same ~2 cores, and
ContainerResourcesTest pins that equivalence rather than asserting it in
prose. ContainerResourcesTest green; CpuLimitWiringTest still red --
main does not read the knob yet.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
SPC_GRINDER_CPUS caps every container the daemon starts -- each mod boot
and each loader install -- in cores, like docker's own --cpus. The cap
itself is not new: ContainerResources has carried a 200_000us quota since
the container runtime existed, and every collaborator has accepted one.
`main` simply never passed one, so the value was unreachable from the
outside; this is the same shape of gap SPC_GRINDER_HOST closed for the
report's bind address.

Default 2, i.e. the two cores every existing install already ran on, so
nothing is re-tuned by upgrading. What the grinder can occupy is
therefore workers x cpus (2 x 2 = 4 by default), which is what the README
now states -- together with the part the knob does not cover: the daemon's
own host-side work (mod downloads, pack generation, the headless Chromium
a locked CurseForge file needs) is a normal process and is capped the
normal way. The unit gains a commented CPUQuota= for exactly that, with
the reason it cannot reach the boots: containers are children of the
Docker daemon, not of the service's control group -- the same fact that
makes the shutdown hook the only thing able to stop them.

Verified against a live daemon (Docker 29.7.2), not reasoned about:
DockerJavaContainerEngineIT.theCpuCapReachesTheKernelWithItsPeriod reads
the cap back from inside the container's own cgroup, since docker echoing
a HostConfig only proves the field was transmitted. It uses a *non-default*
50ms period so the guard has teeth, and it does: with .withCpuPeriod
dropped, a requested 1.5 cores arrived as `75000 100000` -- 0.75 cores,
silently halved. That is the failure the assertion exists for.

Suite: 298 tests, 0 failures, 22 skipped (bind-address guards need a
non-loopback IPv4); the gated Docker IT green in full at 7/7.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Context files for the new knob, and the one fact worth landmining: send a
CFS quota with its period or the cap is whatever the daemon's default
period makes it -- measured at 0.75 cores for a requested 1.5.

Grinder row 290 -> 298 tests.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
One HIGH (a positive cap that rounds to a 0us quota returns *uncapped*,
because forCpus uses the computed quota as its own sentinel), four
MEDIUM (a README section inserted mid-section, a startup line that logs
the derived quota instead of the cores the operator set, a guard shipped
in the same commit as the code it pins, and the installer's "three worth
a decision" list still not naming the new knob), four LOW.

Every claim measured rather than recalled, including three the branch's
own docs assert: the daemon's 1ms floor and its verbatim error, quota 0
reading as `max 100000` in the container's cgroup, and -- the one that
changes the fix list -- a 1000-core quota being accepted on a 16-core
host, so an over-large value needs a sentence and not a clamp.

Base equivalence clean: develop's 290 unmodified guards against the
branch's production code, 0 failures, 0 compile errors.

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, H1. forCpus branched on the computed quota, so any
count under 5e-6 cores rounded to 0us and returned the unset quota --
docker's "no limit" -- instead of the floor its own KDoc promised. The
smallest possible cap became no cap, which is the wrong direction for a
knob that exists to contain untrusted mods. The test committed before
this one went from `expected: <1000> but was: <0>` to green.

The branch is now on `cpus == 0.0`, the only input that means uncapped,
with the sentinel named (UNSET_QUOTA) so the two zeroes cannot be
confused again. Non-finite input is rejected up front: both survive
String.toDouble(), and both round into a lie -- infinity into a quota so
large it means uncapped, NaN into 0.

Also from that audit: Math.round -> roundToLong (L1, Java-ism), and the
KDoc no longer implies docker's --cpus validation comes with its
arithmetic (L3). It does not -- measured on a 16-core host, a 1000-core
quota is accepted and the cgroup reports it verbatim -- so an over-large
value is documented as the operator's problem rather than clamped.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Audit iteration 23, M2. The startup line logged the derived quota, which
answers in a unit nobody set -- and at the documented escape hatch
printed `cpuQuota=0/100000`, reading as "zero CPU" for the value that
means uncapped. Two guards: the rendering itself, and that `main`'s
startup line goes through it, which is the same join CpuLimitWiringTest
already covers for the cap reaching the containers.

Red as committed: Unresolved reference 'cpuCapDescription' (x3).

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>
Eight of nine findings fixed; M3 (a gated IT shipped in the same commit
as the code it pins) is accepted rather than rebased away, with the
reasoning in the audit file: the rewrite would invalidate every hash the
report cites, and the guard is a gated IT whose teeth were checked by
removal and recorded in three places. Stated as a judgement, not omitted.

Grinder row 298 -> 303 tests; the skip range becomes 16-23 because the
gated Docker IT grew a case.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The memory cap is the last hardcoded half of the per-container budget,
and the one with the largest blast radius: with javaArgs left empty --
which the grinder never sets -- the server's heap is *derived* from this
number by the JVM's own container awareness, measured at 25% (Temurin 21,
--memory=3g -> MaxHeapSize 805306368, --memory=1g -> 268435456). So the
guards pin the conversion, the 3 GiB default the worker-sizing advice
divides by, the daemon's own 6MB floor, and the same input-decides-
uncapped rule the CPU cap now has.

CpuLimitWiringTest becomes ContainerLimitsWiringTest: it guarded one knob
reaching the containers and now guards both, through the single
forLimits() call, plus both caps appearing on the startup line. Assertions
are the existing ones with the memory equivalents added; only the matcher
that locates the variable changed, which is the reference-only kind of
test edit the conventions carve out.

Red as committed: Unresolved reference 'forLimits' (x7), and the wiring
guards fail on a `main` that reads no memory knob.

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>
The literal pins on `main`'s fallbacks cannot catch a drift *between* the
two places a default lives. A class default moved to 4 GiB with `main`
still falling back to "3" leaves every direct ContainerResources()
construction -- the fallbacks in ContainerCandidateVerifier,
ContainerServerRunner and DockerLoaderInstaller, plus
ReadmeConfigurationTest's own sizing check -- disagreeing with the daemon
that is actually running. So this asserts the identity, not the values.

Teeth checked rather than assumed: with the class default flipped to
4 GiB it fails with `expected: <...memoryBytes=4294967296...> but was:
<...memoryBytes=3221225472...>`, then passes again restored.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
docs: record the memory knob and the heap derivation behind its warning
All checks were successful
Documentation / Writerside webhelp (push) Successful in 1m51s
Continuous / Build JAR (push) Successful in 11m10s
Docker Test / build image (push) Successful in 16m36s
Docker Test / build image (pull_request) Successful in 17m44s
Qodana / scan (push) Successful in 11m14s
Documentation / Help image (push) Successful in 3m18s
Continuous / Build AppImage (x86_64) (push) Successful in 2m23s
Continuous / Build AppImage (aarch64) (push) Successful in 3m7s
Test / build (pull_request) Successful in 15m54s
Test / build (push) Successful in 15m4s
Qodana / notify (push) Successful in 2m49s
Continuous / Build Install4J Media (push) Successful in 13m25s
Continuous / Continuous Pre-Release (push) Successful in 6m33s
54d32afb60
The measured fact worth landmining: the grinder's packs pass no -Xmx, so
the container's memory limit *is* each server's heap (25% of it on
Temurin 21), while also being the worker-sizing divisor. That is why the
knob ships with a don't-touch warning rather than as a throughput lever.

Grinder row 303 -> 310 tests.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Selection and staging were one function, so the only combination that
could ever be staged was the one selection returns -- the newest
bootable file. The crash re-check that follows needs to stage a
combination it picked itself, which is what the split enables.

Behaviour-preserving: prepareBootPack still selects exactly as before
and then delegates, and the release-only + minecraftAcceptable + loader-
availability predicate moves into bootableMinecraft() verbatim. Its
release set is now read once per call rather than once per selection,
which is the same single read prepareBootPack did.

BootVerifierSelectionTest's three guards (the minecraftAcceptable gate,
a real release advancing to download, the pre-release-only rejection)
pass unchanged, with no assertion edited.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Reported live on 2026-08-23: iron-chests -- a mod that is unarguably
server-safe -- came out HIGH on "Forge 48.1.0 / Minecraft 1.20.2 ->
CRASHED (exit 1)" with the note "Declared server/both but the server
crashed". Exactly one build of the mod is ever booted, so "this build
crashes" and "this mod is clientside" are indistinguishable, and the
engine resolves that ambiguity in the one direction that publishes a
wrong entry to the fallback list.

The guards pin the three pure decisions of a re-check that asks another
version of the mod:

- which versions to try -- the newest file of each *other* Minecraft
  version, most recent first, one per version, filtered by the same
  loader-availability gate selection uses;
- when it is worth trying -- only a CRASHED outcome that *contradicts*
  a declared server support, and only within a boot budget, so a true
  positive (metadata and boot agreeing) still costs one boot;
- how the attempts reconcile -- one clean boot clears the crash (a
  clientside mod cannot run server-side in any build), crashes
  everywhere keep it, and anything that learned nothing leaves it
  standing, same conservative direction as the loader-build re-check.

ClientsideVerifierServerSupportTest pins the shared "declares server
support" predicate, including the CurseForge shape that produced the
report: the platform has no sideness field, so the claim comes from
SPC's own jar scan, and a gate reading only the platform would never
arm for any CurseForge mod.

Red as committed: Unresolved reference 'pickRecheckCandidates' (x5),
'shouldRecheckAgainstOtherVersions' (x4), 'reconcileOtherVersionRecheck'
(x5), 'OtherVersionAttempt', 'declaresServerSupport' (x6).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A crash that contradicts the metadata is now re-checked on up to two
other versions of the mod -- the newest file of each of the next two
most-recent Minecraft versions -- and one clean boot there clears it. A
mod that cannot run server-side cannot run server-side in *any* build,
so a version that boots proves the crash belonged to that build.

Before this, one build was booted and one build decided. iron-chests
came out HIGH off "Forge 48.1.0 / Minecraft 1.20.2 -> CRASHED (exit 1)"
with the note "Declared server/both but the server crashed", which is
the engine reading a broken build as a lying mod -- and HIGH is the one
confidence that reaches /as-properties, where a wrong entry silently
strips the mod from every server pack built against the list.

The gate is the contradiction, not the crash: only when the platform's
self-report or SPC's jar scan claims server support, which is the same
predicate that prints that note (ClientsideVerifier.declaresServerSupport,
now shared so the two cannot drift). Where the metadata already leans
clientside the crash confirms it, and re-checking would spend boots to
learn nothing while the crawl falls behind. Budget is a constructor knob
(otherVersionRecheckLimit, default 2, 0 switches it off), deliberately
not an env var -- no new deployment surface for a number nobody has
evidence to tune yet.

Conservative in every other direction, matching the loader-build
re-check: it stops at the first clean boot, crashes elsewhere corroborate
and are named in the detail, and an attempt that learned nothing --
staging failed, timed out -- leaves the crash exactly as it was.

Cost: two extra boots per contradicting crash, and only there. A true
positive still costs one boot, because a client-only mod's metadata and
its crash agree.

Teeth verified, not assumed: with the survivor lookup stubbed to null,
aVersionThatBootsCleanClearsTheCrash and
aSurvivorOutweighsAnotherVersionThatAlsoCrashed fail; with distinctBy
removed, recheckCandidatesAreTheNewestFileOfEachOtherMinecraftVersion
fails.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Every attempt for one candidate stages into <work>/boot/<slug>-<loader>,
which staging wipes, so all of them write the same boot.log -- and the
grinder's reaper keeps exactly that one file and deletes the rest. The
reported verdict is often not the last attempt: the loader-build
re-check keeps the first crash, and the other-version re-check keeps it
across up to two further boots. The log a HIGH is diagnosed from is
therefore a different boot's console, which is worse than no log.

Three guards: the reported outcome's console is what ends up in its log
file, an outcome that never produced one leaves the file alone rather
than emptying it, and an unwritable log is swallowed -- diagnostics may
never cost a verdict, the same rule outcomeFor's own write follows.

Red as committed: Unresolved reference 'restoreDecisiveConsole' (x3),
and BootOutcome has no console to carry.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The verdict a candidate is published on is frequently not the last boot
it ran -- both crash re-checks keep the original crash -- but every
attempt stages into the same wiped directory and therefore writes the
same boot.log, so the file held the last attempt's console. The
grinder's reaper then keeps that one file and deletes the staging around
it, so the crash behind a HIGH was diagnosed from a different boot.

BootOutcome carries the console it produced (outcomeFor already had the
lines in hand), and verify() writes the decided outcome's console back
after the re-checks. Best-effort like the write it repairs: an
unwritable log is a diagnostics problem, never a reason to lose a
verdict.

Pre-existing since the loader-build re-check landed, and the
other-version re-check makes it near-certain rather than occasional --
up to three boots now share the file.

Teeth verified: with the restore's writeText removed,
theReportedOutcomesConsoleIsWhatEndsUpInItsLogFile fails.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The durable facts a later session needs: that the gate is the
*contradiction* rather than the crash and why arming it wider would
spend two boots per true positive, that the predicate is shared with the
report note so the two cannot drift, that CurseForge's missing sideness
field means the claim can only come from the jar scan (so a
platform-only gate would never arm for the mod that prompted this), and
that every attempt for one candidate writes the same boot.log -- with
restoreDecisiveConsole having to stay last in verify().

The grinder's cross-cutting landmine gains the pacing consequence: one
contradicting crash can hold a worker for up to three boot budgets.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The 110 came from counting PASSED lines before the boot-console guards
landed, which is exactly the stale-number failure the conventions warn
about. Re-derived from serverpackcreator-clientside/build/test-results/
test/*.xml: 113 tests, 0 failures, 0 skipped.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Two operator-facing surfaces changed shape without the README saying so:
the report's Detail column now carries the re-check outcome on a crash
(corroborated elsewhere, cleared by a clean boot, or a single-version
sample), and the log gains a line when a crash contradicts the mod's
claimed server support. Both are how an operator tells 'this HIGH was
checked against other versions' from 'this HIGH is one build'.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A mod published for a single Minecraft version has nothing to re-check
against, and the line said so as a count of zero -- while the verdict's
own detail said it in words. Both branches keep the "although the
metadata declares" phrase the README documents as the grep pattern.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The live iron-chests verdicts show the engine already held the disproof
and threw it away. One run, one project, two rows:

  Forge    HIGH ironchest- ... Forge 48.1.0 / MC 1.20.2 -> CRASHED (exit 1)
  NeoForge LOW  ironchest- ... NeoForge 21.11.45 / MC 1.21.11 -> SURVIVED

Exit 137 on the NeoForge row is a clean boot, not a kill:
ContainerServerRunner watches for the ready-line and stops the container
the moment it appears, so a SURVIVED boot always exits 137. The mod had
booted a real server minutes before being published as clientside.

It is decisive because the published artefact is a loader-agnostic
file-name stem matched with startsWith -- so the Forge HIGH publishes
"ironchest-" and strips the NeoForge build that just proved it boots.
Hence the guards key on the *entry* colliding, not merely on another
loader surviving: different stems strip nothing, and a mod really can be
client-only on one loader.

loaderDisprovingTheCrash pins what counts as disproof (only a clean boot,
only a colliding non-blank entry, never itself), supersededByLoader pins
what the verdict then says -- confidence re-derived from the metadata-only
aggregate rather than hardcoded, bootResult and excerpt kept because the
crash is a fact worth diagnosing, and the note *rebuilt* so it no longer
ends in "a strong clientside signal", which would be false.

The paging guards close the hole that made the committed other-version
re-check unreliable for exactly this shape of project: resolve() took the
newest 50 files across all loaders, so a mod that migrated Forge ->
NeoForge keeps publishing NeoForge builds until its older Forge builds
fall out of the window, leaving the re-check nothing of that loader to
boot. A project inside one page still costs one call, a totalCount that
never arrives stops at a stated cap, and a dependency stays deliberately
single-page.

Red as committed: Unresolved reference 'loaderDisprovingTheCrash' (x7),
'supersededByLoader' (x3), 'MAX_FILE_PAGES' (x2).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
report() gains a second pass over the per-loader verdicts: where one
loader CRASHED and another SURVIVED deriving the *same* list-entry, the
crash stops counting as clientside evidence. Confidence drops to what
aggregate() yields for the same signals with no boot -- one ladder, not
a second one -- while bootResult and the crash excerpt stay, because the
server did crash and that is worth diagnosing. The note is rebuilt
rather than appended to: its old text ended in "a strong clientside
signal", which is the part that is no longer true.

iron-chests produced both rows in one run and the disproof was
discarded. It is decisive because the published artefact is a
loader-agnostic file-name stem matched with startsWith, so the Forge
HIGH publishes "ironchest-" and strips the NeoForge build that had just
booted a server to its ready-line. That is also why the guard keys on
the entry colliding rather than on any survival anywhere: different
stems strip nothing, and sideness can genuinely differ per loader.

CurseForge file resolution now pages instead of taking the newest 50.
Without it the other-version re-check is unreliable for exactly the
projects that produce this false positive: a mod that migrated Forge ->
NeoForge keeps publishing NeoForge builds until its older Forge builds
fall out of the single-page window, leaving that loader nothing to
re-check against. A project inside one page still costs one call
(totalCount says when to stop), the walk is capped at MAX_FILE_PAGES so
an unreachable totalCount cannot spin through the key's quota, and a
truncated read is logged rather than passed off as the whole history.
Dependencies stay deliberately single-page -- they need *a* usable file,
not a history, and paging each one would multiply a sweep's API calls.

The two guards layer: the within-loader re-check runs during the crash's
own boot, cross-loader reconciliation after every loader is in, so a
crash has to survive both.

Teeth verified: relaxing the entry-collision condition fails
aLoaderBootingUnderADifferentEntryDisprovesNothing; capping the file
walk at one page fails resolvePagesThroughEveryPublishedFile and
aTotalCountThatIsNeverReachedStopsAtTheCap.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The durable facts: that the entry colliding is what makes two loaders'
verdicts contradict each other (the published stem is loader-agnostic),
that a SURVIVED row exiting 137 is normal rather than a kill to chase,
that the crash is preserved while only its standing changes, and that
the two crash guards layer with the within-loader one paying first.

Also the finding that made the earlier fix unreliable on its own: the
single-page CurseForge file window hides an older loader's builds
precisely for the migrated projects that produce the false positive.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The report's Detail column can now say a crash was set aside because
another loader booted a server under the same list-entry, and the log
says so too. Also the new truncation warning, so an operator who sees it
knows a project's oldest builds were not read rather than that the
project has none.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
merge: stop one crashing build from being read as a clientside mod
All checks were successful
Continuous / Build JAR (push) Successful in 11m6s
Documentation / Writerside webhelp (push) Successful in 1m17s
Docker Test / build image (push) Successful in 16m31s
Test / build (pull_request) Successful in 16m10s
Qodana / scan (push) Successful in 14m0s
Test / build (push) Successful in 15m27s
Continuous / Build AppImage (x86_64) (push) Successful in 3m28s
Continuous / Build AppImage (aarch64) (push) Successful in 4m59s
Qodana / notify (push) Successful in 25s
Documentation / Help image (push) Successful in 8m21s
Continuous / Build Install4J Media (push) Successful in 12m52s
Continuous / Continuous Pre-Release (push) Successful in 6m30s
Docker Test / build image (pull_request) Successful in 14m14s
0bf11c962e
iron-chests was published HIGH -- and therefore into /as-properties, and
therefore out of every server pack built against that list -- off a
single boot: "Forge 48.1.0 / Minecraft 1.20.2 -> CRASHED (exit 1)". It
is not a clientside mod. Three things were wrong, each found by the one
before it.

One build decided a project. "This build crashes" and "this mod cannot
run on a server" produced identical evidence, and the engine broke the
tie toward the answer that silently strips a mod for everybody. A crash
that *contradicts* the metadata is now re-checked on up to two other
versions of the mod -- the newest file of each of the next two
most-recent Minecraft versions -- and one clean boot there clears it. The
gate is the contradiction, not the crash: where the metadata already
leans clientside the crash confirms it, so a true positive still costs
one boot.

The disproof was already in hand. The live verdicts showed both loaders
of the same project in one run: Forge CRASHED, NeoForge 21.11.45 /
1.21.11 SURVIVED, both deriving "ironchest-". The engine had booted a
real server with this mod, watched it reach its ready-line, and
published it as clientside anyway. It matters because the published
artefact is a loader-agnostic file-name stem matched with startsWith, so
the Forge crash strips the NeoForge build that had just proven itself --
which is why the new cross-loader pass keys on the *entry* colliding
rather than on any survival anywhere. Sideness can genuinely differ per
loader; a shared stem is what makes two verdicts contradict each other.

And the first fix would probably not have saved this mod. CurseForge
resolution took the newest 50 files across all loaders, so a project
that migrated Forge -> NeoForge keeps publishing NeoForge builds until
its older Forge builds fall out of the window -- hiding the evidence
from exactly the projects that produce the false positive. Resolution
now pages to totalCount, capped at 10 pages with a warning when it
truncates; dependencies stay single-page on purpose.

Two smaller fixes ride along. Every attempt for one candidate writes the
same boot.log, and the reported verdict is usually not the last boot, so
the console a HIGH was diagnosed from was a different boot's -- the
reaper keeps that one file and deletes the rest. And a crash never
erases its own evidence now: bootResult and the excerpt survive being
superseded, because the server did crash and that is worth diagnosing.

Suite 93 -> 126, zero failures; grinder and app suites green. Every new
guard had its teeth checked by sabotage rather than assumed: stubbing
the survivor lookup, dropping distinctBy, relaxing the entry collision,
capping the file walk at one page and removing the log restore each fail
exactly the guards that claim to cover them.

Not fixed, and still open: a distribution-locked CurseForge file is
never scanned, so it declares nothing, the other-version re-check does
not arm, and a crash there can still reach HIGH off one boot unless
another loader happens to survive under the same stem.
Merge pull request 'Grinding muh Gears!' (#672) from develop into alpha
Some checks failed
Documentation / Writerside webhelp (push) Successful in 1m52s
Documentation / Help image (push) Has been cancelled
Qodana / notify (push) Has been cancelled
Docker Test / build image (push) Has been cancelled
Qodana / scan (push) Has been cancelled
Test / build (push) Has been cancelled
Generate Release / semantic-release (push) Successful in 3m2s
68d9c57c5a
Reviewed-on: #672
RELEASE: 9.0.0-alpha.7
Some checks failed
Generate Release / semantic-release (push) Has been skipped
Build Release / Preparations (push) Successful in 56s
Docker Test / build image (push) Successful in 18m7s
Documentation / Help image (push) Successful in 6m5s
Documentation / Writerside webhelp (push) Successful in 1m58s
Test / build (push) Successful in 16m7s
Qodana / notify (push) Successful in 19s
Qodana / scan (push) Successful in 15m54s
Build Release / Docker images (push) Successful in 15m36s
Build Release / JARs, media and checksums (push) Successful in 34m52s
Build Release / Forgejo release (push) Successful in 3m29s
Build Release / VirusTotal scan (push) Successful in 3m8s
Build Release / Publish Maven (push) Successful in 8m16s
Build Release / Mirror release outward (push) Failing after 1m15s
50fd50f378
Red on purpose. `ModrinthPlatform.filesOf` maps every entry of a version's
`files[]`, and a Modrinth version commonly carries more than one: authors attach
source jars, flagged `"primary": false`.

Measured against the live API on 2026-08-23, `creativecore` publishes 300
versions; its Fabric group holds 143 files, of which one is the stray
`CreativeCore-sources.jar` (fabric, 1.21.1, non-primary, 2024-09-04). That one
name shares no delimited prefix with the `CreativeCore_FABRIC_v*.jar` builds, so
`FilenameStemDeriver` falls back to stripping the version off the *shortest*
name and derives `CreativeCore-sources` — an entry that matches no published
file. Deriving from the primaries alone yields `CreativeCore_FABRIC_`.

Three of the 300 versions flag no primary at all, so the filter has to be
per-version: primaries when the version has any, every file when it has none.
The second pin covers that, and the existing canned JSON — which carries no
`primary` field — pins the same fallback.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`filesOf` mapped every entry of a version's `files[]` onto a `ModFile`, so an
attached source jar became a candidate for everything the engine does with a
file: derive the published list-entry from it, pick it as the jar-scan sample,
and — order permitting — boot it.

`modFilesOf` keeps only the entries Modrinth flags `"primary": true`, falling
back to every file of a version that flags none. The fallback is load-bearing,
not padding: 3 of `creativecore`'s 300 versions genuinely carry no primary flag,
and dropping them would lose real builds.

What it cost, measured against the live API on 2026-08-23: `creativecore`'s
Fabric group holds 143 files, one of them the stray `CreativeCore-sources.jar`.
That name shares no delimited prefix with the `CreativeCore_FABRIC_v*.jar`
builds, so `FilenameStemDeriver` fell back to stripping the version off the
shortest name and published `CreativeCore-sources` — an entry matching no file
the project has ever shipped. It also cost the mod its cross-loader disproof:
`loaderDisprovingTheCrash` compares entries, and `CreativeCore-sources` matches
neither of the other loaders' `CreativeCore_`, so a Fabric crash stood as HIGH
while NeoForge had booted a server in the same run.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Red on purpose — it does not compile, because the sample the crash re-check
boots has to name a loader per candidate and gate on the (loader, Minecraft)
pair rather than on the Minecraft version alone.

What the pins ask for, and why. Reported 2026-08-23 on `creativecore`: the
Fabric crash on Minecraft 26.2 spent both its re-checks on Fabric 26.1.2 and
Fabric 26.1 — the same loader, the same loader version (0.19.3) and the two
Minecraft versions adjacent to the crashing one. Near-identical code in a
near-identical environment; both came back INCONCLUSIVE, so the crash stood as
HIGH, while NeoForge had booted a server for the same project in the same run.

So each pick now has to introduce a Minecraft version-line and a loader that no
earlier pick used, newest Minecraft first: the booted line is skipped before its
neighbours are considered, and the budget is not spent twice on one loader. The
budget itself is unchanged.

`aSingleMinecraftLineStillSpendsTheWholeBudget` pins the other direction —
diversity is a preference, not a filter, so a project publishing one loader and
one Minecraft line samples exactly as deeply as it did before.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`pickRecheckCandidates` took the newest file of each *other Minecraft version*
of the crashing loader. With a budget of two that is the two versions either
side of the crashing one, on the same loader, at the same loader version — near
identical code, re-tested in a near-identical environment.

Reported 2026-08-23 on `creativecore`, a mod whose own description advertises
server features. Fabric / Minecraft 26.2 crashed; both re-checks went to Fabric
26.1.2 and Fabric 26.1, both under Fabric 0.19.3, and both came back
INCONCLUSIVE, so the crash was published HIGH. In the *same* run, NeoForge
26.1.2.97 booted a server cleanly for the same project, and the CurseForge run
minutes earlier booted the very file the 26.1 re-check gave up on.

Each pick now introduces a Minecraft version-line and a loader that no earlier
pick used, considered newest-Minecraft-first, with the crashing combination's
own line marked used from the start. A line is two components (`26.1.2` and
`26.1` are one, `26.2` another), because that is the granularity at which mod
source actually differs. On the shape above the same two boots become Fabric
26.1.2 and NeoForge 1.21.11 — measured by running the real `ModrinthPlatform`
and `pickRecheckCandidates` over the project's live 300-version response, not
read off the miniature in the unit test.

Diversity is a preference, not a filter: it relaxes to a new line, then a new
loader, then anything left, so a project publishing one loader and one Minecraft
line samples exactly as deeply as before. The budget is unchanged.

Crossing the loader is a wider claim than `loaderDisprovingTheCrash` allows, and
it is gated to match: this sample is spent only where the crash already
contradicts a declared server support, i.e. where one of the two signals is
known to be wrong. Every attempt's label now names its loader, because the
returned outcome can be a boot run under a different loader than the verdict is
about.

Attempts still stage into the *crashing* loader's directory — all attempts for
one candidate share one `boot.log`, which `restoreDecisiveConsole` repairs, and
staging under the candidate's own loader would wipe the pack and console that
loader's own verdict is built from.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Two defects that had to line up to publish `creativecore` HIGH: a Modrinth
source jar becoming the derived list-entry (which also disabled the cross-loader
disproof installed hours earlier, since that guard compares entries), and a
crash re-check whose whole budget went to the two Minecraft versions either side
of the crashing one on the same loader.

Both get a landmine in the module file, because both look like settled ground:
the platform layer reads like a straight JSON mapping, and the re-check reads
like it already samples other versions.

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>
The loose end from the `creativecore` report: its Minecraft 26.2 boots did not
merely disagree, they disagreed about the same build, 71 seconds apart, with one
of them exiting 127. Landmined in both module files because the reaper already
carried a note reasoning about *different* slugs sharing a prefix — the case
where two candidates share a slug outright was the hole, and it is exactly what
the platform column exists to name.

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>
Staging directories are named from the *resolved report's* `ProjectFiles.platform`
and `slug`; the reaper was handed the queued candidate's copy of both. `Grinder`
already logs "Platform mismatch for …: candidate says 'X', resolved report says
'Y'" when a source labels a project differently from the platform that resolves
it, and a slug is a mutable display name a rename can move out from under a
queued candidate — so the codebase knows the two can disagree.

On disagreement the reap matched no directory at all and left a full server pack
per attempt behind, which is the disk-growth class `BootWorkspaceReaper` exists
for: 98 GB across 1750 attempt directories, ~23 GB/h, measured 2026-07-30.

`reapTarget` prefers the report and falls back to the candidate when the
verification threw — precisely the case where staging is most likely to be left
behind, and the case where no report exists to ask. It is a pure decision so it
can be pinned without an ApiWrapper, a loader cache or a container engine, the
same split `BootVerifier`'s own decisions follow.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
All three describe what the code does and stopped being true when the crash
re-check began spanning loaders.

L1 — `BootVerifier` logged "re-checking N other version(s)" while the sample now
also spans loaders, and the message named none of them. It now says
"combination(s)" and lists each `<loader> / Minecraft <version>`, which is the
line an operator reads when a verdict has to be explained after the fact. Its
empty-sample sibling gets the same word.

L2 — `BootVerifierCrashRecheckTest`'s synthetic labels kept the pre-change
`"ironchest-1.20.1.jar (Minecraft 1.20.1)"` form. They are opaque strings to the
pure functions under test and every assertion is byte-identical, but the file is
where a reader goes to learn what a label looks like, so it should show the real
one.

M2 — `serverpackcreator-clientside/CLAUDE.md` claimed `MetadataScannerTest` is
the only test needing a resource. Measured: three already did on `develop`
(`MetadataScannerTest`, `LoaderVersionResolverTest`, `BootVerifierSelectionTest`)
and this branch adds a fourth. The replacement names all four and states the
`grep` that re-derives them, rather than leaving another number to trust —
"cite names, not snapshots" applies to the context files themselves.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Red on purpose — `CrashLogStore` does not exist, `ReportServer` takes no crash
logs, and `VerdictReportRenderer.toHtml` takes only a verdict list.

**Why a crash console needs a home outside staging.** `BootWorkspaceReaper`
keeps one `boot.log` per attempt directory, but staging *wipes and re-creates*
that directory, so the next re-grind of the same `(platform, slug, loader)`
destroys the console for the verdict that is still published. A crash is the
only outcome that reaches HIGH, and its usual cause — a server reaching for a
client-only class — is legible from nothing else. The pins therefore cover the
round trip a report link makes, that the same slug on two platforms is two logs,
and that a re-grind *replaces* rather than accumulates, so the store is bounded
by distinct crashing tuples instead of by uptime.

`aNameThatEscapesTheStoreReadsNothing` and the server's traversal case are the
security half: the store is addressed **by name off a query string**, so a name
is untrusted input. The report binds loopback by default but is documented as
something an operator may reverse-proxy, so a traversal must resolve to nothing
rather than to any file the daemon's user can open.

`anOversizedConsoleIsKeptFromItsTailAndSaysSo` — a mod can spew megabytes before
it dies; the crash is at the end, and a silently shortened log is one nobody can
trust.

The renderer asks a per-row lookup rather than reading a field off the verdict,
so a link is offered only where a file is actually kept and the table can never
point at a 404.

The endpoint buttons close a smaller gap: `/export.csv`, `/status` and
`/as-properties` were reachable only from a line printed at startup, and the
overview is the only page an operator ever opens.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Red on purpose — `keepCrashConsoles` does not exist. Completes the previous
commit's pins with the producer half: the store now has a reader and an index,
but nothing yet puts a console into it.

The attempt directory is wiped and re-created by the next re-grind of the same
`(platform, slug, loader)`, so a crash console survives only until the project
comes round again — while the verdict it justifies stays published. Only the
boot that CRASHED is kept: a clean boot proves nothing about sideness and
explains nothing either.

The second pin is the one that keeps this from ever costing a verdict — a crash
whose console never reached disk keeps nothing and throws nothing. Collecting
evidence after the fact must not fail a grind that already has its answer.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Two operator-facing gaps, one commit because they are the same page.

**Crash consoles now survive the sweep that produced them.** `CrashLogStore`
copies the console of every CRASHED boot out of staging into `<home>/crash-logs`
the moment a candidate's verdicts are in. `BootWorkspaceReaper` already kept one
`boot.log` per attempt directory, but staging wipes and re-creates that directory,
so the next re-grind of the same `(platform, slug, loader)` destroyed the evidence
for a verdict that stays published. A crash is the only outcome reaching HIGH and
its usual cause — a server loading a mod that reaches for a client-only class,
`NoClassDefFoundError: net/minecraft/client/…` — is legible from the console and
from nothing else.

Only CRASHED is kept: a clean boot proves nothing about sideness and explains
nothing either. Logs live under the daemon's home rather than under `work/`,
because everything below `work/` is scratch the reaper may reclaim. Growth is
bounded by the number of distinct crashing tuples, not by uptime, because a log
is named after its tuple and a re-grind replaces it — the opposite of the naming
that once grew the work tree to 98 GB. An oversized console is kept from its tail
with the truncation stated in the file; the stack trace is at the end.

The store is addressed **by name off a query string**, so a name is untrusted:
`read` requires a plain file name resolving directly inside the store, checked on
the string before the filesystem and confirmed canonically afterwards so a
symlink cannot lead out. A refusal is indistinguishable from an absent log, so
probing an unauthenticated report tells a caller nothing.

**Every endpoint is now a button beside "Download CSV"** — `/export.csv`,
`/status`, `/as-properties` and the new `/crash-logs` index were reachable only
from a line printed at startup, and the overview is the only page an operator
ever opens. Each crashing row also links its own console.

The renderer takes a per-row *lookup* rather than reading a field off
`GrindVerdict`: the log lives on disk, so asking at render time means a link
appears exactly when a file is there, and a log removed by hand cannot strand the
table pointing at a 404. It defaults to "no logs", keeping the page renderable —
and openable straight from disk — with no store wired.

Teeth checked: dropping the traversal guard fails
`aNameThatEscapesTheStoreReadsNothing` and
`servesAKeptCrashLogAndRefusesToEscapeItsStore`; dropping the CRASHED filter
fails `aCrashedBootsConsoleIsKeptOutsideStaging`.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The audit report (iteration 24) gains its resolution section, including why H1's
remedy was chosen over the two cheaper ones — a patched result would have kept
each row honest while losing which build actually cleared the crash, and dropping
cross-loader survivors would have undone the fix the branch exists for.

Two new landmines. `bootResult` alone no longer identifies whose boot it was, and
the guard that compares list-entries depends on knowing. And a crashed boot's
console now lives outside staging, addressed by an untrusted name over HTTP —
both of which read as settled ground otherwise.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`creativecore` — a library mod whose own description advertises server features
— was published HIGH clientside under the entry `CreativeCore-sources`. Three
independent defects had to line up, and the third was only visible once the
first two were understood.

1. A Modrinth version's `files[]` is not a list of mods. Source jars are flagged
   `"primary": false`; mapping them all made one stray `CreativeCore-sources.jar`
   the shortest name in the Fabric group, so the derived list-entry matched
   nothing the project ships — and, because `loaderDisprovingTheCrash` compares
   entries, disabled the cross-loader disproof installed hours earlier.

2. The crash re-check never left the crashing combination's neighbourhood: both
   boots went to the same loader, the same loader version and the two adjacent
   Minecraft versions. Each pick now introduces a Minecraft version-line and a
   loader no earlier pick used, on the same budget.

3. Per-attempt scratch space was keyed on `(slug, loader)` while the grinder
   grinds the same slug on both platforms in parallel — so one run deleted the
   server pack out from under the other's container. That is what produced the
   exit-127 boot and NeoForge 26.2 reading SURVIVED on one platform and CRASHED
   on the other for the identical build.

Audited before merge (`claude-docs/REFACTOR-AUDIT.md`, iteration 24). Five
findings, all fixed and re-pinned; the HIGH was one this branch introduced — a
cross-loader survivor was being published as the crashing loader's own boot and
then disproving a third loader's crash. `develop`'s unmodified test tree run
against the branch's code: 415 pre-existing guards, 0 failures, exactly two files
uncompilable, both intended contract changes.

Also lands two operator-facing changes: crashed boots' consoles are copied out of
staging before the next re-grind destroys them, and every endpoint is a button on
the report overview.

api 359 · clientside 136 · app 149 · grinder 325 — 0 failures.
Red on purpose — there is no `RequeueStore`, no `RequeueSelection`, and neither
`Grinder.grind` nor `GrindPool.grindAll` takes a force.

The catalog crawl plus the re-verify TTL answer "when does a project come round
again?" with *eventually, at TTL*, and that is the wrong answer when the defect
is in the engine rather than in the mod. Three landed on 2026-08-23 alone — a
source jar becoming a list-entry, a crash re-check that never left the crashing
combination's neighbourhood, and two platform runs of one slug sharing a staging
directory — and each invalidated verdicts that were already published.

`aForcedGrindReVerifiesEvenAFreshVerdict` is the pin that makes the queue worth
having. A verdict is queued precisely *because* it is wrong, and it is almost
always recent: engine defects get found by reading verdicts that were just
produced. Without the force, a drained queue would turn straight into
`SKIPPED_FRESH` and do nothing, which is the failure mode that looks like it
worked.

Persistence is pinned because the queueing tool and the daemon are different
processes — an operator queues work against a service that is already running —
so an in-memory queue would be empty in the process that matters.

`RequeueSelection.verifiedBefore` expresses the recurring shape once: a defect is
found, and every verdict produced before the fix is suspect. One candidate per
*project* rather than per verdict row, since re-grinding re-verifies every loader
anyway.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The crawl plus the re-verify TTL answer "when does a project come round again?"
with *eventually, at the TTL*. That is right when a mod changes and wrong when
the defect is in this engine — and then the bad verdicts are already published,
so waiting out 30 days means serving a known-wrong clientside entry for a month.
Three such defects landed on 2026-08-23 alone.

`RequeueStore` is a persisted queue drained at the **start of every pass**, ahead
of the catalog slice, and ground with `force = true`. The force is the load-
bearing half: a project is queued precisely because its verdict is wrong, and a
wrong verdict is usually a recent one — engine defects get found by reading
verdicts that were just produced — so an unforced drain would turn straight into
SKIPPED_FRESH and look like it worked.

Two selectors, because two things actually happen:

  --requeue <url>…              a named handful someone disputed
  --requeue-before <instant>    everything verified before a fix landed

The second is the recurring shape and the reason this exists at all: a defect
invalidates a *population*, not a list assembled by hand. One candidate per
project rather than per verdict row, identified by platform + the platform's own
project id where known, so a renamed project is still one re-grind and the same
slug on two platforms is still two.

**Queue-and-exit, over the CLI rather than HTTP, and deliberately before the SPC
claims.** The report server is unauthenticated by design, so a write endpoint
there would let anyone who can reach the page schedule unbounded container work.
And the command runs against a daemon that is already up, so it must not claim
the preferences node or re-pin SPC's home — which is also why it prints to stdout
and never to `log`: the first log statement in a process constructs the very
`ApiProperties` those claims exist to control.
`theRequeuePathRunsBeforeTheClaimsAndNeverLogs` guards both halves, because
`main`'s own body cannot see a log call made from inside the helper.

Verified against the real entry point and a store sliced from live data (875
verdicts): 14 rows → 7 distinct projects queued with both platforms of `chipped`
and `ambientsounds` kept apart; re-running added 0 of 7; a named URL added 1; a
bad instant printed guidance rather than a stack trace; and the run left only
`requeue.json` behind — no log directory, confirming no `ApiProperties` was built.

`/status` reports `requeued` so a backlog is visible rather than inferred, and a
queued grind logs `(re-grind requested)` so the log distinguishes "the crawl
reached this" from "somebody decided the old verdict was wrong".

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The force on the drain, the CLI-not-HTTP choice, and the runs-before-the-claims /
never-logs pair each look like style until you know what they cost. The last is
the existing preferences landmine one level removed — a log call inside the
helper is invisible to a guard that scans main's body.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Three defects fixed on 2026-08-23 each invalidated verdicts that were already
being published, and none of them had a remedy: the catalog crawl plus the 30-day
re-verify TTL bring a project round *eventually*, which means serving a
known-wrong clientside entry until then.

`RequeueStore` is the lane that jumps both. Persisted, drained at the start of
every pass ahead of the catalog slice, and ground forced — the force being the
half that matters, since a verdict is queued because it is wrong and a wrong
verdict is usually a recent one. `--requeue <url>…` for a named handful,
`--requeue-before <instant>` for the recurring shape: a defect invalidates a
population, not a hand-assembled list.

Queue-and-exit over the CLI rather than HTTP (the report is unauthenticated by
design), running before SPC's home/preferences claims and printing to stdout
rather than logging, because the command is run against a daemon that already
owns those. Verified against the real entry point over a store sliced from the
live 875-verdict file.

grinder 325 → 336, 0 failures.
Three findings of substance. H1 is this session's: the drain added a second
GrindPool per pass while the shutdown hook holds one handle, so a stop landing in
a drain is signalled, awaited, reported clean — and then the catalog pass starts
anyway, creating containers after the hook has finished.

M3 is pre-existing and was found by reading the production log rather than the
code: 'Pass #$pass complete' interpolates a shadowed GrindPass, so the line an
operator greps for progress is a multi-kilobyte dump of every candidate.
Surfaced rather than deferred, per the conventions.

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>
Audit iteration 25, findings H1, M1, M2 and L1.

**H1** — the re-grind drain gave the pass loop a second `GrindPool`, and the
shutdown hook holds one handle which it reads once (deliberately: reading twice
could signal one pool and wait on another). A stop landing in a drain was
therefore signalled, awaited and reported complete, after which `main` fell
through and built the catalog pool — starting boots and creating containers
*behind* a hook that had already finished, with systemd's TimeoutStopSec counting
down. Containers live in the docker daemon's cgroup rather than the unit's, so
the hook is the only thing that can ever stop them. A `running` re-check between
the two pools closes it, landmined in place.

**M1** — `/status` is announced before either pool now, and counts both. It used
to be announced after the drain with only the catalog slice's size, so for the
whole of a 300-candidate drain the endpoint that answers "what is it doing right
now?" showed the *previous* pass's number and size while `active` showed workers
grinding candidates belonging to neither.

**M2** — the inter-pass wait is served in 15-second slices and ends early when
work is queued. It was one uninterruptible sleep, and `pauseAfterPass` returns
`betweenSweeps` (default 21 600 s) after a completed sweep that verified nothing,
so queueing a re-grind into a just-dozed daemon bought a six-hour wait. The whole
point of the queue is not serving a known-wrong verdict while a timer runs down;
trading a 30-day TTL for a 6-hour one was better and still not what was built.
`GrindPacing.pollInterval` keeps the decision pure, so no test has to sleep.

**L1** — `pending()` is `@Synchronized` like its neighbours. It only reads and
degrades to empty, so the exposure was a stale count rather than corruption, but
an annotation that is on two of three methods reads as decorative.

The two source guards were re-targeted before they passed, and the reason is
worth stating: they originally keyed on `crawler.nextBatch()` and on
`requeue.drain()`, and the fix moved both. The *intent* — no new pool after a
signalled stop, and a pass size that includes the drain — is unchanged; the
landmarks were badly chosen. They now key on the two `GrindPool` constructions
and on `beginPass`'s argument, which are the things that carry the meaning.
Teeth re-checked after re-targeting: removing either fix fails its guard.

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>
`val pass = pool.grindAll(batch.candidates)` shadowed the `var pass` counter, so
`"Pass #$pass complete: …"` interpolated the `GrindPass` data class — a
multi-kilobyte dump of every reached candidate's URL and popularity, in the one
line an operator greps to see how a pass went. 14 of them in the production log.

Renamed to `catalogPass`, which is also what it is: the *catalog* slice's result,
now that a pass can also grind a re-grind drain. Pre-existing on `origin/develop`
and fixed in its own commit rather than folded into the audit work that found it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
H1 is iteration 25's own fix biting back: the sliced wait computes its remainder
after a file read, so the final slice can go negative, and Thread.sleep throws an
IllegalArgumentException that no catch on that path handles — ending main and
leaving a fire-and-forget daemon quietly doing nothing.

M1 is the crash-log cap bounding what is written rather than what is read, with
runCatching(Throwable) standing ready to swallow the OOM.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Red on purpose — `RequeueSelection.fromLinks` does not exist, and the two other
pins fail against the current code. Audit iteration 26.

H1 — `pollInterval` must never hand back a negative. The caller computes its
remainder *after* the loop condition, 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. A negative Duration compares below
the slice and passes straight through, and `Thread.sleep(-5)` throws
IllegalArgumentException, which is not InterruptedException: it escapes the
wait's catch, escapes `while (running.get())`, and ends `main`. The daemon then
stops grinding with no crash anyone is watching for. Measured:

  PROBE Thread.sleep(-5) -> java.lang.IllegalArgumentException: timeout value is negative
  PROBE Duration.ofMillis(-5) < Duration.ofSeconds(15) = true

M1 — the crash-log cap must bound what is *read*, not only what is written.
`keep` does `console.readText()` before the MAX_BYTES check; 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 be swallowed and the daemon
would carry on in an unknown heap state. Pinned **by measurement** — a 64 MiB
console, with the heap growth across the call required to stay under the file
size — because that is the only formulation that distinguishes a bounded read
from a lucky one; the existing truncation test plants a file just over the cap
and would pass either way.

M2 — a link no platform resolves must be refused by the command that read it.
`ModPlatforms.ofUrl` answers `Unknown` for a typo'd host, and queueing it reports
"Queued 1 of 1" before failing hours later inside the daemon, in a log nobody is
reading.

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>
Both iterations' findings fixed, with the two judgement calls stated rather than
left to look like drift: the iteration-25 guards were re-targeted (intent
unchanged, landmarks moved by the fix), and iteration-26's M2 consolidated the
one-shot path's identical hole instead of patching only the queue.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
merge: two audit iterations over the unpushed work
Some checks failed
Continuous / Build AppImage (x86_64) (push) Has been cancelled
Continuous / Build AppImage (aarch64) (push) Has been cancelled
Continuous / Build Install4J Media (push) Has been cancelled
Continuous / Continuous Pre-Release (push) Has been cancelled
Continuous / Build JAR (push) Has been cancelled
Docker Test / build image (push) Has been cancelled
Documentation / Help image (push) Has been cancelled
Qodana / scan (push) Has been cancelled
Qodana / notify (push) Has been cancelled
Test / build (push) Has been cancelled
Documentation / Writerside webhelp (push) Has been cancelled
5a4b64c41f
Iteration 25 — H1: the re-grind drain gave the pass loop a second GrindPool while
the shutdown hook holds one handle and reads it once, so a stop landing in a
drain was signalled, awaited and reported clean, and then the catalog pass
started anyway — creating containers behind a finished hook with TimeoutStopSec
counting down. Also: /status stale and under-reporting for the whole of a drain;
a queued re-grind waiting out a six-hour inter-sweep pause; and a pre-existing
shadowed pass counter that had been printing an entire GrindPass where the pass
number belongs (14 such lines in the production log).

Iteration 26 — H1: iteration 25's own sliced wait could compute a negative
remainder and die on Thread.sleep's IllegalArgumentException, which no catch on
that path handles, ending main and leaving a fire-and-forget daemon quietly not
grinding. Also: the crash-log cap bounded what was written rather than what was
read, with runCatching(Throwable) ready to swallow the resulting OOM; and
--requeue accepted links no platform resolves, reporting success before failing
hours later in a log nobody reads.

Nine findings, all fixed, each pinned red first. Two judgement calls are recorded
rather than buried: iteration 25's source guards were re-targeted when the fix
moved their landmarks (intent unchanged, teeth re-checked afterwards), and
iteration 26's M2 consolidated the one-shot path's identical hole instead of
patching only the queue.

api 359 · clientside 136 · app 149 · grinder 344 — 0 failures.
Tag repair after migrating from GitLab to Forgejo
All checks were successful
Documentation / Writerside webhelp (push) Successful in 1m53s
Continuous / Build JAR (push) Successful in 8m42s
Qodana / scan (push) Successful in 9m58s
Docker Test / build image (push) Successful in 15m53s
Documentation / Help image (push) Successful in 5m41s
Continuous / Build AppImage (x86_64) (push) Successful in 3m36s
Continuous / Build AppImage (aarch64) (push) Successful in 5m0s
Test / build (push) Successful in 15m24s
Qodana / notify (push) Successful in 29s
Continuous / Build Install4J Media (push) Successful in 13m19s
Continuous / Continuous Pre-Release (push) Successful in 6m9s
53ffe7f749
Signed-off-by: Griefed <griefed@griefed.de>
`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>
`9.0.0-alpha.6` and `9.0.0-alpha.7` both show exactly one red job -- `Mirror release outward`,
attempt 1 -- and they failed for completely unrelated reasons. The run list cannot tell them apart,
which is the whole trap, so the rules file now separates them and says how to.

Run 222 (alpha.6) mirrored all twelve assets to GitHub successfully and only then died on
`exitcode '22'`, curl's `--fail`. The step was `Mirror to GitLab.com`, present at that tag and deleted
two and a half hours later by *fix(ci): drop the gitlab.com release mirror, and stop curl hiding why*.
So alpha.6 needs no repair and that failure cannot recur. Verified against both APIs that its GitHub
release is in fact complete -- 12/12 assets with matching sizes, an identical 41,275-char body
including the VirusTotal section, `target_commitish` `f7ebba4e`, `prerelease: true`. **A red `mirror`
job does not mean an incomplete GitHub release**; check before repairing one.

Run 272 (alpha.7) is the stalled-mirror 422, and is recorded as the second instance of the class the
gitlab.com section already described -- the class being that a mirror step's precondition is the
mirror, not the API call. Distinguish the two by exit code: `22` is the old GitLab step, `1` is the
`::error::` the GitHub steps now emit. Reading the log is the fastest way in and works anonymously on
a public repo, via `GET /api/v1/repos/{owner}/{repo}/actions/jobs/{job_id}/logs` -- noting that the
API's run id is not the number in the run's URL, which is `index_in_repo` (222 -> 252, 272 -> 311), so
`/actions/runs/222` returns 404.

Also records that nothing else in the release should be gated on the mirror, which is why `news` needs
`[prepare, release, maven, docker]` and not `mirror`, and adds `news` to CI-SECRETS.md: `WEBHOOK_URL`
is now read by two workflows and is optional in both.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
merge: the release mirror's precondition, and the Discord announcement
All checks were successful
Documentation / Writerside webhelp (push) Successful in 2m14s
Continuous / Build JAR (push) Successful in 12m43s
Qodana / scan (push) Successful in 14m43s
Docker Test / build image (push) Successful in 18m27s
Continuous / Build AppImage (x86_64) (push) Successful in 2m39s
Continuous / Build AppImage (aarch64) (push) Successful in 2m36s
Documentation / Help image (push) Successful in 6m46s
Qodana / notify (push) Successful in 15s
Test / build (push) Successful in 15m25s
Continuous / Build Install4J Media (push) Successful in 12m56s
Continuous / Continuous Pre-Release (push) Successful in 8m4s
39080000f7
Two release-build.yml concerns and their record. `mirror` now probes that GitHub actually has the
release commit before POSTing the release, and reuses an existing release so re-running that job alone
-- the only safe repair -- works. `news` is recreated from `main`, without the remote-script download
and the deprecated `::set-output` the original carried.
Red 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>
ServerPackCreator's own configuration glyph (`img/config.png`, copied
byte-identical) ships inside the grinder jar and is served from there, so the
page still fetches nothing from outside itself.

Registered under both `/favicon.ico` and `/favicon.png` — the pages link the
latter, browsers ask for the former unprompted — because either one otherwise
falls through to the catch-all `/` context and receives the verdict table with
`Content-Type: text/html` in place of an icon. The `<link>` goes on both HTML
pages; the plain-text crash consoles are covered by the `.ico` route.

`respond` now delegates to a byte-oriented `respondBytes`, which is what the
icon needs and what every text endpoint was already doing via UTF-8.

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>
`Could not find parent layer for module …` is the modloader falling over on its
own module wiring, before FML exists and before any mod is loaded. It exited
non-zero, so it reached the classifier's floor and was scored CRASHED — a
clientside HIGH for a mod whose code never ran (`ars-nouveau`, Forge 48.1.0 on
Minecraft 1.20.2, 2026-08-23).

It is upstream and deterministic rather than a flaky boot: the ServerStarterJar
synthesises a boot layer for the module path in Forge's `unix_args.txt`, and
Forge's `SecureModuleClassLoader` resolves a read module's configuration against
its *direct* parents only, so `java.base` — one level up, in the real boot
configuration — is not found and it throws. cpw's original, which NeoForge runs,
falls back to the platform classloader there; that asymmetry is why the same jar
launches NeoForge and not Forge.

The ServerStarterJar's own pre-launch give-ups join the same guard: with no
run-script it has no launch arguments to read and exits before any loader code
runs.

New rung between launch-failure and killed/OOM. The three guards added in the
commit before this one were red there and are green here.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Red on purpose. `USE_SSJ` defaults to `true` and HELP.md already records the
incompatibility the knob exists for — "people ran into trouble when using Forge
and Minecraft 1.20.2 and 1.20.3". A human reads that and flips it; an unattended
grinder cannot, so every Forge 1.20.2/1.20.3 candidate booted a server that died
in `BootstrapLauncher` before FML and learned nothing.

Pinned on both boots: installing one way and launching the other would cache an
install layer the offline boot cannot use.

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. `GrindVerdict.verifiedAt` has always been recorded — it is what
the re-verify TTL compares against — but neither output ever showed it, so a
reader could not tell a verdict reached minutes ago from one reached weeks ago
on a loader build long since superseded.

`YEAR/MM/DD` in UTC, zero-padded: the same store then reads the same on any
host, and the column sorts correctly under the table's text sort.

The two exact header expectations move with it, which is a new column and not a
changed behaviour of an existing one.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`GrindVerdict.verifiedAt` was recorded from the start and shown nowhere, so the
overview could not distinguish a fresh verdict from one reached weeks ago on a
loader build long since superseded — which matters most for the rows an operator
acts on, since a HIGH is published to the fallback list.

A `Scanned (UTC)` column on the table and a `Scanned` column closing each CSV
row, both through one `ScanDate` so the page and the file it hands out cannot
disagree. `YEAR/MM/DD`, UTC and zero-padded: the same store reads the same on
any host, and the table's text sort orders the column correctly.

The four guards added in the commit before this one were red there.

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>
Closes the gap the Forge argfile switch opened: a cached install layer missing
`unix_args.txt` now fails with the launcher's `Error: could not open …` instead
of the `Unable to access jarfile` this guard already excused, and the boot was
being scored CRASHED for it.

Matched with the launcher's own `Error: ` prefix, so a mod logging "could not
open" about one of its own files still gets judged on its merits.

Red in the commit before this one.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Root CLAUDE.md refactor state (clientside 136 → 138, grinder 344 → 351, counts
read back from the test-result XML), plus the durable facts where the code that
needs them lives:

- container/: the resolvable-hostname requirement and the measurement behind it,
  and a LANDMINE for `/tmp` being `noexec` — JNA cannot load a native library
  there, which is the oshi noise in every crashed console and a latent
  false-positive for any mod needing JNA at load time. Left as a decision, not
  a cleanup: adding `exec` weakens the untrusted-mod posture.
- loader/: why `USE_SSJ=false` is load-bearing for Forge, with the before/after
  boots, and the note to match the message rather than the module.
- report/: the bundled favicon's two routes, and the one shared `ScanDate`.
- clientside/: the loader-bootstrap rung, the eight-rung ladder, and why cpw's
  fallback is the reason the same starter jar launches NeoForge and not Forge.

REFACTOR-LOG carries the blow-by-blow for all five items.

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>
Audit iteration 27, H1. `Error: could not open` sat at rung four, unanchored, so
any line containing the phrase was excused — and because the client-only-class
marker is at rung seven, that excuse cost a true HIGH rather than merely adding
noise. `could not open` is a generic verb phrase a mod may log about its own
resources, unlike the three distinctive sentences beside it.

`^` is exact here: the launcher writes the message as the entire line, and lines
are matched one at a time, so it means "the launcher said it" and nothing else.
The KDoc claimed the `Error: ` prefix already achieved this; it did not, and now
says what the anchor is for instead.

Both directions green: the launcher's own line stays INCONCLUSIVE
(`aJvmThatNeverLaunchedIsInconclusive`), a mod's line no longer suppresses the
crash (`aModLoggingCouldNotOpenDoesNotEscapeAClientOnlyClassCrash`, red in the
commit before this one).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Audit iteration 27, M1. `rowHtml` passed every cell through `esc` except the one
added for the scan date, while that function's KDoc — edited in the same commit
that added the cell — asserts that every cell is escaped.

Behaviour-preserving, provably: `esc` rewrites only `& < > " '`, and a
`yyyy/MM/dd` string from a fixed formatter over an `Instant` contains none of
them, so the rendered byte sequence is identical. Hence `refactor:` and hence no
assertion moved — the existing renderer and report-server guards stay green
untouched. What changes is the invariant: uniform escaping is what makes the next
cell safe to add, and a lone exception is a trap for whoever adds it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Audit iteration 28. `columns` and `rowHtml`'s cell list are two hand-maintained
lists that must stay the same length, and nothing checked it: add a header
without its cell and the table still renders, every column past the gap shows its
neighbour's data, and `sortBy(index)` — wired from the header's position — sorts
the wrong one. No existing guard notices, since each looks for one value
somewhere in the page. This branch incremented both lists, which is when they
drift.

A characterization test, so it passes as written; teeth checked in both
directions by breaking the code:

  header with no cell   expected: <9> but was: <8>
  cell with no header   expected: <8> but was: <9>

Counted off the rendered page, so it pins the consequence rather than the two
source lists.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
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>
Reverts this branch's `USE_SSJ=false`, now that the templates bypass the
ServerStarterJar themselves for exactly the Minecraft versions it cannot launch.

The knob was the right fix while the templates could not tell those versions
apart, but it is blanket: it also disabled the starter jar for 1.17–1.20.1 and
1.20.4+, where it demonstrably works. The grinder would then boot every Forge
pack by a route almost no user's pack takes — and would never again notice the
starter-jar path breaking. It noticed once, which is the whole reason the
templates now decide, so trading that away to keep a redundant workaround is the
wrong direction.

The guard moves with it: `leavesTheStarterJarChoiceToTheTemplates` now pins that
a pack's own `USE_SSJ` survives untouched, and it fails if the line comes back.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The api landmine gets the table and the trap: `securemodules` 2.2.21 still
contains the throw, so reading the source condemns every modern Forge — and
1.21.1 boots through the starter jar anyway, because from Minecraft 1.20.3 the
installer writes a `-jar <shim>` argfile and the module-path route is never
taken. Widening the rule on source evidence alone would have cost every modern
pack the compatibility the starter jar exists for.

`variables.txt` and the root `HELP.md` (the source the api resource copy is
generated from — the copy is gitignored) now tell operators they should not need
`USE_SSJ` at all, rather than naming two Minecraft versions and leaving them to
act on it.

The grinder's loader notes record why its own knob came back out, and the api
row of the root table records that the two shells which cannot be executed
everywhere are now covered by driving the extracted function in a container.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Read back from the test-result XML: 351, not the 350 the section claimed. The
count moved twice in one branch (an alignment guard added, two PackVariables
guards replaced by one), which is exactly how a number quoted in prose goes
stale.

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>
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Five reports off the live grinder, and two of them were bad clientside evidence
rather than cosmetics.

* Container name resolution. `--network none` gives the daemon no address to
  write into `/etc/hosts`, so a boot could not resolve its own hostname and every
  one opened with three `UnknownHostException` stacktraces from log4j's
  `getLocalHost()`. Boot containers now carry a fixed hostname mapped to
  loopback, which is the entry a networked container gets for free.

* A Forge server that never bootstrapped was scored as a mod crash. Two fixes:
  the classifier no longer reads a loader that died in `BootstrapLauncher` as
  CRASHED, and the *templates* now bypass the ServerStarterJar for the Minecraft
  versions it cannot launch — so the pack boots instead of needing an operator to
  find the note in HELP.md.

  The affected range was measured, and the obvious hypothesis was wrong. Forge's
  installer writes either a `-p <module path>` argfile or a `-jar <shim>` one,
  and only the first defeats the starter jar; `securemodules` still *contains*
  the throw at 2.2.21, but from Minecraft 1.20.3 nothing reaches it. So the range
  is 1.20.2/1.20.3, not "everything from 1.20.2 on" — which would have cost every
  modern pack the hosting compatibility the starter jar exists for. Boots: 1.20.1
  and 1.21.1 reach the ready-line *through* it, 1.20.2 dies at
  `SecureModuleClassLoader.<init>` and reaches `Done (5.183s)!` from its argfile.

* A favicon, served out of the jar under both `/favicon.ico` and `/favicon.png`,
  and a `Scanned` date on the overview and in the CSV through one shared
  formatter.

* polytone's verdict was already correct; what its console also showed is JNA
  failing against a `noexec` tmpfs. Landmined rather than fixed — the only remedy
  weakens the untrusted-mod sandbox, which is an owner's decision.

Three audit iterations (27-29) found five further defects in this branch's own
work, all fixed here: an over-broad launcher excuse that could suppress a true
clientside HIGH, an unescaped table cell, uncoupled header/cell lists, an
inverted fail-safe polarity whose doc claimed the opposite, and an unscreened
version comparison — the last of which was partly pre-existing and made the
PowerShell template throw on a snapshot-shaped version.

Every code commit is preceded by its own red `test(...)` commit. Templates are
verified by execution in all three shells, fish and PowerShell in containers,
across eleven Minecraft versions. `develop`'s own guards run green against this
branch's production code — api 359, clientside 136, grinder 344 — with the only
two failures being the CSV header assertions the `Scanned` column deliberately
changed.

Suites: api 356 -> 361, clientside 136 -> 139, grinder 344 -> 351, zero failures.

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>
docs: record the tmpfs exec decision and what noexec was costing
Some checks failed
Test / build (push) Failing after 13m9s
Continuous / Build Install4J Media (push) Successful in 11m50s
Continuous / Continuous Pre-Release (push) Successful in 6m14s
Documentation / Writerside webhelp (push) Successful in 1m44s
Continuous / Build JAR (push) Successful in 13m32s
Docker Test / build image (push) Successful in 17m50s
Qodana / scan (push) Successful in 16m21s
Continuous / Build AppImage (x86_64) (push) Successful in 2m48s
Continuous / Build AppImage (aarch64) (push) Successful in 3m19s
Documentation / Help image (push) Successful in 6m34s
Qodana / notify (push) Successful in 20s
4ffe309f97
The container landmine flips from "do not add exec without deciding" to the
decision itself, with the measurement that changed its justification: noexec was
not merely producing JNA noise in crash reports, it was silently costing every
boot Netty's native epoll transport.

Also records the half that must not drift: nosuid and nodev are still applied,
verified rather than assumed, so nobody "restores" noexec believing it was free
or widens the grant further.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`:serverpackcreator-api:dokkaGeneratePublicationJavadoc` is now clean (8 → 0).
The reported list was seven; `NeoForgeInstance` was the eighth in the same run.

None of them is a signature restatement — where there was nothing to add beyond
the name, the doc says what the *type* is for instead:

- `ForgeTomlScanner.client` — that it is the TOML's own upper-case spelling, and
  that it is a `side=CLIENT` on the *platform* dependency that marks the mod.
- `ScannedMod.toString` / `ModDependency.toString` — that the dependency list is
  expanded rather than left as object identities, which is why it is hand-written.
- `NetworkConfig.Companion` — that the keys are public so a host names the
  constant instead of retyping the property string.
- `WebUtilities.Companion` — that HasteBin's ceilings are private because they
  are that service's limits, not this class's promise.
- `SPCGenericListener` — that it carries no payload, and to reach for a typed
  listener when the details matter.
- `Comparison` — why three values exist: an era boundary wants EQUAL_OR_NEW, an
  update check wants NEW.
- `NeoForgeInstance` — why it is an interface at all: NeoForge's 1.20/1.20.1
  builds live under the legacy `net/neoforged/forge/` group and address their
  installer by both versions, everything later by the bare NeoForge version, and
  `NeoForgeLoader` hides that split behind this type. Verified against
  `OldNeoForgeInstance`/`NewNeoForgeInstance` rather than assumed.

Also fixes the one such warning this session introduced: `ReportServer.Companion`
(grinder 4 → 3), whose member was documented while the companion holding it was
not.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
clientside 20 → 0, grinder 3 → 0. api was already 0.

Two of these were not missing prose at all, which is why they were worth reading
rather than filling in:

- `GrindPool.awaitStop` had a fourteen-line doc explaining why the interrupt is
  load-bearing — orphaned, because `trackedWorkerCount` had been inserted between
  the block and the function it described. Reordered so the doc lands on
  `awaitStop` again; nothing was written.
- `LoaderVerdict` documents its properties in a class-level `@param` block, and
  `@param bootCrashExcerpt` was simply absent from the list. Added, saying the
  part that is easy to get wrong: the excerpt survives a later pass stripping the
  crash of its standing, because the server did crash and that is still worth
  diagnosing.

Three single-line data-class constructors were reshaped to one parameter per line
so each could carry its own doc — `RunResult.Completed`, `RunResult.NotStarted`
and `ClientsideListEditor.Entry`. Names, types, order and defaults unchanged;
this is the enabling reshape the conventions already sanction, not a redesign.
`BootVerifier.Prepared.Ready`/`Failed` were already one per line and just needed
the docs.

The seven `X.Companion` blocks get a sentence on what the block is *for*, not a
restatement of its members: that `BootVerifier`'s holds the pure re-check
predicates precisely because they need no verifier state, that
`CurseForgePlatform`'s quotes CurseForge's limits rather than choosing them, that
`SuspendAwareDeadline`'s lives outside the class so both boot paths measure
against the same number.

Also filled in `JarScan`'s three bare entries while in that file — it had one
documented value out of four, and the useful distinction is that `ERROR` is not a
sideness claim.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
First batch of the app module's dokka backlog: 626 -> 504 -> 442 undocumented.

Grounded rather than generated. The GuiProps icons name the component that
actually uses each one, found by grepping the call sites (folderAddIcon is the
inclusions editor's add-source button, closeIcon is 8x8 because it sits inside a
tab label); the ConfigEditor accessors name the widget each reads or writes,
which is the one thing the ServerPackConfigTab interface doc cannot say.

Where a whole class shares one fact, it went on the class: BeanConfiguration's
beans all re-expose one ApiWrapper, and the note worth leaving is that they call
apiWrapper() rather than taking it as a parameter — which returns the singleton
only because @Configuration is proxied by default, so proxyBeanMethods = false
would silently build a second wrapper per bean.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
EOF
442 → 318 undocumented.

The entity `equals`/`hashCode` overrides were the ones worth reading rather than
filling in, because each deliberately ignores fields and the omissions are the
contract:

- `ModPack.equals` compares project, version, name, source and hash and ignores
  the id, dates, size, counters, status and file — it answers "have we already
  got this upload?", which is what the duplicate-check asks.
- `ServerPack.equals` ignores the counters and the date: a pack downloaded since
  is still the same artefact, and treating it otherwise defeats the dedup.
- `RunConfiguration.equals` is exact and element-wise across all three embedded
  lists, and that exactness is load-bearing — it is how the service decides an
  incoming configuration may be reused, so a looser comparison hands somebody
  else's server pack back.

`ScrollTextField`/`ScrollTextArea` share one fact that belonged on the class:
every member forwards to the *wrapped* text component, not to the scroll pane.
The drop-target overrides get the reason spelled out — Swing delivers a drop to
whatever the mouse is over, which is the scroll pane, so without them dropping a
file on a path field silently does nothing.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
318 -> 226 undocumented. Same standard: where a class shares one fact it goes on
the class, and the fact is one a reader could not get from the signatures.

- TabbedConfigsTab.checkAll carries the warning that matters about it: it is what
  the 500 ms typing debounce fires, across every open tab, so anything added
  there is paid per keystroke-pause times the user's open configs.
- ServerPackCreator.apiWrapper records why it is built before the first log
  statement — ApiProperties is log4j's own ConfigurationFactory, so logging first
  constructs one against an unresolved home.
- RunConfigurationService says the reuse is the point, and VersionMetaResponse
  explains why Fabric is a flat list while Forge is keyed by Minecraft version.
- FileSystemStorageService's single-line constructor was reshaped to one
  parameter per line so both could be documented, which is where the note lives
  that the MessageDigest is injected because it is stateful.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
226 -> 146 undocumented.

Two landmines already recorded in the module's CLAUDE.md now sit on the code
they constrain, where an implementer will actually meet them:

- SettingsEditor's interface doc carries the reload-after-save rule, because
  several settings normalise on write or read, so a panel compared against its
  pre-save widget values reports unsaved changes forever. SettingsHandling.save
  repeats it at the call site that must not drop the reload.
- RunConfigurationRepository's duplicate lookup warns against spelling the query
  with 'In', which derives to "contains any of" rather than "equals" — the
  earlier form matched a configuration sharing a single mod and handed back
  somebody else's server pack.

DocumentChangeListener gets the reason it exists (Swing reports insert, remove
and attribute changes separately; every consumer here wants all three the same),
and ErrorEntry gets the consequence of its message being its own @MongoId: a run
failing identically a hundred times writes one document, not a hundred.

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>
docs(plugin-example): document the addons logger companion
All checks were successful
Documentation / Writerside webhelp (push) Successful in 2m34s
Continuous / Build JAR (push) Successful in 15m6s
Docker Test / build image (push) Successful in 21m1s
Qodana / scan (push) Successful in 20m1s
Continuous / Build AppImage (x86_64) (push) Successful in 2m30s
Documentation / Help image (push) Successful in 6m25s
Continuous / Build AppImage (aarch64) (push) Successful in 3m43s
Qodana / notify (push) Successful in 48s
Test / build (push) Successful in 19m10s
Continuous / Build Install4J Media (push) Successful in 13m18s
Continuous / Continuous Pre-Release (push) Successful in 6m42s
57d22b57cc
The last undocumented declaration in the repository, and it only turned up when
the dokka check was run across *every* module rather than the four reported —
this one is in the plugin example, which is documentation-by-example and so is
exactly where a gap costs most.

The doc says the part a plugin author needs: `AddonsLogger` is not an arbitrary
name. ServerPackCreator configures that appender for plugins, so logging through
it lands in the addons log instead of being mixed into SPC's own.

Repo-wide dokka is now clean.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Three guards, all red against the current store:

  aStoreWrittenByANewerBuildStillLoads       expected 1, got 0
  anUnreadableStoreIsPreservedRatherThanOverwritten  expected 1, got 0
  oneUnreadableRowDoesNotDiscardTheOthers    expected 1, got 0

JsonVerdictStore builds a bare jacksonObjectMapper(), so
FAIL_ON_UNKNOWN_PROPERTIES is on and readValue<List<GrindVerdict>> is
all-or-nothing. A store written by a newer build therefore fails to read,
load() logs "starting empty", and the very next record() calls persist(),
which serialises the whole map over the file. One unknown field costs the
entire store.

aCorruptFileDegradesToEmpty pins "start empty rather than crash" — it does
not pin "and then don't destroy the file", which is why the suite stayed
green while the store was lost.

The forward direction is already safe: Jackson applies Kotlin constructor
defaults for absent properties, which is why the live store's 875 rows
carry no projectId key and load fine.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Turns the three guards from the previous commit green. Three changes, all
protecting the store against being destroyed by its own recovery path:

- FAIL_ON_UNKNOWN_PROPERTIES off, so a store written by a newer build
  survives a downgrade instead of reading as corrupt.
- load() reads row by row rather than as one document, so a verdict this
  build cannot make sense of (a Confidence constant added later, say)
  costs that row instead of every row.
- Whatever could not be read is copied aside as <name>.unreadable-<epoch>
  before returning, because record() persists the whole map immediately
  and would otherwise overwrite the evidence with whatever survived.

A copy rather than a move: the store must still be where the operator
expects it, and the rescued bytes are what a later build or a human needs
to recover the dropped rows.

aCorruptFileDegradesToEmpty is unchanged and still green -- the service
still starts empty rather than crashing. Only the destruction is new.

Grinder suite: 355 tests, 0 failed, 25 skipped.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Red as a compile error, which for a field that does not exist yet is the
only red available:

  GrinderTest.kt:98  Unresolved reference 'declaredClientSide'
  GrinderTest.kt:99  Unresolved reference 'declaredServerSide'
  GrinderTest.kt:100 Unresolved reference 'jarScan'
  GrinderTest.kt:102 Unresolved reference 'bootedLoader'

LoaderVerdict already carries all four and ClientsideReportRenderer
already prints the sidenesses and the jar scan in the CLI's markdown
table -- they simply never reach GrindVerdict, so the grinder's own
report cannot show what the platform declared versus what the jar did.

aVerdictWithoutRecordedSidenessIsNullRatherThanUnknown pins the
distinction that matters: a legacy row has no answer, whereas CurseForge
publishes none for every row (CurseForgePlatform.resolve hardcodes
UNKNOWN). Collapsing both onto UNKNOWN would make the ~870 pre-existing
verdicts indistinguishable from every CurseForge verdict.

loaderVerdict() gains four defaulted parameters; every existing call site
is unchanged.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Turns the previous commit's guards green. GrindVerdict gains four
defaulted fields -- declaredClientSide, declaredServerSide, jarScan and
bootedLoader -- populated at the single site that maps a LoaderVerdict
onto a GrindVerdict, in Grinder's per-loader loop.

The clientside engine already decided all four, and the CLI's
ClientsideReportRenderer already prints the sidenesses and the jar scan
in its markdown table. They simply stopped at LoaderVerdict, so the
grinder's own report could show what the verdict *was* but never what it
was based on.

Sideness and jarScan are nullable rather than defaulted to UNKNOWN:
UNKNOWN is a real answer a platform gives -- CurseForge gives it for
every project, since CurseForgePlatform.resolve hardcodes it and never
queries CurseForge for a sideness field -- while null means the question
was never recorded. A report that renders both as "UNKNOWN" would tell a
reader that ~870 legacy rows had been checked and found not-client-side.

No migration step: absent properties take the Kotlin constructor
defaults, which is the same mechanism projectId already relies on, and
the preceding commit made unknown properties survivable in the other
direction too.

Grinder suite: 357 tests, 0 failed, 25 skipped.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
runPrepared had three call sites -- the first boot, the newest-build
re-check, and each other-version re-check -- each repeating the same four
collaborators. A private boot(pack) now owns that call and the three
sites delegate to it.

Behaviour-preserving, and the evidence is that the whole clientside suite
stays green with no test edited at all: 139 tests, 0 failed, 0 skipped.

The reason to do it before anything else: staging wipes the attempt
directory on every stage, so anything that must happen per attempt has to
be added in exactly one place. Added at two of the three sites it would
silently lose precisely the re-check evidence, which is the evidence a
disputed verdict turns on.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Red: Unresolved reference 'BootArtifacts' -- the unit does not exist yet.

Eight guards over the artifacts a boot produces. Today only the container
console is kept, only for a CRASHED boot, and the server's own logs/ and
crash-reports/ are read nowhere in the codebase: the sole mention of
those directory names is InstallLayerSnapshot excluding them from the
loader-install cache. A mod wrongly cleared, or an error in the checking
itself, therefore leaves nothing to look at.

The guards that carry weight rather than describe shape:

- anOversizedArtifactIsNeverReadWholeIntoMemory measures the allocation
  against a 64 MiB log, mirroring CrashLogStoreTest's existing guard.
  Capping after readText() is how a daemon dies of an OOM it swallowed.
- theArtifactCountIsCappedAndEveryOmissionIsNamedInTheIndex: a cap that
  drops files silently reads as "this is everything".
- aPackWithNoLogsAndNoConsoleYieldsNothing stops the index from being
  emitted to announce its own emptiness.
- onlyANonSurvivedBootIsWorthKeeping puts retention in clientside so the
  CLI verb and the daemon cannot disagree about it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Turns the previous commit's guards green. BootArtifacts.collect reads a
staged pack's logs/ and crash-reports/ alongside the captured console and
returns them as separate entries.

Separate rather than one blob because they disagree in useful ways:
logs/latest.log is log4j's file appender, so it carries entries stdout
never sees and misses the shell and launcher output stdout has. That
difference is what a disputed verdict turns on.

Bounded three ways, each observable rather than silent:
- MAX_BYTES_PER_ARTIFACT keeps the tail via RandomAccessFile.seek, never
  readText-then-trim, with the truncation written into the content.
- MAX_ARTIFACTS keeps the newest 8 by mtime.
- Everything found is named in an index.txt entry, kept or not, with the
  reason -- a capped set of logs is otherwise indistinguishable from a
  complete one.

An unreadable file becomes an artifact saying so rather than vanishing:
failing to read a crash report is itself evidence about the boot.

worthKeeping() puts retention here rather than in the grinder, so the CLI
verb and the daemon cannot disagree about which boots are worth keeping.

Fixed one guard's own expectation while making it green:
anOversizedArtifactIsNeverReadWholeIntoMemory asserted a single artifact
where collect correctly also returns the index. The memory assertion it
exists for is unchanged.

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

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Red: "Too many arguments for runPrepared(...)" and "Unresolved reference
attemptName" -- neither the hook nor the accessor exists yet.

Six guards. The two that are not about the happy path:

- onlyOneCallSiteInvokesRunPrepared asserts the structure the feature
  depends on. runPrepared happens three times per candidate (first boot,
  newest-build re-check, other-version re-check), and a hook added at two
  of three would silently cover the first boot and miss both re-checks --
  a boot that gets re-checked is by definition a contested one. The
  previous commit collapsed the call sites; a later edit can undo that
  with no behavioural test noticing, so the structure is pinned directly.
  Same justification as ReportBindWiringTest and ShutdownWiringTest.
- aThrowingSinkDoesNotFailTheBoot holds the sink to the same rule as the
  live-log sink beside it: a verdict that already ran must not be lost to
  a full disk.

theAttemptNameIsTheStagingDirectoryItBootedFrom pins deriving the tuple
from the log file's parent rather than threading platform/slug/loader
through every attempt -- and it stays correct for the other-version
re-check, which stages into the crashing loader's own directory.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Turns the previous commit's guards green. BootVerifier gains a defaulted
bootArtifactSink, invoked from runPrepared once the outcome is classified,
and Prepared.Ready gains an attemptName accessor deriving the
(platform, slug, loader) tuple from its log file's parent.

Inside runPrepared, not after verify() returns, and that is the whole
point: stageBootPack deleteRecursively()s the attempt directory on every
stage, so the newest-build re-check and each other-version boot destroy
the previous attempt's serverpack/ and boot.log. restoreDecisiveConsole
already repairs the console of the *decided* attempt; everything else --
the server's own logs, its crash reports, and every attempt that was not
the decisive one -- was simply gone. Those are what a contested verdict
turns on, since a crash is the only outcome that reaches HIGH.

attemptName is derived rather than carried as three more fields, and it
stays correct for the other-version re-check, which deliberately stages
into the crashing loader's directory rather than its own.

The invocation is wrapped in runCatching, the same rule the live-log sink
beside it follows: a verdict that already ran must not be lost because a
disk filled up.

Both existing construction sites (VerifyClientsideCommand,
ContainerCandidateVerifier) compile untouched -- the parameter is
trailing and defaulted.

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

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Pure rename, ahead of the store learning to keep more than a crash
console. The untrusted-name machinery is carried verbatim rather than
rewritten -- isInsideStore's string check before the filesystem is
touched, the canonical-parent confirmation against a symlink, the
seek-based tailOf and its truncation notice. That code is scarred and
documented; retyping it is how the path-traversal landmine comes back.

Behaviour-preserving: 357 tests, 0 failed, 25 skipped, with no assertion
edited -- only the class name in BootLogStoreTest and the four reference
sites.

The on-disk directory (<home>/crash-logs) and the two HTTP routes are
deliberately untouched here; both are documented, and one is bookmarked
by anyone who has used the report.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Red: the constructor takes no budget, attemptKey does not exist, and keep
still takes a single console File.

Eleven guards over what the store has to become now that a boot is more
than one console. The ones carrying weight:

- pruningDropsAPreviousGrindsOrphansAndNothingElse is the load-bearing
  bound. Deterministic naming only replaces *this* grind's attempts; a
  re-grind whose re-check samples a different loader or Minecraft line
  writes new names and strands the previous grind's files forever. Without
  pruning, this feature re-introduces exactly the growth class the reaper
  exists for -- the one that reached 98 GB across 1750 directories.
- aSlugContainingTheSeparatorIsStillFoundByItsTuple: slugs and loaders
  both contain '-', so a stored name can never be parsed back apart. The
  lookup has to rebuild the prefix instead.
- everyAttemptOfOneCandidateIsKeptSideBySide: the re-check boots are the
  evidence a contested crash is argued with, so they cannot overwrite each
  other the way boot.log does in staging.
- theBudgetSweepDeletesOldestFirstUntilUnderTheCeiling, paired with
  aStoreUnderItsBudgetIsLeftAlone so the backstop cannot fire constantly.
- aNameThatEscapesTheStoreStillReadsNothing re-asserts the inherited
  traversal guard against the reworked lookup.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Turns the per-attempt store guards green, and completes the capture path
started in -clientside.

BootLogStore now keeps an attempt's whole evidence set rather than one
crashed console: keep() takes BootArtifacts, namesFor() rebuilds the
tuple prefix (a stored name can never be parsed apart -- slugs and
loaders both contain '-', which is why '~' is the separator),
pruneExcept() drops a previous grind's orphans, enforceBudget() is the
ceiling, and adoptLegacy() moves the superseded crash-logs directory in
once, renaming each console into this store's own shape so it is
reachable from its row rather than merely present on the index.

ContainerCandidateVerifier's post-hoc keepCrashConsoles is gone: the sink
fires per attempt, during the boots, because staging wipes the attempt
directory on every stage and nothing readable survives to the end of
verify(). What remains after a candidate is the prune and the budget
sweep.

Two defects found reviewing my own work rather than by a test:

- The record of what a grind wrote was an instance field, but ONE
  ContainerCandidateVerifier serves every GrindPool worker -- so one
  candidate's prune would delete logs another had just written. Same
  cross-candidate class as the unqualified attempt directory that wiped a
  pack mid-boot. Now per-invocation, pinned by
  candidatesPrunedInParallelDoNotDeleteEachOthersLogs.
- namesFor lists the store on every call and the table asked per row, so
  a 875-row page did 875 directory listings. ReportServer now snapshots
  one listing per request and groups it on the owner prefix.

Report: a Logs column replaces Crash log, rendering every kept artifact
behind a <details> disclosure -- collapsed, so a heavily re-checked row
cannot dominate the table, and native HTML so the page still needs no
JavaScript and still opens from disk. /boot-log and /boot-logs are the
routes; /crash-log and /crash-logs stay as aliases, because both are
documented and an operator has them bookmarked.

Knobs SPC_GRINDER_BOOT_LOGS and SPC_GRINDER_BOOT_LOG_BUDGET_MIB (2048)
land in the entry point, README table and systemd unit together, as
ReadmeConfigurationTest and SystemdUnitConfigurationTest require.

Existing tests changed, and why -- all behaviour changes, hence feat:
- ContainerCandidateVerifierReapTest: keepCrashConsoles is gone; its
  retention moved into the per-attempt sink. Rewritten against it, with a
  survived-keeps-nothing case added.
- ReportServerTest.servesAKeptCrashLogAndRefusesToEscapeItsStore: new
  keep() signature and routes; both indexes and the alias now asserted.
- VerdictReportRendererTest.onlyARowWithAKeptCrashLogGetsALink: a row now
  carries many logs, so it asserts the disclosure and the label instead.
- linksEveryEndpointBesideTheDownloadButton: /crash-logs -> /boot-logs.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

Grinder suite: 364 tests (was 351), 0 failed, 25 skipped.
Red: Unresolved reference 'ConsoleRuleFile' / 'ConsoleRuleSet' -- neither
the file loader nor the rung exists yet.

Twenty guards over a rule file that turns a newly-observed clientside
signature into a file edit rather than a release.

The verified gap they close: FML refuses a client-only class with
"Attempted to load class ... for invalid dist DEDICATED_SERVER", and
NeoForge's ServerStarterJar can print that and still exit 0, so the
ladder's exit-code fallback scores it INCONCLUSIVE. That string appears
nowhere in this codebase --
aRuleCrashesAConsoleThatAZeroExitWouldHaveExcused asserts both halves,
the INCONCLUSIVE without a rule and the CRASHED with one.

Placement is the design decision, so it is pinned from both sides:

- ABOVE the exit code and the built-in client-class marker, so a rule can
  raise a crash the exit status excused *and* excuse a console the marker
  would have crashed (a known-broken loader build printing a client
  class).
- BELOW the timeout and killed/OOM guards, so a hand-edited file can
  never manufacture a HIGH out of host trouble. That is not hypothetical:
  a memory-starved Docker VM once turned fat mods into HIGH-confidence
  clientside crashes systematically, and the same principle is already
  pinned for the built-in marker.
- Below a ready-line, which stays decisive: the server demonstrably ran.

The failure modes carry the most weight, because "keep the last good
rules" hides breakage unless it is surfaced:

- anUnknownVerdictDropsTheRuleRatherThanDefaultingToCrashed -- defaulting
  to the one value that reaches HIGH is how a typo publishes a wrong
  fallback-list entry.
- anUncompilableRegexDropsOnlyItsOwnRule, so one typo cannot disable
  every rule beside it.
- aMalformedFileKeepsTheLastGoodRulesAndReportsTheError -- both halves.
- anAbsentFileIsAnEmptyRuleSetAndNotAnError, and anEmptyRuleSetChangesNothing
  over all four ladder outcomes, so the default install behaves exactly as
  it did before rules existed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Turns the previous commit's guards green. A rule file maps console text
to a boot result, so a newly-observed clientside signature is a file edit
rather than a release.

Rung 7 of the ladder, and the placement is the design:

  1 ready-line  2 timed out  3 setup abort  4 launch failure
  5 loader bootstrap  6 killed / OOM
  7 *** the operator's rules ***
  8 client-class marker  9 dependency failure  10 exit code

Below 1-6 because all of those mean the mod never got a fair run, and a
hand-edited file must never be able to manufacture a CRASHED -- hence a
HIGH -- out of host trouble. A memory-starved VM doing exactly that to
the biggest mods, systematically, is on this engine's record. Above 8-10
so a rule can raise FML's "for invalid dist DEDICATED_SERVER" on a zero
exit, which is the verified gap, and can excuse a console the built-in
marker would crash.

classify() keeps its three-argument form, delegating with an empty rule
set, which is why theGuardOrderIsPinnedAsAWhole and ~20 other existing
BootLogClassifierTest cases stayed green with no edit -- the rung is
purely additive. That guard was extended rather than joined by a sibling,
because it pins the order *as a whole* and a second place stating the
order is how the two drift.

A rule with no verdict identifies without deciding: it names itself on
the Classification and lets the ladder settle the result. First match in
file order wins -- the file's order is the only precedence its author can
see.

The loader is deliberately unforgiving in one direction and forgiving in
the other: an unknown verdict string, a missing id, a missing pattern or
an uncompilable regex drops that rule and records why, while a whole-file
parse failure keeps the last good set. Defaulting an unrecognised verdict
to CRASHED would let a typo publish a wrong entry to everyone polling the
fallback list.

Reload is a (lastModified, length) stat on read, not a WatchService:
classify runs once per boot and a boot takes minutes, so the check costs
microseconds far below once a second, while a watcher costs a thread, a
platform-specific backend and tests that need sleeps. The one-second
mtime granularity that makes the pair necessary is landmined in the doc.

Clientside suite: 170 tests (was 152), 0 failed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Red: "Too many arguments for outcomeFor(...)" and "Unresolved reference
firedRule".

Provenance as a *field*, not only prose. "How many verdicts did rule X
decide?" is the only way to find a rule firing too broadly, and a
sentence inside a human-readable detail string cannot answer it -- an
unauditable heuristic deciding what reaches everyone polling the fallback
list is the recurring defect class here.

anOutcomeNoRuleTouchedIsUnchanged compares the detail of a boot run
without rules against one run with rules that do not match, so the
default install's output is pinned byte-for-byte rather than assumed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Turns the previous commit's guards green. BootOutcome gains firedRule,
outcomeFor takes the rule set and threads the match through, and
BootVerifier gains a consoleRules supplier.

A supplier rather than a captured value, asked once per attempt: that is
what makes an edit during a multi-day run take effect on the next boot
instead of the next restart, which is the whole point of a hot-reloadable
file.

The rule appears twice on purpose. In the detail, as
"... [rule 'fml-invalid-dist': FML refused a client-only class]", for
whoever reads the report; and as a field, for whoever has to count how
often a rule fired before judging it too broad. Prose cannot answer the
second question, and an unauditable heuristic deciding what gets
published to everyone polling the fallback list is the recurring defect
class here.

Parameter ordering is load-bearing, and I got it wrong first: `rules` was
appended AFTER the bootArtifactSink function parameter, which silently
re-bound every trailing-lambda call site to it --
BootVerifierArtifactSinkTest stopped compiling with "actual type is
(...) -> ..., but 'ConsoleRuleSet' was expected". The sink stays last so
callers can pass it as a trailing lambda, and the doc says so.

Clientside suite: 173 tests (was 170), 0 failed.

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>
Turns the previous commit's guards green.

Both exclusion sets carried the doc comment "ids that are the platform
rather than a mod" and both broke it. fabricloader, quilt_loader and
quilt_base are the platform; `fabric` is Fabric API and
`quilted_fabric_api` is Quilt's port of it -- mods, and the ones a server
most often genuinely needs. An embedder reading ScannedMod.dependencies
was told a Fabric mod depended on nothing but its non-API dependencies.

ModDependency gains versionConstraint, kept verbatim and unparsed:
Fabric/Quilt write npm-style ranges (>=0.92.0) and Forge/NeoForge write
Maven ranges ([15.2,)), so normalising here would lose the information a
consumer needs to tell them apart. @JvmOverloads preserves the old
(String)V and (String, Sideness)V JVM constructor descriptors -- pf4j
loads COMPILED plugin jars, so binary compatibility is the contract that
actually binds, not merely source compatibility.

Downstream consequence, checked rather than assumed: ModListCompiler runs
a dependency-rescue loop, so a disabled Fabric API / QFAPI jar is now
pulled back into a pack when a kept mod depends on it. That is correct --
you cannot strip Fabric API from a Fabric server -- and its blast radius
on a stock install is nil, because neither id appears in the shipped
fallback clientside list, so nothing is disabled to be rescued. The fix
becomes protective the moment a custom or polled list contains such an
entry, including the list the grinder itself publishes.

Recorded in claude-docs/API-BEHAVIOUR-CHANGES.md.

API suite: 365 tests, 0 failed, 1 skipped -- including generation, which
is what the rescue loop feeds.

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, and repairs the reported crash.

ConfigurationHandler now calls nekodetectorFindings(path), which runs the
scan inside a try and returns nothing when it cannot run. Two deliberate
choices, both unusual enough to be documented at the call site:

- It catches Throwable. Normally wrong, correct here: the reported
  failure was NoClassDefFoundError, an Error, which the scanner's own
  `catch (ex: Exception)` could never have caught.
- The guard sits at the CALL SITE, not inside SecurityScans. The class
  could not be loaded at all, so no statement inside that method ever
  ran. Only a guard around the invocation can contain that -- which is
  also why the scan is a parameter: it keeps the SecurityScans reference
  inside a lambda body evaluated within the try, and lets a test inject a
  scanner that cannot load.

A failure is logged loudly and NOT added to the config errors: an
unavailable scanner is a defect in this installation, not in the user's
modpack, so it must not block their build. The log line says as much, so
whoever reads it is not sent hunting through their mods.

SecurityScans.scanUsingNekodetector widens its own catch to Throwable for
the same reason -- it is a third-party jitpack artifact, so a missing
transitive class arrives as a LinkageError.

NOT reproduced locally, and worth saying: every local artifact checked
contains the class -- serverpackcreator-api-dev.jar, the compiled classes
output, and the nested api jar inside the app fat jar, whose
nekodetector-Version-1.1-pre.jar also carries me/cortex/jarscanner/Main.
So the crashing run used some other classpath. The fix is correct
regardless: an optional scanner must never be able to take generation
down, however it comes to be missing.

API suite: 368 tests, 0 failed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Red: Unresolved reference 'VersionConstraint'.

Two grammars, because the loaders disagree and B1 deliberately carried
each descriptor's constraint verbatim so they could be told apart:
Fabric/Quilt write npm-style ranges (>=0.92.0, ^1.2.0, ~1.2.0) and
Forge/NeoForge write Maven ranges ([47,), (,3.0]).

The load-bearing guard is anythingUnreadableAccepts. A constraint this
parser cannot read must ACCEPT, never refuse: refusing would make a gap
in grammar coverage indistinguishable from every mod's dependencies being
unsatisfiable, turning a parser shortfall into a catalog-wide
mass-INCONCLUSIVE event. anUnknownVersionAccepts is the same rule from
the other side -- a version we cannot determine cannot be judged.

Beside those, the cases that bite in the wild:
ignoresBuildMetadataWhenComparing (0.92.2+1.20.1 is what Fabric API
actually publishes), comparesComponentsNumericallyRatherThanAsText (or 10
sorts below 9), and aShorterVersionIsPaddedRatherThanRejected.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Turns the previous commit's guards green. Reads both grammars B1's
verbatim constraints made distinguishable: npm-style clauses with
comparators, ~ and ^ shorthands and .x wildcards (Fabric/Quilt), and
Maven ranges with inclusive/exclusive bounds and open ends
(Forge/NeoForge). || is a disjunction, spaces a conjunction. Build
metadata after + or - is ignored, since Fabric API publishes
0.92.2+1.20.1 and that is the common case, not an edge one.

anythingUnreadableAccepts earned its place immediately: my first
implementation FAILED it. numbersOf maps a digit-less component to 0, so
a clause like "whatever" compared equal to 0.0.0 and REFUSED every real
version -- exactly the direction the design forbids, and invisible to
inspection because every deliberate test case was well-formed. A
looksLikeVersion check now gates each comparison site, and its doc says
what it is protecting against.

That direction is the whole safety property: an unreadable constraint
must fail towards ACCEPT, because a refusal is indistinguishable from the
dependency being genuinely unsatisfiable, and would turn a gap in grammar
coverage into a catalog-wide mass-INCONCLUSIVE event. The worst a gap can
do now is fail to narrow a choice that would have been made anyway.

Clientside suite: 184 tests, 0 failed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Red: "Too many arguments for pickDependencyFile" -- it takes no constraint
yet, and ModFile carries no version.

aConstraintNarrowsTheChoiceButNeverEmptiesIt is the load-bearing one, and
it asserts all three directions: the satisfying file is preferred, a set
where NOTHING satisfies still returns what it returned before, and a null
constraint is byte-identical to the old two-argument call.

That middle assertion is the point. Returning null where the old code
returned a file would turn a bootable candidate into a refusal, and
refuseForMissingDependencies scores a refusal INCONCLUSIVE -- so the mod
would quietly stop being verified at all rather than fail loudly.

aFileWithNoKnownVersionIsStillEligible is the same rule for the other
unknown, and theQuiltFallbackStillAppliesWithAConstraint guards the
fallback that exists precisely for Fabric API, which is what this whole
feature is about.

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: Unresolved reference KnownModIds.

A jar manifest names `fabric`; the platform wants a Modrinth slug
(fabric-api) or a CurseForge numeric id (306612). Without this bridge the
most-depended-on mod in the ecosystem cannot be resolved from a manifest
at all -- which is the whole point of reading manifest dependencies.

Two asymmetries are pinned deliberately:

- anUnknownIdIsTriedAsAModrinthSlug: Modrinth resolves a project by slug
  OR id and most mod ids are their slug, so a guess costs one lookup that
  may miss -- far cheaper than never resolving the dependency.
- anUnknownIdIsNotGuessedOnCurseForge: CurseForge addresses projects by
  NUMERIC id, so a mod id is never a valid ref. Guessing would spend API
  quota on a search that cannot be verified from the id alone.

The table stays tiny on purpose: the platform-declared path already
resolves everything the platform knows, so this covers only what the
platform metadata omits. A large guessed table is un-pinned data that
goes stale silently.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Turns the previous commit's guards green. KnownModIds bridges the two
vocabularies: a jar manifest names `fabric`, a platform wants a Modrinth
slug (fabric-api) or a CurseForge numeric id (306612).

Both spellings of Fabric API are seeded, because descriptors use both --
`fabric` in fabric.mod.json, `fabric-api` in some Quilt and Fabric ones.
Those two entries are the entire table, and it should stay that way: the
platform-declared path already resolves everything the platform knows, so
this covers only ids declared in the jar and nowhere else. A large table
of guesses would be un-pinned data going stale in silence.

The asymmetry between platforms is the design, not an oversight. Modrinth
resolves a project by slug OR id and most mod ids are their own slug, so
an unknown id is handed over as-is: one lookup that may miss beats never
resolving the dependency. CurseForge addresses projects by NUMERIC id, so
a mod id is never a valid ref there, and searching would spend API quota
on a match nothing could verify -- an id that maps nowhere is reported,
never fabricated.

The test uses the platform-name literals the rest of -clientside uses
rather than the grinder's ModPlatforms constants, which this module
cannot reference (grinder depends on clientside, not the reverse).

Clientside suite: 193 tests, 0 failed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A one-shot grind of modelfix on Docker 29.7.2, 456s, all four loaders.
What only a real runtime could answer:

- The NeoForge attempt crashed and left a genuine NeoForge
  ModLoadingCrashException crash report -- an artifact this engine had
  never captured. Five artifacts kept, index.txt reporting 3 kept /
  0 not kept.
- Retention held: every SURVIVED attempt kept nothing, including the
  other-version re-check.
- The run happened to exercise the exact case the per-attempt sink exists
  for: the NeoForge crash was superseded by a Fabric 1.20.4 re-check that
  staged into the crashing loader own directory, so a post-hoc copy after
  verify() would have found it already overwritten.
- The console/server-log difference is now measured, not asserted: the
  console holds launcher output latest.log lacks, and latest.log holds
  100 lines the console lacks.
- Report verified end to end: the details disclosure with five
  boot-log links, both indexes, the CSV Rule column, and /status
  reporting bootRules with no rule file present.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A sweep of malformed constraints found a SECOND instance of the hole
looksLikeVersion was added to close, reached through a branch that
already returned before the guard: a bare ".x" (or ".*") took
prefix = "" and then matched nothing, so it refused every version.

The hand-picked cases in anythingUnreadableAccepts missed it, which is
the point of adding the sweep. That rule's whole purpose is covering
input nobody anticipated, so examples chosen by the same person who wrote
the parser are a thin net for it -- both holes found so far were in
branches that looked obviously fine.

VersionConstraintFuzzTest now runs 30 malformed shapes against 6 real
versions and asserts not one refuses: empty brackets, dangling
comparators, half-written ranges, disjunctions with a junk arm, and text
with no digits. It is paired with readableConstraintsStillRefuseWhenThey
Should, so "accept the unreadable" cannot quietly become "accept
everything" -- which would be the same defect wearing the opposite mask.

Why a refusal matters more than it looks: it is indistinguishable from
the dependency being genuinely unsatisfiable, so a gap in grammar
coverage would surface as a catalog-wide mass-INCONCLUSIVE event rather
than as a parser bug.

Clientside suite: 196 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>
Turns the previous commit's guards green, and the split is structural
rather than a flag: an unmapped manifest id never reaches
refuseForMissingDependencies at all, so it cannot refuse a boot even by
accident. That function keeps its exact signature and behaviour, and its
three existing pins in BootVerifierOutcomeTest stay green untouched --
which is the evidence the refusal semantics were reused rather than
rewritten.

Why the asymmetry. 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 provides, or something optional in practice. Since a refusal
is scored INCONCLUSIVE, treating every unresolvable manifest id as one
would convert a large share of today's working boots into INCONCLUSIVE --
a regression dressed as a feature.

Three helpers, all pure and unit-tested:

- stageableRequirements drops the environment's own ids (minecraft, java,
  fabricloader, forge, neoforge, quilt_loader, quilt_base) and anything
  the platform already resolved, so the two overlapping sources do not
  download the same jar twice.
- unmappedDependencyNote names what went unresolved. It does not refuse,
  but it must be SAID, or a gap in KnownModIds is invisible: the boot is
  quietly less faithful for a reason nobody can see in the verdict.
- refuseForTooManyDependencies caps the graph at MAX_INJECTED_DEPENDENCIES
  = 12. A forty-jar pack that fails says nothing about the candidate,
  since any one of the forty could be the cause.

Clientside suite: 204 tests, 0 failed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Completes the resolution half of B. downloadWithDependencies now scans
each staged jar's manifest and resolves what the platform metadata never
mentioned -- the case Fabric API most often falls into, and which until
the -api exclusion fix was not even reported as a dependency.

The two sources overlap heavily, so anything already visited is skipped
rather than downloaded twice. What is genuinely new is the author who
declared a dependency only in fabric.mod.json.

The refusal split is honoured at every branch, and the asymmetry is the
whole point:

  maps to no project      -> unmapped  (never refuses)
  mapped but unresolvable -> unmapped  (a guess that missed)
  no file for this combo  -> unmapped
  mapped, resolved, then
  failed to download      -> unsatisfied (refuses -- we chose to trust it)

Also wired: the injected list feeds refuseForTooManyDependencies, and
unmappedDependencyNote is logged so a KnownModIds gap is visible rather
than silently making a boot less faithful.

ModPlatform gains `name`, replacing an `is CurseForgePlatform` instanceof
I had just written and the four scattered "Modrinth"/"CurseForge"
literals in the two implementations. A candidate's platform and a
recorded verdict's platform must agree exactly -- the grinder re-grinds
the same project forever otherwise -- so one property beats literals that
can drift apart.

Clientside suite: 204 tests, 0 failed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Red: Unresolved reference 'InjectedDependency' / 'DependencyAttribution',
and BootOutcome has no blamedDependency.

attributionNeverChangesTheBootResult is the load-bearing guard: it runs
the same crashed outcome with and without injected dependencies and
asserts the RESULT is identical, only the annotation differs. If it ever
fails, the feature has quietly become a downgrade mechanism.

Why annotate rather than downgrade (decided 2026-08-29): the candidate
did crash a server in the configuration a real pack produces.
Downgrading that on a string heuristic trades a false positive for a LOST
TRUE POSITIVE, which is the more expensive direction for a list deciding
what SPC strips from every pack built against it. Requeuing the
dependency as its own candidate answers the question with data -- grind
it, see whether it crashes alone -- instead of with a guess.

anInformationalMentionOfADependencyIsNotAttributed is the guard that
keeps the heuristic worth having. A dependency's name appears in every
"loading mod" line of a normal boot, so blaming on a bare mention would
attribute nearly every crash to whichever dependency happened to be
listed -- worse than not attributing at all. Blame requires a crash
marker or a stack frame, and stands down when the candidate's own name
shares the frame.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Turns the previous commit's guards green. DependencyAttribution.blame
finds the injected dependency a crash names; BootVerifier.attribute
annotates the outcome with it and BootOutcome gains blamedDependency and
stagedDependencies.

attribute() only ever looks at a CRASHED outcome and returns one whose
result is the input's, always. The annotation exists to make a suspicion
visible and countable, never to overrule a boot -- so the feature cannot
silently start eating true positives.

Two conservative choices, both of which the tests forced:

- A bare mention is not evidence. A dependency's name appears in every
  "loading mod" line, so a line must carry a crash marker or be a stack
  frame. Without this, nearly every crash would be attributed to whatever
  dependency happened to be listed.
- Blame stands down when the candidate is named ANYWHERE in the crash
  context, not merely on the same line. I wrote the narrow version first
  and aCrashNamingTheCandidateItselfIsNotAttributedToADependency caught
  it: "NoClassDefFoundError: com/benbenlaw/core/Thing" thrown from "at
  com.strawberrymod.Main" is ONE crash naming both, and judging line by
  line blamed the dependency on the strength of the first line alone.

Name matching normalises away the separators that differ between a jar
name and a package path (benbenlaw-core-1.20.1.jar vs com/benbenlaw/core)
and cuts the stem at the first digit group, with a four-character floor so
short ids cannot match arbitrary text.

Clientside suite: 210 tests, 0 failed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Completes B5. A dependency the crash names is queued as its own candidate
through RequeueStore, and the staged jar set reaches the report and CSV as
a Dependencies column.

The requeue is the whole point of attribution. The blame is a string match
over a console and is never allowed to move a verdict, so the suspicion
has to be settled some other way: grind the dependency alone and see
whether it crashes by itself. That converts a guess into evidence instead
of acting on it.

InjectedDependency and BootOutcome carry the dependency's project link
alongside its file name, because a jar name is not something the queue can
grind. Queueing failures are logged and dropped -- a problem putting work
on a queue must not cost the verdicts just earned.

The Dependencies column exists because a verdict reached with jars
injected beside the mod is a different claim from one reached with the mod
alone, and a reader should be able to see which they are looking at.

Clientside 210 tests, grinder 364 tests, 0 failed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Both are inversions of the obvious, and both cost something to get wrong:
VersionConstraint fails towards ACCEPT (a refusal is indistinguishable
from a genuinely unsatisfiable dependency, so a grammar gap would look
like a catalog-wide outage), and the refusal split keeps unmapped
manifest ids away from refuseForMissingDependencies structurally rather
than by a flag.

Also recorded: the accept-direction shipped broken twice during
development, both times invisible to inspection, the second found only by
a deliberate adversarial sweep. That is why the fuzz guard exists rather
than more hand-picked cases.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
SPC_GRINDER_RULE_FALLBACK, default `grinder`: a rule stating no verdict
leaves the decision to the built-in ladder and merely names itself on the
verdict -- which restores "if none is specified, determine by grinder"
and brings back labelling a signature without deciding it.
`inconclusive` opts into the conservative reading, for a run where
unfinished rules are expected.

The distinction that makes this coherent: an ABSENT verdict is undecided,
an UNREADABLE one is broken. A misspelt verdict therefore stays
INCONCLUSIVE whatever the setting says -- the author tried to state an
intention and failed, and a typo is not an intention to honour. The
opt-in also governs undecided rules ONLY: theOptInDoesNotOverrideARule
ThatStatesAVerdict pins that turning it on cannot silently rewrite
somebody's deliberate CRASHED rules.

ConsoleRule.verdict goes back to nullable so a rule stays as authored, and
the policy is applied at classify time rather than baked in at load. That
is what lets /status report which mode is in force (`undecidedVerdict`)
rather than showing rules whose meaning has already been rewritten.

Three tests changed from pinning the old unconditional default to pinning
BOTH modes, which is the honest shape now that it is a choice.

QFAPI seeded into KnownModIds while I was here, verified against both live
APIs rather than guessed: Modrinth `qsl` (qvIfYCYJ), CurseForge `634179`.
That mapping is unguessable from `quilted_fabric_api` -- the Modrinth
slug-fallback would miss it outright -- which is the clearest argument yet
for the registry existing at all. The pre-seeded Fabric API id 306612 was
confirmed the same way.

Clientside 214 tests, grinder 364 tests, 0 failed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
On the grind path rather than the report path, and it wants a measurement
before a fix -- this repo justifies performance-shaped changes with
numbers, and taking that measurement properly needs a store far larger
than the 875 rows on hand.

Recorded with the distinction that matters: this is NOT the report layer
in-memory filter/sort, which was measured as fine for a human-facing page.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Lands GREEN, deliberately: these are characterization guards over
correct-but-fragile code, put in place to protect the column restructure
that follows. "Red first" applies to tests for new behaviour, and this is
not new behaviour.

everyColumnRendersTheValueItsHeaderNames is the guard counting cannot
give you. everyHeaderHasACellBeneathIt proves the NUMBER of cells matches
the number of headers -- which is exactly what an off-by-one preserves:
drop a column and add another and every cell past the gap shows its
neighbour's data with the count still right. Each field now carries a
distinct sentinel and cell i is asserted to hold column i's.

theCsvAndTheTableAgreeOnTheirDataColumns pins that the two renderings of
one column list describe the same fields. They have drifted before -- the
CSV header carried seven while the table carried eight, for a long time --
so the relationship is stated as an assertion rather than left to whoever
next adds a column.

Fixed one sentinel while writing it: the date column reads Instant.EPOCH
from the fixture, so the expectation is 1970, not 2026.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Red: Unresolved reference 'VerdictSelection' / 'VerdictQuery' /
'QueryParams' / 'VerdictField'.

Pinned as a pure function rather than through the HTTP handler on
purpose. Every edge case here -- page 0, page past the end, size=banana,
a sort naming a dropped column, a filter value carrying & -- costs three
lines and microseconds; through the server each would be a socket round
trip asserting against a 60 KB blob. It is also what will make the table
and the CSV PROVABLY agree: they will agree because they run this
function over the same query, not because two renderers were kept in step
by hand.

The two that carry the most weight:

- junkInTheQueryFallsBackInsteadOfThrowing. These values arrive from
  bookmarks, shared links and people editing the address bar. A report
  that 500s is worse than one that quietly falls back.
- theSizeInForceIsAlwaysOfferedEvenWhenTheResultCountWouldNot. Filter
  104,000 rows down to 12 while size=1000 and a naive "only offer sizes
  the data justifies" leaves the control with no matching option, so the
  browser shows the first one and the page silently disagrees with its
  own URL.

emptyParametersReadAsAbsentAndAreNotEmitted exists because a GET form
submits its empty controls, so ?f.name=&q= arrives constantly and would
otherwise ride along in every shared link.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Turns the query guards green. VerdictField is now the single declaration
of a report column -- header, CSV header, URL token, filter kind and cell
text in one place -- and QueryParams/VerdictQuery/VerdictSelection do the
selecting as pure functions with no store, server or clock.

VerdictField also adds the Platform, Project sideness and Jar sideness
columns. Project sideness is rendered honestly rather than as a bare
enum: "not recorded" when nobody asked, "not published by CurseForge"
where the platform publishes none for ANY project, and the pair otherwise.
Showing UNKNOWN for both would tell a reader we checked and found the mod
server-safe.

Two bugs the tests caught that reading would not have:

- toQueryString built its parts inside buildList, whose MutableList
  receiver SHADOWS the `size` property. So `size != DEFAULT_PAGE_SIZE`
  compared the list's length and emitted it as the value: size=250 became
  "size=0" and size=2 became "size=4". Every shared link would have
  carried a wrong page size. Bound outside the block now, with the trap
  named in a comment.
- My own expectation for the size ladder was wrong, not the code. For
  1,842 rows, 2,000 would show everything on one page -- which is what
  `all` already is -- so sizes at or above the count are omitted as
  duplicates rather than kept as "the next one up".

offeredSizes always includes the size in force even when the count would
not justify it: filter a large store down to a handful while a big size is
set and a purely count-derived list leaves the control with no matching
option, so the browser shows its first one while the URL says another.

Grinder suite: 382 tests, 0 failed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Completes the report work. The table is built from a VerdictPage, so
filtering, sorting and paging all happen server-side and every one of them
lives in the URL -- which is what makes a view bookmarkable and shareable.

Measured against the real 875-row store rather than asserted:

  875 rows -> 4 pages at size 250; sizes offered [100, 250, 500, all]
  HIGH=39, Fabric=229, q=create -> 8   (matches the store's own profile)
  20 filtered+sorted selections in 13ms  (~0.65ms each)
  page bytes: size=250 -> 110,703   size=all -> 369,873

So the cost was never the filtering -- it is the HTML, and paging is what
addresses it. A 3.3x smaller default page on 875 rows, and the ratio grows
with the store.

Filtering needs NO JavaScript: CHOICE columns get a <select> of the values
actually present, TEXT columns an <input>, all inside one GET form.
Submitting IS the URL update. Sorting became header links carrying the
rest of the query, so the DOM sort is gone -- it was lost on every reload
anyway, and could not be shared.

The CSV button is now a link to /export.csv with the current filters and
sort but size=all, since "download the filtered set" means all of it. The
embedded CSV literal is gone, and its removal is pinned: an embedded copy
would silently disagree with a filtered export, and dropping it also stops
the page putting mod-supplied text inside a <script> block at all.

/export.csv runs the SAME VerdictSelection the table does, so the two
cannot disagree -- they agree by sharing the function, not by two
renderers being kept in step. A bare /export.csv still exports everything
(defaultSize = null), which is the documented behaviour operators script.

Three columns land with it: Platform, Project sideness and Jar sideness.
Project sideness is rendered honestly -- "not recorded" when nobody asked,
"not published by CurseForge" where the platform publishes none for ANY
project -- because a bare UNKNOWN would read as "checked, and it is fine".

Existing expectations changed, hence feat: the CSV header gains three
columns, the sort assertions move from onclick to links, and
embedsTheCsvForTheDownloadButton becomes
theDownloadButtonLinksTheFilteredCsvExport, which now pins the removal.

Grinder suite: 382 tests, 0 failed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The numbers are from the real 875-row store rather than reasoning: 13ms
for 20 filtered+sorted selections, and 110,703 page bytes at size=250
against 369,873 for all. The cost was never the filtering -- it is the
HTML, which is what paging addresses.

Also recorded: the buildList `size` shadowing bug, because it is
invisible by reading and would have put a wrong page size in every shared
link, and the size-ladder correction where my expectation was wrong
rather than the code.

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>
The queueing had no unit test -- an omission, since it is the mechanism
that makes attribution safe: the blame never moves a verdict, so the
suspicion is only ever settled by grinding the dependency alone.

Also records what a live daemon confirmed: the report filters, pages and
sorts a real store correctly, /export.csv honours the same query and
parses as well-formed CSV, and --requeue is drained FORCED and ahead of
the crawl on the next pass -- GrindLoop ordering that was a source grep
until this week.

Two counting traps recorded because both looked like defects and neither
was: wc -l under-counts a CSV with no trailing newline, and the row total
moved mid-run because the daemon was grinding.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Both guards fail on this commit, which is the point.

Found by reading 200 of the 609 crash logs the live grinder publishes.
`Fabric API requires version ...` is the single largest failure class in
them -- the requirer in 55 of the 200 sampled logs -- and it is caused by
`pickDependencyFile`, not by any mod.

`pickForLoader` prefers an exact Minecraft match but falls back to the
newest file for the loader whatever version it targets, and that fallback
is consulted *before* the Quilt-to-Fabric loader fallback. CurseForge tags
only recent Fabric API files as Quilt-compatible, so a Quilt boot matched a
`+26.3` file on the loader, took it despite the version mismatch, and never
reached the Fabric build that had the right Minecraft version.

Measured over the sample: 20 of the 35 boots that staged a Fabric API
staged one for the wrong Minecraft version -- all Quilt, all `+26.3`, into
packs as old as 1.19.2. Quilt Loader refused each pack outright and the
*candidate* was scored CRASHED for the grinder's own doing.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Turns the previous commit's two guards green. Clientside suite 216, 0 failed.

The Minecraft version is now fixed across both loader attempts instead of
being a preference inside each. That is what makes the Quilt-to-Fabric
fallback reachable: it used to be consulted only when the project published
nothing for Quilt at all, so a Quilt-tagged file for the *wrong* version
satisfied the first attempt and the Fabric build carrying the right version
was never considered.

A dependency that matches no file for the pack's Minecraft version is now
not staged, and the boot is refused. `refuseForMissingDependencies` scores
that INCONCLUSIVE, which is the honest verdict -- the mod never got a fair
run. The candidate under test keeps its loose fallback: booting it on a
near-miss version still tests the candidate, whereas injecting a near-miss
dependency only manufactures a version conflict to blame on it.

BEHAVIOUR CHANGE, and an existing assertion moved with it:
`dependencyFilePrefersExactMinecraftMatchThenFallsBack` expected a 1.19.2
dependency to be staged into a 1.21 pack. That expectation was the bug, so
the test is renamed `dependencyFileTakesTheExactMinecraftMatch` and now
expects null. Flagged rather than quietly relabelled -- per the conventions
a changed expected value means this is a fix, not a refactor.

Not fixed here, recorded as a known gap: `clientOnlyClassMarker` misses
Fabric intermediary class names (`net/minecraft/class_746` is LocalPlayer),
seen in 2 of the 200 sampled logs. There is no safe pattern -- `class_NNNN`
is intermediary for every class, not only client ones -- so a marker would
trade these false negatives for false positives. It needs a version-specific
ID list or nothing.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Four of the five guards fail on this commit. The fifth,
`aClientClassCrashOutranksTheNetworkExcuse`, passes by construction today
and is here to stay green through the next commit -- it is what stops the
new network excuse being placed above the decisive client-only marker.

Written from a census of 200 of the 609 crash logs the live grinder
publishes. 130 of the 200 (65%) carry no client-side evidence at all, yet
every one is scored CRASHED. The four largest recognisable causes, none of
which says anything about sideness:

  63  Quilt solver `requires version [x, y) of z`
  15  network denied (UnknownHost/Connect/SocketTimeout)
   6  mixin ClassMetadataNotFoundException naming an unstaged class
   6  ClassNotFoundException on the legacy MixinTweaker

The network class is the clearest: boots run `--network none`, so a mod
whose loader phones home at startup cannot pass here and would not fail
anywhere else. OneConfig fetches its stage1 from api.polyfrost.org, falls
back to a Swing dialog when it cannot -- hence `Fontconfig error: No
writable cache directories` in a headless container -- and exits.

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>
Two corrections found while checking the plan for leftovers.

`report/CLAUDE.md` still described the embedded-CSV button that the query
layer replaced, which reads as authoritative and is not.

README section 7 documented every `/status` field except `bootRules`, added
with the console-rule file. An operator checking whether their rules loaded
had nothing to look at.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`StoreWriteBenchTest` was swept into 47ccb99d4 as a scratch file and ran on
every build, seeding a 100 k-row store for no one's benefit. It is now gated
behind SPC_GRINDER_BENCH=1 and gains a second case that splits persist()
into its three costs, which is what B35 actually asked about.

Per record(): 1 k -> 18-25 ms, 10 k -> 74-83 ms, 100 k -> 787-1050 ms
(45.8 MiB file).

B35 asked whether dropping the pretty-printer would be enough. It is not.
At 100 k rows: sort 40.6 ms, pretty write 707.5 ms, compact write 360.7 ms.
The printer is about half the write and 16% of the bytes -- a 2x win that
still leaves ~360 ms per verdict. The O(n) whole-file rewrite is the cost.

The deployed store holds 38,258 verdicts and persist() is @Synchronized on
the grind worker's thread, so at the observed ~4.3 verdicts/second this
already serialises every worker.

No fix landed. Both remaining candidates -- coalesced writes, or an
append-log with compaction -- change either durability or the store format
on a file holding 38 k live verdicts, and neither belongs at the tail of
this branch. Both are written up in BACKLOG.md with the numbers to size
them against.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Definition-of-done #4 for the two fixes on this branch: the dependency
Minecraft-version rule in BootCandidateSelector and the environment excuses
in BootLogClassifier, with the before/after verdict counts on real logs and
the one gap left deliberately unfixed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Red on this commit: the module does not compile, because `flushInterval`,
`flush()` and `close()` do not exist yet.

Five guards for the fix B35's measurements point at. The first is the point
of the change -- a buffered store must not rewrite the whole file per
verdict -- and it is asserted on the file never appearing, because a size
or mtime comparison would race the flusher. The others cover the explicit
flush, the scheduled flush firing unasked, and the two data-loss paths that
matter: close() during an orderly shutdown, and the default staying
write-through so no existing caller silently loses durability.

Numbers behind it (StoreWriteBenchTest, 2026-08-29): 18-25 ms per record()
at 1 k rows, 74-83 ms at 10 k, 787-1050 ms at 100 k. Dropping the
pretty-printer was measured and is not enough -- 707 ms to 361 ms at 100 k.
Only writing less often is.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Turns the previous commit's five guards green.

`persist()` serialises the whole store, and `record()` called it per verdict
while holding the lock on a grind worker's thread. Measured before/after on
the same run (StoreWriteBenchTest, SPC_GRINDER_BENCH=1):

  rows      write-through        coalesced
  1 000        20.8 ms             2.5 us
  10 000       94.7 ms             2.1 us
  100 000    1242.5 ms             2.6 us

Coalesced `record()` is flat -- it no longer scales with the store at all,
which is the actual defect. The deployed store holds 38,258 verdicts and
grows monotonically, so write-through was getting worse every pass.

Shape: `record()` buffers and marks pending; a daemon flusher persists on
SPC_GRINDER_STORE_FLUSH_SECONDS (default 30); the shutdown hook flushes
last, after the workers have stopped, so an orderly stop loses nothing. The
pending flag is cleared *after* a successful write, so a failed flush is
retried on the next tick rather than dropping what it was holding.

Nothing about the file format, the pretty-printing or the atomic move
changes -- this only changes how often the write happens.

Durability, stated plainly: a hard kill can now lose up to one interval of
verdicts, which the re-verify TTL re-derives. The default is deliberately
write-through (Duration.ZERO), so coalescing is opted into at the
composition root and no existing caller -- or test -- silently loses the
durability it was written against.

`VerdictStore.flush()` is defaulted to a no-op so the in-memory store needs
no ceremony and the shutdown hook can flush without knowing which it holds.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
BACKLOG.md says a landed item is deleted there and recorded in
REFACTOR-LOG.md, so B35's entry is removed rather than marked closed and the
file returns to its documented empty state. The numbering note already
explains why that is not a reset: B36 is next, and IDs are never reused.

The log entry carries the crash-log census the two clientside fixes came
out of, the before/after verdict counts on the real logs, B35's before/after
timings, and why the append-log was not needed after all.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Fifty-six commits. The nine features Griefed asked for, plus the defects
that reading the live grinder's own crash logs turned up.

Features: all boot artifacts kept per attempt and browsable per row; Fabric
API staged as a real dependency for Fabric and Quilt; recursive dependency
resolution from both platforms and from jar manifests; report pagination,
per-column filter and search, and the whole query in the URL; a hot-editable
console-rule file with an operator-chosen fallback verdict; project- and
jar-sideness columns; and the grinder's own complexity cut down (GrindLoop,
GrinderConfiguration, VerdictQuery extracted from a 300-line main).

Fixes found along the way, each pinned red before it went green:

- JsonVerdictStore could lose the entire store: FAIL_ON_UNKNOWN_PROPERTIES
  was on, so a downgrade failed the read, logged "starting empty", and the
  next record() overwrote everything. Found while planning, fixed before any
  field was added -- adding one would have armed it.
- A dependency was staged for the wrong Minecraft version. 20 of 35 sampled
  Quilt boots got a `+26.3` Fabric API into packs as old as 1.19.2, and the
  candidate wore the resulting verdict.
- Harness failures scored as the mod's crash. Re-classifying real logs: the
  21 Griefed sent went 21 CRASHED -> 11/10, a 200-log sample 200 -> 113/87,
  both true positives retained.
- B35: verdict-store writes coalesced. 1242.5 ms -> 2.6 us per record() at
  100k rows, and flat rather than growing with the store.

Full build green: 1137 tests, 0 failed, 29 skipped.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
docs: file B36 — the published HIGH verdicts need re-grinding after deploy
Some checks failed
Qodana / scan (push) Successful in 15m26s
Docker Test / build image (push) Failing after 15m30s
Qodana / notify (push) Successful in 14s
Test / build (push) Failing after 22m31s
592f1ce21c
The clientside fixes merged today change 38% of the verdicts that are
actually published. HIGH is the only confidence that reaches
/as-properties, which SPC instances poll as their fallback.updateurl, so
each false positive is a working mod being stripped from users' server
packs.

Measured against the live service rather than estimated: /export.csv holds
38,532 verdicts of which 466 are HIGH; 455 of those matched a published
crash log; re-classifying all 455 with the fixed BootLogClassifier leaves
281 CRASHED and excuses 174.

By loader that is Quilt 118, Forge 52, NeoForge 4 -- the Quilt
concentration being the pickForLoader bug staging a +26.3 Fabric API into
packs as old as 1.19.2.

Filed rather than done because it cannot be done from here: the deployed
daemon still runs pre-branch code, so the fixes must be deployed first, and
the re-grind is hours-to-days of host time to schedule.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Red on this commit: VerdictSelection.select takes no log-count lookup yet,
so the module does not compile.

Logs was exempted from sorting as well as filtering, on the reasoning that
it is not derivable from a GrindVerdict. Filtering it stays exempt; sorting
is not meaningless at all. Not every entry gets logs -- artifacts are kept
only for boots that did not survive, and the reaper drops the oldest
attempts once the budget is passed -- so "which rows actually have something
to read" is the question a maintainer opens this table to ask.

Six guards: both directions, the round trip through the URL, the no-lookup
case, and the header being a real sort link (without which the feature is
unreachable by clicking). The tie-break is pinned as stable in BOTH
directions, unlike the field sorts which reverse their whole comparator --
reversing it here would shuffle every log-less row when a reader merely
flipped the arrow, and those rows are the majority.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Turns the previous commit's six guards green. Grinder suite 402, 0 failed.

Sorting is now keyed by a `SortKey` rather than a `VerdictField`, because
the sortable columns and the verdict-derived ones are not the same set:
Logs is rendered from a directory listing, so it can be sorted but never
filtered, searched or exported. Modelling that as a sealed type keeps the
difference in the compiler instead of in a comment -- a VerdictField cannot
be asked for a log count, and SortKey.Logs cannot be asked for cell text.
VerdictField itself is untouched.

`VerdictSelection.select` takes an optional log-count lookup, defaulting to
"nothing has logs" so every caller that does not sort by them needs no
directory listing. The server passes the same per-request snapshot the
renderer already uses, through one shared `logNamesFor`, so the count a row
is sorted by and the links it then shows cannot drift apart.

The Logs tie-break runs by slug in BOTH directions, deliberately unlike the
field sorts which reverse their whole comparator: reversing it here would
reshuffle every log-less row whenever a reader flipped the arrow, and those
rows are the majority.

`/export.csv` deliberately passes no lookup -- that export has no Logs
column, so `sort=logs` degrades to the slug/loader order rather than costing
a listing for a column nobody is exporting.

Landmine worth naming: SortKey.Column's property is `column`, not `field`.
Inside a property getter `field` is the backing-field keyword, so
`field.param` bound to a backing field the property does not have, and the
compiler reported "Property must be initialized" -- naming neither the cause
nor the collision.

Also corrects the README, which still described 6 columns and no filtering
long after the table grew to 13.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The unit test pins the ordering; this pins the wiring. The count the sort
uses and the names the cells render must come from the same per-request
snapshot -- they were two separate lookups in the first draft, which is
exactly how a row sorts as having logs and then renders an em-dash.

Drives the real HTTP handler against a real BootLogStore directory:
GET /?sort=logs&dir=desc must lead with the row holding artifacts, render a
sort link on the header, and still show the "2 log(s)" count it was ordered
by.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
That line was the reasoning the exemption was built on, and it was wrong for
the reason Griefed gave: not every entry has logs, so ordering by them is
how a maintainer finds the rows with anything to read.

Records what replaced it -- the SortKey sealed type, the single snapshot
shared by the sort and the cells, the `field` backing-field collision, and
why the Logs tie-break does not reverse with the sort.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
B36 planned a targeted requeue of the 428 published projects whose HIGH
verdicts the merged fixes invalidate. Griefed deployed and reset the store
instead, which achieves the same thing and more.

Verified against the live service rather than assumed: /status now carries
the bootRules block, so it is running the merged code; the store went
38,258 -> 186 verdicts; and the spread is 130 LOW / 54 MEDIUM / 2 HIGH. The
466 HIGH and the 174 false positives among them are gone, and
/as-properties is back to essentially the shipped list.

Deleted per the backlog's convention and recorded in REFACTOR-LOG.md, where
the measurement keeps its value: it is the only end-to-end evidence of what
the pre-fix engine published, and the number to compare against once the
fresh sweep covers comparable ground.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Definition-of-done #4: what changed, why the original exemption was wrong,
and the two things a reader needs before touching it -- that SortKey is a
sealed type because the sortable and verdict-derived column sets differ, and
that the sort's count and the cell's links must share one snapshot.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
merge: sorting the report by the Logs column
Some checks failed
Documentation / Writerside webhelp (push) Successful in 6m16s
Continuous / Build JAR (push) Failing after 12m30s
Continuous / Build AppImage (x86_64) (push) Has been skipped
Continuous / Build AppImage (aarch64) (push) Has been skipped
Continuous / Build Install4J Media (push) Has been skipped
Continuous / Continuous Pre-Release (push) Has been skipped
Qodana / scan (push) Successful in 16m41s
Test / build (push) Failing after 13m8s
Docker Test / build image (push) Failing after 25m42s
Qodana / notify (push) Successful in 23s
Documentation / Help image (push) Successful in 5m50s
388b6e3a29
Logs was exempt from sorting as well as filtering, on the reasoning that it
is not derivable from a GrindVerdict. Filtering it stays exempt; the sorting
half of that reasoning was wrong, and for the reason Griefed gave: not every
entry has logs -- artifacts are kept only for boots that did not survive,
and the reaper drops the oldest past the budget -- so ordering by them is
how a maintainer finds the rows with anything to read.

The sort key is now a sealed SortKey (Column(VerdictField) or Logs) rather
than a VerdictField, because the sortable columns and the verdict-derived
ones are not the same set. VerdictField is untouched. VerdictSelection.select
takes an optional log-count lookup defaulting to "nothing has logs", and the
server passes the same per-request snapshot the cells render from through
one shared logNamesFor -- two lookups is how a row sorts as having logs and
then renders an em-dash, which is why the wiring is pinned through the real
HTTP handler and not only as a pure unit.

Also closes B36. It planned a targeted requeue of the 428 published projects
whose HIGH verdicts the previous merge invalidates; Griefed deployed and
reset the store instead, which achieves the same and more. Verified live:
/status now carries bootRules, the store went 38,258 -> 186 verdicts, and
the spread is 130 LOW / 54 MEDIUM / 2 HIGH. The measurement is kept in
REFACTOR-LOG.md as the only end-to-end evidence of what the pre-fix engine
was publishing.

Full build green: 1144 tests, 0 failed, 29 skipped.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Three of the four guards fail on this commit. The fourth,
theCsvDefaultOrderIsTheSameOrdering, passes today because both layers
happen to declare identical rank tables -- it is here to fail if they ever
drift, which is the whole reason the plan wanted them collapsed into one.

Observed on the live report: `?sort=confidence` returned HIGH, HIGH,
INCONCLUSIVE, INCONCLUSIVE, INCONCLUSIVE, LOW. That is alphabetical order,
in which INCONCLUSIVE -- "nothing was learned" -- outranks both MEDIUM and
LOW. Only the *default* order ever carried a rank; a named sort fell through
to VerdictField.text, which for this column is the enum name.

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>
Definition-of-done #4 for the confidence-sort fix, and a warning worth
leaving: the column's cell text is an enum name, so anything that sorts it
as text gets alphabetical order in which INCONCLUSIVE beats MEDIUM.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The plan required it for the report restructure and it had never been run.
Base 57d22b57c's unmodified test tree against this work's production code.

Four files could not compile, enumerated rather than worked around: the
CrashLogStore -> BootLogStore rename with its reshaped keep(), the
keepCrashConsoles -> bootArtifactSink hook, and toHtml(List) ->
toHtml(VerdictPage). All three are data-shape changes rather than renames,
so adapting them would mean rewriting fixtures.

The other 48 files ran: 323 tests, 16 failed, zero regressions. 14 are
source-text guards over main()'s body, which item 9 deliberately moved into
GrinderConfiguration and GrindLoop -- their HEAD replacements execute what
these could only grep. 2 are the CSV header growing from 7 columns to 12.

Also states what was NOT run: B6's merge gate, which needs Docker and hours
of boots and has been overtaken by the live reset-and-re-grind.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Two things the plan specified that had never landed, found by re-reading it
rather than by trusting my own account of it.

The Confidence header sorted alphabetically. Observed live:
`?sort=confidence` returned HIGH, HIGH, INCONCLUSIVE, INCONCLUSIVE,
INCONCLUSIVE, LOW -- an order in which INCONCLUSIVE, meaning nothing was
learned, outranks MEDIUM and LOW. Only the default order had ever carried a
rank; a named sort fell through to VerdictField.text, which for that column
is the enum name. VerdictField now carries the `sortKey` the plan asked for,
defaulting to `text` and overridden only by CONFIDENCE.

The rank table also existed twice -- once in the report's sorter, once in
VerdictCsvExporter -- so the table and the export could drift on what
"highest confidence first" means. It is now VerdictField.CONFIDENCE_RANK,
declared once, with a guard that fails if the two ever disagree.

And the equivalence proof the plan required for the report restructure,
which had never been run: base 57d22b57c's unmodified tests against this
work's production code. Four files could not compile, all three signature
changes enumerated in REFACTOR-LOG.md; the other 48 ran 323 tests with 16
failures and zero regressions -- 14 source-text guards over a main() that
item 9 deliberately emptied, and 2 for the CSV header growing to 12 columns.

Full build green: 1148 tests, 0 failed, 29 skipped.

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 red guard green. API suite 374, 0 failed.

Completes B0, which had only half-landed. Making `fabric` a real dependency
again was necessary but not sufficient: ModListCompiler's rescue matched
ModDependency.modID against the disabled mod's own modID literally, so
"fabric" was compared to "fabric-api" and missed.

Verified against the real artifact rather than reasoned about: Fabric API
0.92.11+1.20.1 from Modrinth's CDN declares "id": "fabric-api" with
"provides": ["fabric"] and 53 nested jars. The newest builds have dropped
the block entirely, so its absence must stay normal -- pinned.

ScannedMod gains `provides`, behind @JvmOverloads so the previous
(File, String, Sideness, List) JVM constructor descriptor survives; pf4j
loads compiled plugin jars, so binary compatibility is the contract that
binds. FabricFamilyScanner gains an open readProvides defaulted to empty,
overridden by FabricScanner (flat array) and QuiltScanner (nested under
quilt_loader, entries either a bare string or an object with an id -- both
shapes occur and both are pinned, because reading one silently yields a
plausible empty list rather than an error).

The rescue loop's condition and body had duplicated the same three-level
match; both now call one `dependantOf`, which is where the alias lookup
lives.

Recorded in API-BEHAVIOUR-CHANGES.md.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Three verification steps the plan specified and that had never been run.
The middle one found the `provides` defect fixed in this branch; the third
proves the whole staging chain on a real boot, with the "previously" half
confirmed from the pre-branch source rather than asserted.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The plan's last unrun verification. Six pre-registered candidates ground on
57d22b57c and on current code, fresh home each, shared loader cache.

Four false positives removed, three controls held, zero regressions. The
controls holding is what makes the removals meaningful: arcane-vortex's FML
invalid-dist and avm-mod's class_746 still reach HIGH, so this is not a
blanket softening of the classifier.

astronomical/Quilt is the strongest result -- HIGH(CRASHED) to LOW(SURVIVED)
with the correct QFAPI for Minecraft 1.19.2 staged. Not excused, proven
server-safe once the pack was assembled correctly.

Scale is stated rather than implied: 6 candidates, not the plan's ~100. The
available Docker VM was 2 CPUs / 1.93 GiB, where one multi-loader candidate
costs ~20 minutes.

Also notes a cosmetic defect the results surfaced: stagedDependencies can
list the same jar twice when it resolves through both the platform and the
jar manifest.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The clientside test tree did not compile from clean, and had not since
`18f59b4bf feat(clientside): stage the dependencies a jar manifest declares
alone` added an abstract `val name: String` to ModPlatform without updating
the two anonymous implementations in the test tree.

Incremental compilation hid it: adding an abstract member to an interface is
an ABI change, but Gradle never recompiled those two files, so every build
since -- including the full green ones this work was merged on -- was green
without ever compiling them. `--rerun-tasks` is what exposed it. A build
that is green for the wrong reason is worth more attention than the two
lines it took to fix.

`platform.name` is read on the staging path (`platformRefFor` maps a
manifest mod-id per platform), so neither fixture could answer with
`error(...)` the way its other members do. AttemptStagingIsolationTest's
factory already took a `name` it never wired up, and now returns it -- the
same name its ProjectFiles carry, which is precisely the agreement that
fixture exists to exercise. BootVerifierSelectionTest gets "unused", a
spelling no platform uses, so a mod-id resolves to nothing there.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Red on this commit. Found by reading B6's merge-gate results rather than
the code: `amblekit/Fabric` recorded `fabric-api-0.100.8+1.20.6.jar` twice
in its stagedDependencies.

A dependency is reachable under two different-but-equally-valid
identifiers -- the platform ref the author linked (`P7dR8mSH`) and the mod
id the jar's own manifest declares (`fabric`, mapped by ModIdRegistry to the
slug `fabric-api`). `stageableRequirements` dedupes by *ref*, so it cannot
see that those resolve to one file; only the file name can.

Cosmetic in the report, but not against the cap: double counting refuses a
pack that is within it, and `refuseForTooManyDependencies` scores a refusal
INCONCLUSIVE -- so the mod silently stops being verified. The guard uses
seven distinct jars recorded twice, because six duplicated is twelve entries
and the cap is twelve, which would have passed while testing nothing. The
companion guard keeps the cap biting on thirteen genuinely distinct ones.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Turns the previous commit's guard green. Clientside suite 223, 0 failed.

Fixed at both layers, because they are two different contracts:

- The staging site no longer records a file it already holds, which is what
  reaches `stagedDependencies` and so the report's Dependencies column and
  the CSV.
- `refuseForTooManyDependencies` counts distinct files, so the cap's own
  contract is honest for any caller rather than only for the one path that
  now happens to hand it a clean list.

Ref-level dedupe cannot do this. `stageableRequirements` compares the
platform ref the author linked (`P7dR8mSH`) against the mod id the manifest
declares (`fabric`, mapped to the slug `fabric-api`); both name the same
project and neither string equals the other, so only the resolved file name
identifies it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Two things B6's gate surfaced that are worth a reader knowing before they
touch staging: a dependency is identified by its resolved file rather than
by a ref, because one project has two equally-valid ref spellings; and a
green build is not evidence that the test tree compiles, which is why
--rerun-tasks earns its place.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
merge: B6's merge gate, the provides rescue, and a build that was green for the wrong reason
All checks were successful
Documentation / Writerside webhelp (push) Successful in 2m33s
Continuous / Build JAR (push) Successful in 13m27s
Qodana / scan (push) Successful in 12m27s
Docker Test / build image (push) Successful in 16m46s
Documentation / Help image (push) Successful in 3m9s
Continuous / Build AppImage (x86_64) (push) Successful in 2m35s
Continuous / Build AppImage (aarch64) (push) Successful in 2m55s
Qodana / notify (push) Successful in 9s
Continuous / Build Install4J Media (push) Successful in 9m35s
Test / build (push) Successful in 15m30s
Continuous / Continuous Pre-Release (push) Successful in 5m0s
bd5f8282c6
Closes the last unrun item of the plan, and two defects found by running it.

B6's merge gate: six pre-registered candidates ground on 57d22b57c and on
current code, fresh home each, shared loader cache. HIGH verdicts 8 -> 4 --
four false positives removed, three controls held, zero regressions. The
controls holding is what makes the removals meaningful: arcane-vortex's FML
invalid-dist and avm-mod's class_746 still reach HIGH, so this is not a
blanket softening of the classifier. astronomical/Quilt is the strongest
result, HIGH(CRASHED) -> LOW(SURVIVED) with the correct QFAPI for Minecraft
1.19.2 staged: not excused, proven server-safe. Scale is stated rather than
implied -- 6 candidates, not the plan's ~100, on a 2-CPU / 1.93 GiB Docker
VM where one multi-loader candidate costs ~20 minutes.

Two defects, each pinned red before its fix:

- B0 had only half-landed. Fabric API 0.92.11+1.20.1 declares
  "id": "fabric-api" with "provides": ["fabric"] (verified against the jar
  from Modrinth's CDN; the newest build has dropped the block). No scanner
  read `provides`, so ModListCompiler's rescue compared "fabric" to
  "fabric-api" and missed, and a clientside list naming Fabric API still
  stripped it from under every mod using the historical id. ScannedMod now
  carries `provides` behind @JvmOverloads, and the rescue matches a mod's id
  and its aliases.
- A staged dependency was counted by ref rather than by file, so one jar
  reachable as both `P7dR8mSH` and `fabric-api` was recorded twice -- and
  double-counted toward MAX_INJECTED_DEPENDENCIES, refusing packs within the
  cap and scoring them INCONCLUSIVE.

And one that was hiding: the clientside test tree had not compiled from
clean since 18f59b4bf added an abstract ModPlatform.name without updating
two anonymous implementations in tests. Gradle's incremental compilation
never recompiled them, so every green build since -- including the ones this
work was merged on -- was green without ever compiling those files.

Also here: the Docker integration suite (9/9, no skips) and the Fabric API
acceptance check, both specified by the plan and never run until now.

Full build green: 1156 tests, 0 failed, 29 skipped.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
deploy/update-grinder.sh: clone a branch, build it as an unprivileged
account, install it, restart. Run as root, which is the inverse of
install-grinder.sh and for a reason -- its only privileged act is dropping
to the build account.

It builds as a dedicated build account rather than as `grinder`. The service
account is created nologin and without sudo, which is correct for a service
account, and the installer needs both -- it calls sudo about thirty times.
`sudo -u grinder -i ./install-grinder.sh` therefore fails twice over: -i
runs the account's login shell, which is /usr/sbin/nologin, and the account
could not sudo even if it had one. Both prerequisites are checked before
anything is cloned, because otherwise the missing docker group surfaces
inside the installer as a stopped daemon and the missing NOPASSWD as a hang
on a prompt with no TTY to answer it.

Three decisions that are easy to get wrong by hand, so the script encodes
them:

- The checkout lives OUTSIDE the install prefix, and a path inside it is
  refused. install-grinder.sh ends with `chown -R root:root $PREFIX`, which
  would take the build tree with it and leave the next build unable to write
  its own source. The guard uses a trailing slash so a sibling like
  /opt/spc-grinder-src is not mistaken for a child of /opt/spc-grinder.
- The service is not stopped or started here. The installer does both, and
  its EXIT trap restarts the service if the install dies halfway -- which
  only fires if the installer was the one that stopped it.
- `bash -lc`, never `-i`, and `-H` so Gradle gets the build account's own
  ~/.gradle instead of failing on root's.

Preflight mirrors the installer's: `set -Eeuo pipefail`, an ERR trap naming
the line, and the same absolute/two-components-deep shape check on the
directory it is about to `rm -rf`. Untested by design -- no test in this
module references the deploy scripts.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
deploy/update-grinder.sh — clone a branch, build it as an unprivileged
account, install it, restart. Run as root, whose only privileged act is
dropping to the build account.

The question it answers: `sudo -u grinder -i ./install-grinder.sh` cannot
work, and not for a shell-syntax reason. The service account is created
nologin and without sudo — correct for a service account — while the
installer needs both, calling sudo about thirty times for systemctl, useradd
and everything under the prefix. So the build runs as a separate account
whose docker membership and passwordless sudo are verified before anything
is cloned.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The strict NOPASSWD precondition was wrong for the account operators will
actually reach for. Running with --build-user grinder failed on it, and no
amount of interactivity could have fixed that: root running `sudo -u
grinder` needs no password, but the installer's own sudo -- grinder going
back to root -- does, and install-grinder.sh creates that account with
`useradd --system`, which leaves the password locked. There is no password
anyone can type.

So the script now grants passwordless sudo for the duration and removes it
on exit, which it can do because it is root already. That is not a
meaningful escalation: the account is in the docker group, and `docker run
-v /:/host` owns the host, so it could take root whenever it liked.

The grant is validated with `visudo -c` before it counts -- a malformed
drop-in breaks sudo for the whole box, including the root shell that would
have to repair it -- written 0440, and proven to work rather than assumed,
because NSS or a mistyped account name would otherwise surface much later as
the installer hanging on a prompt. Removal is on EXIT, not on the success
path, so a build that dies at Gradle or is interrupted does not leave
permanent passwordless root behind. A drop-in left by a killed run is
refused rather than silently reused.

--no-temp-sudo keeps /etc/sudoers.d untouched. With a TTY it warns and lets
the installer prompt -- useful only for an account that has a password, and
it says so rather than implying the prompt will work for a service account.
Without one it fails as before.

Verified: the flags parse, the usage block renders, and a replica of the
trap ordering shows the drop-in removed when a later step dies, with the
exit status preserved.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Running with --build-user grinder hit the strict NOPASSWD precondition, and
no amount of interactivity could have satisfied it: the password prompt that
appears is the installer's own sudo going back to root, and
install-grinder.sh creates that account with `useradd --system`, which
locks the password. There is nothing anyone could type.

The script now grants passwordless sudo for the duration and removes it on
exit -- something it can do because it is root already, and something that
costs nothing in privilege terms because the account is in the
root-equivalent docker group either way. Validated with visudo before it
counts, proven to work rather than assumed, and removed on EXIT so an
interrupted build leaves nothing behind.

--no-temp-sudo keeps /etc/sudoers.d untouched and, with a TTY, lets the
installer prompt -- while saying plainly that a service account has no
password for that prompt.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Red: planManifestDependency does not exist yet.

Observed live on CurseForge/attributefix at Minecraft 1.21.11. Its manifest
declares `Depends on 'fabric-api' (-∞, ∞)`; nothing was staged for it, and
the boot ran anyway. Quilt Loader refused the pack with "AttributeFix
requires any version of fabric-api, which is missing!" and the candidate
wore the verdict -- precisely what the refusal path exists to prevent.

The cause is a contract this file already documents and the code does not
keep: unsatisfied is "platform-declared misses, AND manifest ids the
registry did map and then failed to stage", unmapped is "ids that map to no
project at all". stageManifestDependencies has three failure branches, and
only the download one honours that. The `no usable file` branch files a
mapped, resolved dependency under unmapped, which never refuses.

Pinned as a planner rather than through the boot path: the decision was
spread over three adjacent branches that had already drifted, which is how
this happened, and driving stageManifestDependencies directly needs the full
boot harness. Four guards -- the refusal, both guess-that-missed cases, and
the happy path picking a file that actually fits the pack.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Turns the previous commit's four guards green. Clientside suite 227, 0 failed.

stageManifestDependencies had three failure branches and only the download
one kept the rule the function's own doc states. A dependency that mapped to
a real project, resolved on the platform, and then had no file for this
loader and Minecraft version was filed under `unmapped` -- which never
refuses -- so the boot went ahead without it.

That is what happened to CurseForge/attributefix at Minecraft 1.21.11: its
manifest declares `fabric-api`, nothing was staged, the boot ran, and Quilt
Loader ended it with "AttributeFix requires any version of fabric-api, which
is missing!". The mod was blamed for a pack we assembled incomplete.

The decision now lives in one place, `planManifestDependency`, returning a
ManifestDependencyPlan rather than being spread over branches that can drift
apart again. It is pure -- no network, no disk -- so the rule is testable on
its own; `visited` bookkeeping stays in the loop, claimed before resolution
exactly as before, which keeps it that way.

Not a blanket tightening: an id that maps to nothing, or to a project the
platform does not carry, is still only a guess that missed and still never
refuses. Only "we mapped it, we resolved it, and then we could not stage it"
became fatal, which is the case the split was written for.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Red. Observed live on Modrinth/architectury-api at Minecraft 1.20.4 / Quilt:
"The Minecraft server .JAR is missing (/srv/pack/server.jar)!" followed by
"Missing game jar at". Quilt's launcher aborts before Loader starts, so no
mod is ever loaded -- and it scored off the exit code alone.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Turns the previous commit's guard green. Clientside suite 228, 0 failed.

Joins launchFailureMarkers, whose band is exactly this: the JVM never got as
far as running the server, so nothing about the mod was exercised. Both
spellings are matched -- the shipped template's own "The Minecraft server
.JAR is missing", and Quilt's "Missing game jar at" -- because a pack can
fail on either depending on which layer notices first.

This only stops the mislabelling. The reason the jar goes missing is a
template bug, fixed separately.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Red on the gap; the companion guard for a complete offline pack passes
already, so the fix cannot buy the first by breaking the second.

Observed live 2026-08-30: Modrinth/architectury-api at Minecraft 1.20.4 died
with "Missing game jar at /srv/pack/server.jar" and the mod was blamed. The
vanilla jar is fetched only as a side effect of installing the launcher --
`--download-server` sits inside the branch that runs when
quilt-server-launch.jar is absent -- so a pack that has the launcher and not
the jar never gets one. A restored backup, a half-cleaned directory, or the
grinder's cached loader install all produce exactly that, and Quilt's
launcher refuses to start without it. Fabric is insulated by its improved
launcher; Quilt has no such fallback.

Executed rather than grepped, per the rule these templates already carry:
this fails silently, producing a plausible-looking pack rather than an
error. The stub for downloadIfNotExist mirrors the real contract -- "false"
for a file already present -- because a stub that always downloaded would
never reach the cached-launcher branch this is about.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Turns the previous commit's guard green; the complete-pack guard stays
green, so this is not bought by breaking the happy path. API suite 376,
0 failed.

setupQuilt only ever obtained the vanilla server jar as a side effect of
installing the launcher: --download-server sits inside the branch that runs
when quilt-server-launch.jar is missing. A pack that kept its launcher and
lost the jar therefore never fetched one, and Quilt's launcher refused to
start with "Missing game jar at .../server.jar". That is a restored backup,
a half-cleaned directory, or -- how it was found -- the grinder's cached
loader install, where Modrinth/architectury-api at Minecraft 1.20.4 was
scored against the mod for it. Fabric does not share the hole: its improved
launcher carries the server itself.

The jar is now checked for and fetched independently, and a failure to get
it crashes with a message naming the cause instead of leaving the launcher
to fail three steps later.

Applied to all three shipped templates so they do not drift.

Verification, stated exactly: bash is EXECUTED by the new guards. fish is
syntax-checked with `fish -n` in an alpine container, because fish is not
installed here and the suite skips that check when it is absent. PowerShell
could NOT be parse-checked on this machine -- the amd64 image crashes qemu
on Apple Silicon and Microsoft publishes no arm64 tag -- so it is verified
by inspection only: every helper it calls (DownloadIfNotExists,
RunInstallerJavaCommand, DeleteFileSilently, CrashServer) exists with that
spelling, $QuiltInstallerUrl is in scope, and both `-Not (...)` and
`Test-Path -Path ... -PathType Leaf` are already used in the file. Worth a
CI parse on an amd64 runner.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
merge: two live failures — one dependency-resolution, one not
Some checks failed
Documentation / Writerside webhelp (push) Successful in 2m31s
Continuous / Build JAR (push) Successful in 14m3s
Continuous / Build AppImage (x86_64) (push) Has been cancelled
Continuous / Build AppImage (aarch64) (push) Has been cancelled
Continuous / Build Install4J Media (push) Has been cancelled
Continuous / Continuous Pre-Release (push) Has been cancelled
Documentation / Help image (push) Has been cancelled
Qodana / notify (push) Has been cancelled
Docker Test / build image (push) Has been cancelled
Test / build (push) Has been cancelled
Qodana / scan (push) Has been cancelled
1d342eb51a
From two boot logs on the running grinder. They looked like one problem and
were three.

CurseForge/attributefix at Minecraft 1.21.11 IS dependency resolution. Its
manifest declares `fabric-api`, nothing was staged for it, the boot ran
anyway, and Quilt Loader ended it with "AttributeFix requires any version of
fabric-api, which is missing!" -- the candidate wearing a verdict earned by
an incomplete pack. stageManifestDependencies has three failure branches and
only the download one kept the rule the function documents: a dependency we
mapped to a real project, resolved, and then could not stage is a case we
chose to trust, so failing it must refuse. "Resolved but nothing usable" was
filed under unmapped, which never refuses. The decision now lives in one
pure planner instead of three branches that had already drifted.

Modrinth/architectury-api at Minecraft 1.20.4 is NOT dependency resolution.
It died on "Missing game jar at /srv/pack/server.jar": setupQuilt fetches
the vanilla server jar only as a side effect of installing the launcher, so
a pack that kept its launcher and lost the jar never gets one. Fixed in all
three templates, and pinned by executing the bash one. The classifier also
learned that a pack with no game jar is a launch failure -- no Loader, no
mods, nothing exercised -- so it can never again be scored as the mod's
crash.

Full build green: 1163 tests, 0 failed, 29 skipped.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
install-grinder.sh --clear deletes the daemon's data directory before
installing; update-grinder.sh --clear forwards it. The goal is a fresh
start: no verdicts, the crawl back at its head, nothing carried over.

It removes SPC_GRINDER_HOME -- /home/grinder/.spc-grinder in the shipped
unit, $HOME/.spc-grinder in GrinderConfiguration.defaultHome -- which holds
the verdict store, cursors, the re-grind queue, kept boot logs and the
loader cache. The cache is the expensive half to rebuild, at ~150 MB per
loader/Minecraft tuple, so the step names each artefact it found rather than
reporting "the data" as one thing.

Placement is load-bearing: it runs AFTER step 3 has stopped the service.
Verdict writes are coalesced and the shutdown hook flushes them, so clearing
a running daemon would just get the store written back out of memory as it
stops -- the one ordering that makes this silently not work.

Guarded before anything is built, on the same shape rule PREFIX already
carries: absolute, at least two components deep, and never the account's own
home or the install prefix. A SPC_GRINDER_HOME of /home/grinder would
otherwise take the dotfiles, ~/.gradle and the Playwright browsers with it;
one of /opt/spc-grinder would delete the binaries just installed.

Verified by executing the real block -- extracted from the shipped script,
run against a fake tree with sudo stubbed: it reports the size, names only
the artefacts actually present, removes the data directory and leaves the
account home standing; the absent-directory branch says so and exits 0. The
four guard cases are exercised in isolation.

Also carries Griefed's change of the update script's default build user from
spcbuild to grinder, which was uncommitted in the working tree.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
merge: a --clear flag for installing onto a clean slate
All checks were successful
Documentation / Writerside webhelp (push) Successful in 1m31s
Continuous / Build JAR (push) Successful in 13m33s
Qodana / scan (push) Successful in 13m24s
Docker Test / build image (push) Successful in 19m46s
Continuous / Build AppImage (x86_64) (push) Successful in 3m13s
Documentation / Help image (push) Successful in 6m56s
Continuous / Build AppImage (aarch64) (push) Successful in 3m27s
Qodana / notify (push) Successful in 24s
Test / build (push) Successful in 18m34s
Continuous / Build Install4J Media (push) Successful in 11m8s
Continuous / Continuous Pre-Release (push) Successful in 6m49s
18cb636260
update-grinder.sh --clear forwards to install-grinder.sh --clear, which
deletes the daemon's data directory before installing: the verdict store,
the crawl cursors, the re-grind queue, the kept boot logs and the loader
cache. Binaries are untouched -- this is about state, not code.

Two things make it safe rather than merely blunt. It runs after the service
has been stopped, which is load-bearing: verdict writes are coalesced and
the shutdown hook flushes them, so clearing a running daemon would only get
the store written back out of memory as it stops. And the path is guarded on
its shape before anything is built, refusing a SPC_GRINDER_HOME that
resolves to the account's whole home or to the install prefix.

Verified by executing the real block against a fake tree with sudo stubbed,
rather than by reading it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Without it the script is an update script and presumes what a first install
leaves behind. The first thing it hits is a chicken-and-egg: it needs the
build account to exist, and the account is created by install-grinder.sh
step 4, which it dies before reaching.

--bootstrap folds three of the five first-install gaps into this script. It
creates the build account when missing -- as a system account with a nologin
shell, the way install-grinder.sh creates the service account, which costs
nothing here because this script runs `bash -lc` rather than `sudo -i`. It
adds that account to the docker group. It passes --install-unit, and
`systemctl enable --now`s the service at the end, so a bootstrapped host
comes back after a reboot.

It grants the docker group ONLY to an account it created. That group is
root-equivalent, install-grinder.sh already refuses to grant it to an
account it did not create, and bootstrapping is not a reason to be less
careful than the script it calls; a pre-existing account is refused with the
usermod line to run deliberately.

Two checks moved earlier or added outright, both because they used to
surface far from their cause:

- Docker's presence and daemon are checked here, not left to the installer,
  which only reaches its own docker checks after this script has wiped the
  checkout and cloned again.
- The BUILD JDK is now checked at all. Nothing did. install-grinder.sh's JVM
  step asks whether systemd will find a java for the SERVICE, which is a
  different question answered much later, so a host with no JDK failed
  inside Gradle talking about JAVA_HOME and nothing about how it got there.

It deliberately installs no packages -- apt/dnf/pacman differ, Docker's
convenience script is its own decision, and installing a container runtime
unattended is a bigger step than an update script should take. Both missing
prerequisites are named with the command to fix them.

The header now carries the whole first-install-vs-update explanation, so the
script answers "can I just download this and run it?" on its own.

Verified: flags parse, --help renders the block, the --install-unit
forwarding neither aborts on an empty array under `set -u` nor duplicates an
argument the caller already passed, and the docker-group decision refuses
for a pre-existing account in both bootstrap and non-bootstrap modes.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Answers "can I download this to a fresh host and run it?" -- which was no,
and the first blocker was a chicken-and-egg: the script needs the build
account to exist, while the account is created by install-grinder.sh step 4,
which it dies before reaching.

--bootstrap creates the build account, adds it to the docker group, passes
--install-unit and enables and starts the service. It grants the docker
group only to an account it created, matching install-grinder.sh's refusal
to hand root-equivalent privilege to an account it did not make.

Two prerequisites that used to surface far from their cause are now checked
up front: Docker's presence and daemon here rather than after a clone, and
the BUILD JDK at all -- nothing checked it, because the installer's JVM step
asks a different question about the service's java, so a host with no JDK
failed inside Gradle.

No packages are installed; both are named with the command to fix them.

The header carries the full first-install-vs-update explanation so the
script answers the question on its own.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Red: installUnavailableMessage does not exist yet.

Reported by Griefed 2026-08-30, reading "Pack post-processing failed: No
cached loader install for Forge 61.2.1 / Minecraft 1.21.11" and reasonably
asking why the grinder does not just install it. It does.
LoaderCache.ensureInstalled runs the installer on a miss; it returns null
only when the install FAILED, or when the tuple is on cooldown after failing
recently. The message named a cache miss, which is the one thing that cannot
be the cause -- a miss is what triggers an install -- and threw away the
reason the cache already knew.

The distinction has to reach the verdict, not just a log line: this string
becomes the verdict detail, and the cooldown path logs at DEBUG, so at
default levels the verdict is the only place an operator can see the cause
at all.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Turns the previous commit's three guards green. Grinder suite 410, 0 failed.

"No cached loader install for <tuple>" named a cache miss, and a miss is
precisely what triggers an install. ensureInstalled returns null only after
an install failed, or while a recent failure is on cooldown, so the message
described the one state that cannot be the cause and discarded the one the
cache knew.

The cooldown is asked for AFTER the null rather than before: ensureInstalled
may have recorded the failure that starts the cooldown during that very
call, and the operator wants the state that resulted.

No behaviour changes -- installs were always attempted on a miss. What
changes is that the verdict detail now distinguishes "the install failed"
from "a recent failure is still on cooldown, so it was not retried", and
says in both cases that this says nothing about the mod. That second half
matters because the cooldown path logs at DEBUG: at default levels the
verdict was the only surface carrying the cause, and it was carrying the
wrong one.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
merge: an unavailable loader install now says why
All checks were successful
Documentation / Writerside webhelp (push) Successful in 2m34s
Qodana / scan (push) Successful in 11m6s
Continuous / Build JAR (push) Successful in 14m0s
Docker Test / build image (push) Successful in 18m50s
Qodana / notify (push) Successful in 20s
Documentation / Help image (push) Successful in 6m31s
Continuous / Build AppImage (x86_64) (push) Successful in 3m9s
Continuous / Build AppImage (aarch64) (push) Successful in 3m40s
Test / build (push) Successful in 19m19s
Continuous / Build Install4J Media (push) Successful in 14m25s
Continuous / Continuous Pre-Release (push) Successful in 7m18s
2df7be3163
"No cached loader install for Forge 61.2.1 / Minecraft 1.21.11" described a
cache miss, which is the one state that cannot be the cause: a miss is what
makes LoaderCache install. The null comes from a failed install, or from a
recent failure still on cooldown -- and the message threw that away.

No behaviour change; installs were always attempted on a miss. The verdict
detail now distinguishes the two, and says in both cases that it says
nothing about the mod. That matters because the cooldown path logs at DEBUG,
so the verdict was the only surface carrying a cause at all.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`6de769d05 refactor(grinder): extract the configuration and the sweep out of
main` removed 239 lines from GrindPoolShutdownTest and left an empty class:
a licence header, ten unused imports, a KDoc still claiming to pin what
`systemctl stop` must do, and `{ }`.

Three of its eight guards were re-homed to the GrindLoopTest that commit
added -- they covered the sweep, which moved. Five covered GrindPool itself,
which did not move, and they were dropped:

  interruptsAWorkerParkedInABootRatherThanWaitingOutItsBudget
  givesUpAfterTheGraceWindowWhenAWorkerWillNotQuit
  stopsWorkersTakingFurtherCandidates
  tracksEveryWorkerBeforeAnyOfThemCanRun
  neverReportsACleanStopWhileAWorkerIsStillRunning

After that, awaitStop's grace window was executed by no test at all; the
only references left were ShutdownWiringTest's source-greps of main's body,
which assert the call is written rather than that it behaves. The path is
the one this module's CLAUDE.md calls load-bearing -- containers belong to
the docker daemon's cgroup, so the hook is the only thing that can stop
them, and TimeoutStopSec is sized against exactly this window.

The loss was silent: a class with no @Test emits no TEST-*.xml, so the
grinder's total went 344 -> 351 -> 410 across the range with five guards
leaving and no count moving.

Restored verbatim from 6de769d05^, minus the three that legitimately moved,
and minus the dead imports. All five pass unchanged against today's
GrindPool, so the deletion cost coverage but concealed no regression.

Teeth checked by mutation rather than assumed. Mutating requestStop() to a
no-op changes nothing -- awaitStop sets stopRequested itself, so that
mutation is vacuous, not the guards. Mutating awaitStop to neither flag the
stop nor interrupt fails exactly the two that depend on it
(interruptsAWorkerParked..., stopsWorkersTakingFurtherCandidates); the other
three hold distinct mechanisms -- grace-window expiry against a stubborn
worker, worker tracking, and the return value not lying -- which that
mutation does not reach.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Audit iteration 30 filed this as "nothing in the build or CI runs a clean
compile". That was wrong, and checking it mattered: test.yml runs on every
push and builds on a fresh runner, so the gate exists.

It also fired. Forgejo runs #301 (592f1ce21) and #306 (388b6e3a2) both went
failure on develop and stayed that way for about a week. Verified locally
that 388b6e3a2's test tree genuinely does not compile -- two anonymous
ModPlatform implementations -- so the red was real rather than flaky.

The half that is worth writing down is why it stayed invisible here: a plain
build of that same commit reports BUILD SUCCESSFUL in 5s off the build
cache, and only --rerun-tasks makes it fail in 21s. So every local full
build agreed with itself and disagreed with CI.

No workflow change: adding a second clean-compile step would not have helped
when the first one's failure was not read.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
api 376, clientside 228, grinder 415 (was 410 — the five guards audit H1
put back). Read from <module>/build/test-results/test/*.xml after a full
build: 1171 tests, 0 failed, 29 skipped.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
H1 closed and verified: five guards restored, file emits a TEST xml again,
awaitStop executed at five call sites, teeth confirmed by a mutation that
was itself checked for meaning.

M3 recorded as wrong: CI has the gate and it fired -- runs #301 and #306
both failed on develop and went unactioned -- with the local build-cache
masking (5s SUCCESS vs 21s FAILED) as the reason it stayed invisible here.

Iteration 31 finds no HIGH; M2 carried forward as unfixable process
history, one LOW noted and deliberately not fixed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Iteration 30 audited the 108 commits since iteration 29 and found one HIGH:
`6de769d05 refactor(grinder): extract the configuration and the sweep out of
main` deleted five executing shutdown guards and left GrindPoolShutdownTest
an empty class whose KDoc still claimed to pin them. After it,
GrindPool.awaitStop's grace window was executed by no test at all -- only
source-greps of main's text remained -- on a path this module's own docs
call load-bearing. The loss was silent: a class with no @Test emits no
result XML, so the grinder's total moved 344 -> 351 -> 410 with five guards
leaving and no count changing.

Restored verbatim, minus the three that legitimately moved to GrindLoopTest.
All five pass unchanged, so the deletion cost coverage but concealed no
regression. Teeth confirmed by mutation -- and the first mutation was the
wrong one, which is recorded, because "survived a mutation" means nothing
until the mutation is shown to be meaningful.

The audit's own M3 was wrong and correcting it was worth more than the fix
would have been: CI does run a clean compile on every push, it did catch
this, and runs #301 and #306 sat red on develop for about a week. Locally it
stayed invisible because a plain build of the same commit succeeds in 5s off
the build cache while --rerun-tasks fails it in 21s. No workflow change --
a second gate cannot help when the first one's red is not read.

Iteration 31 re-audits the fixes: no HIGH, one carried-forward MEDIUM that
merged history makes unfixable, one LOW deliberately left.

Full build green: 1171 tests, 0 failed, 29 skipped.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Red: Unresolved reference 'BootDecision' / 'decidedBy' -- neither exists.

Sampled from the deployed grinder on 2026-08-31. Four of five published
boot logs were scored CRASHED by the bare exit-code rung -- eligible for
a clientside HIGH -- on no sideness evidence at all, and create_ltab was
already in the served fallback list because of it:

  create_ltab  Fabric/mc1.20.6  mixin @Inject found no target      -> CRASHED
  debugify     Forge/mc1.19.1   @Shadow field f_25782_ not located -> CRASHED
  create_ltab  Quilt/mc1.21.1   Quilt "Unhandled solver error"     -> CRASHED
  damagevignette NeoForge/1.20.4 "Missing language javafml [46,)"  -> CRASHED
  debugify     Fabric/mc26.2    "requires any version of ..."      -> INCONCLUSIVE

The root problem is that CRASHED is reachable two ways -- the
client-only-class marker, which no environment failure can fabricate, and
the exit-code fallback, which means only "exited non-zero, nothing
recognised why" -- and the two were indistinguishable afterwards. So the
guards pin a BootDecision naming the rung, with exactly two members
marked decisive: CLIENT_ONLY_CLASS and OPERATOR_RULE (a rule reaching
CRASHED stated it deliberately; an undecided rule resolves to the ladder
or to INCONCLUSIVE, never to CRASHED).

Three guards carry the most weight:

- anUndecidedRuleRidingAlongDoesNotClaimTheDecision -- a note-only rule
  must not launder a bare exit-code crash into decisive evidence.
- aClientOnlyClassInsideAMixinFailureIsStillDecisive -- a mod reaching a
  client class *through* a mixin is a genuine signal, so the new markers
  must stay below the client-class marker. Getting this backwards
  discards true positives, the expensive direction.
- theFabricDependencyFailureIsStillADependencyFailure -- the control. The
  one log already classified correctly must stay that way, or the fix has
  moved the problem rather than solved it.

FallbackPropertiesPublicationGateTest pins that HIGH alone stops being
sufficient, including that a legacy verdict with no recorded decision does
NOT publish -- which deliberately empties the grinder's 34 contributed
entries until a sweep re-grinds them.

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>
Red: Unresolved reference 'minecraftConstraint'.

Every scanner already parses this and throws it away -- Fabric and Quilt
exclude `minecraft` from dependencies as "the platform", the Forge and
NeoForge tomls route the platform entry into the sideness inference -- so
nothing downstream could ask the one question that separates a real
clientside crash from a jar booted on the wrong Minecraft.

That question is not academic. The grinder boots the NEWEST Minecraft a
platform declares for a file without ever asking what the jar was built
for, and Modrinth applies a version node's game_versions to every file of
that version. Measured 2026-08-31, that put create_ltab on 1.20.6 with
1.20.5-era mappings and debugify on 1.19.1 with another 1.19.x's; both
mixin failures were scored CRASHED, and one of the two mods is in the
served fallback list.

forgeReportsTheMinecraftRangeWithoutLosingItsSideness is the one to watch:
the same [[dependencies]] entry carries the versionRange being captured
AND the side= that decides the mod's own sideness, so capturing must not
disturb the inference. theMinecraftEntryIsStillNotADependency pins that
this is additive rather than a change to what dependencies already
returns.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Turns the -api guard green and closes the root cause behind three of the
five real logs.

BootCandidateSelector trusts the platform absolutely. It boots the NEWEST
Minecraft in a file's declared set, and Modrinth applies a version node's
game_versions to EVERY file of that version -- so a mod ticked for
[1.20.4, 1.20.5, 1.20.6] is booted on 1.20.6 whatever it was compiled
against. Where one file claims two loaders (a Modrinth version tagged
[forge, neoforge] with two primary jars, or one CurseForge upload tagged
["1.20.4","Forge","NeoForge"]), the stable sort hands the NeoForge attempt
whichever jar the platform listed first.

Measured 2026-08-31: create_ltab on 1.20.6 with older mappings, debugify
on 1.19.1 with another 1.19.x's, and DamageVignette-2.0.2-forge+mc1.20.jar
under NeoForge 20.4.251. All three died, all three were scored as sideness
evidence, and create_ltab is in the served fallback list because of it.

The jar's own descriptor is the authority the platform metadata is not:

- -api: ScannedMod.minecraftConstraint. Every scanner already parsed this
  and discarded it -- Fabric and Quilt exclude `minecraft` as "the
  platform", the Forge/NeoForge tomls consume the platform entry for its
  `side`. Purely additive; the platform entries still stay out of
  `dependencies`, and the Forge reader is separate from
  getSidenessesAndDependencies precisely so capturing the range cannot
  disturb the sideness that same entry decides.
- -clientside: JarSelfDeclaration compares the descriptors a jar carries,
  and its declared Minecraft range, against what is about to be booted.
  Hooked into stageBootPack before generateServerPack, so a contradiction
  costs no container at all.

FAIL TOWARD ACCEPT is the whole design, not a detail. An unreadable jar,
a missing descriptor, an unparseable constraint, an unrecognised loader, a
scan that throws -- every one boots. Only a positive, readable
contradiction refuses. A gate that refused on doubt would turn a
descriptor gap into a catalog-wide mass-INCONCLUSIVE event, which is the
shape VersionConstraint's own doc warns about and the shape a
LoaderSupportMemory once produced by marking Fabric unusable for 22
Minecraft versions inside minutes. everythingUnreadableIsAccepted sweeps
five broken jars x six loaders and anUnreadableMinecraftConstraintIsAccepted
ten malformed ranges; not one refuses.

The Quilt -> Fabric acceptance mirrors BootCandidateSelector.fallbackLoaders
and stays one-way: Quilt runs Fabric mods, Fabric cannot load a Quilt mod.

Two mistakes of mine, recorded because both are easy to repeat:
- forgeReportsTheMinecraftRangeWithoutLosingItsSideness first declared
  `minecraft` side=CLIENT AND `forge` side=BOTH, then asserted CLIENT.
  sidenessOf is SERVER unless EVERY signal says CLIENT, so the fixture was
  wrong, not the scanner. It now carries one platform entry, with a
  comment saying why.
- The new -api test pointed at src/test/resources/serverpackcreator.properties
  and SPC wrote this machine's absolute paths into the committed file --
  every other test uses the processed build/resources/test copy for
  exactly that reason. TestPropertiesTest.theCommittedTestPropertiesNameNoHost
  caught it. Reverted, switched, and the reason is at the call site.

api 381, clientside 247, grinder 418 tests, 0 failed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Characterization, and they pass as written -- which is the point: they
prove the defect exists rather than asserting it away.

Every pickBootableCandidate test in this class used a single-element
loaders set AND a single-element minecraftVersions set, so neither live
failure shape was representable, let alone covered. Both are shapes the
platforms produce routinely:

- aFileClaimingTwoLoadersIsOfferedForBoth: a Modrinth version tagged
  [forge, neoforge] with two primary jars gives BOTH ModFiles the same
  two-loader set, because loaders are read from the version node and
  applied to every file of it. The selector then offers either jar for
  either loader and, being a stable sort, takes whichever the platform
  listed first. The assertion records that a NeoForge boot receives
  DamageVignette-2.0.2-FORGE+mc1.20.jar -- exactly what happened on
  2026-08-31, dying on "Missing language javafml version [46,)".
- aFileClaimingSeveralMinecraftVersionsIsBootedOnTheNewest: one jar
  ticked for [1.20.4, 1.20.5, 1.20.6] becomes one candidate per version
  and the newest wins, which put create_ltab on 1.20.6 against older
  mappings.

Neither is a complaint about the selector: it cannot tell two jars apart
from metadata that describes them identically, and it has no notion of
what a jar was compiled for. JarSelfDeclaration is what refuses the pick
afterwards, from the jar's own descriptor, and these two tests name that
counterpart so the pair is legible from either side.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
GrinderAuditIT, gated GRINDER_AUDIT_IT=1, asks one question of the whole
store: is every published HIGH decided by a rung BootDecision.decisive
marks? Failure prints the distribution by decision, so a defect shows up
as a bucket rather than as a surprise; the assertion names the tuples.

It exists because sample-and-fix has failed twice. A 200-log census on
2026-08-29 and a merge gate reporting HIGH 8 -> 4 both preceded the
2026-08-31 finding that four of five sampled logs were scored CRASHED by
the bare exit-code rung, with one of the mods already in the served list.
Neither census was committed -- verified, no such script exists in the
repo or its history -- so nothing re-checked the published list against
the consoles behind it and the loop never closed.

Implementation notes worth keeping:

- /boot-logs is HTML-ONLY. There is no machine-readable listing, so names
  are scraped from its ?name= hrefs, and its links point at the /crash-log
  alias rather than /boot-log.
- A stored name is <tuple>~<attempt>~<artifact>, so the tuple is
  everything before the first '~'. That holds because slugs and loaders
  contain '-' but never '~' -- which is why ATTEMPT_SEPARATOR was chosen.
- It assumes a non-zero exit when re-classifying, deliberately. The exit
  status is not published, and every rung a HIGH can legitimately come
  from is decided on the console alone, so assuming non-zero is what keeps
  the exit-code rung reachable and therefore COUNTED. Assuming zero would
  quietly reclassify the very population being audited.
- Politeness is part of the contract: gated, capped by
  SPC_GRINDER_AUDIT_SAMPLE (200), and a missing artifact is skipped rather
  than failing the audit.

GrindVerdict.decidedBy also becomes a report and CSV column via a new
VerdictField.DECISION, filterable as a CHOICE -- `?f.decision=EXIT_CODE`
is how a maintainer lists every verdict reached because a process exited
non-zero and nothing recognised why.

everyColumnRendersTheValueItsHeaderNames caught the new column
immediately, which is what that sentinel guard is for: it counts cells
against headers, so a column added to one and not the other cannot pass.

Grinder suite: 419 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>
The census is the part worth keeping: 27 of 43 published HIGH verdicts
rest on no decisive evidence, measured against the live grinder on
2026-08-31 with GrinderAuditIT.

Includes the honest cost, because the names make it concrete: sodium-extra
and reeses-sodium-options ARE client-only but crash without decisive
evidence, so the gate drops real findings alongside the false ones. The
rule engine is the recovery path -- a verified signature written as a rule
counts as OPERATOR_RULE, which is decisive.

Also records the three mistakes of mine that existing guards caught, and
the two documentation drifts about the ladder's rung count.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Asked for rules recovering the client-only mods that lack proof. Reading
the actual consoles produced one rule, one refusal to write one, and a
harness defect worth more than either.

THE RULE. sodium-extra, reeses-sodium-options and better-block-entities
all die on `NoClassDefFoundError: org/lwjgl/Version`. LWJGL is the
client's windowing and OpenGL binding and a dedicated server never ships
it, so reaching it is proof the mod cannot run server-side -- decisive in
a way the built-in marker misses, since that one matches only
net/minecraft/client. Shipped in deploy/boot-rules.example.json and
verified against the real excerpts by ShippedBootRulesTest, which also
pins that no shipped rule overturns a ready-line or host trouble.

better-block-entities did not reach LWJGL itself; Sodium, staged as its
required dependency, did. The entry still holds: a mod whose required
dependency cannot run on a server cannot run on one either, and the
evidence is about the pack, which is the candidate plus what it requires.
That reasoning is written into the rule's comment rather than left
implicit.

THE RULE I DID NOT WRITE, and the file says why. better-ping-display,
immersive-ui and certain-questing-additions are all published HIGH and all
die on a missing log4j-core. Writing a rule would have recovered three
entries and been exactly wrong: log4j-core is a LOGGING library the server
is supposed to have.

WHICH LED TO THE REAL FINDING. All three are the same tuple, so I checked
three unrelated mods on it -- bbrb, chisels-bits, corgilib -- and every one
fails identically. corgilib is a library and chisels-bits runs on servers.
The cached loader install for NeoForge 21.11.45 / Minecraft 1.21.11 is
broken, and ALL 90 boots against it are worthless; those reaching a
non-zero exit were published as clientside. One poisoned cache entry
manufacturing false positives across an entire tuple is precisely what a
bare exit-code verdict cannot distinguish from a mod crashing on its own
merits.

So log4j-core joins runtimeMismatchMarkers -- a classifier marker, not a
rule, because it is the absence of evidence rather than evidence. That
converts the whole tuple to INCONCLUSIVE at the classifier rather than
relying on the publication gate to catch it.

ACTION FOR THE OPERATOR, which no code change covers: invalidate that
tuple in the loader cache and re-grind it. Until then those 90 attempts
tell you nothing.

Clientside 250, grinder 425 tests, 0 failed.

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>
Three that were outstanding:

- API-BEHAVIOUR-CHANGES.md gains the row for ScannedMod.minecraftConstraint.
  Additive: dependencies, provides and sideness are byte-for-byte
  unchanged, and the trailing defaulted parameter under the existing
  @JvmOverloads keeps the old JVM descriptors for pre-compiled pf4j
  plugins. -app compiles and its 149 tests pass against it.
- report/CLAUDE.md documents the publication gate where it lives, with the
  27-of-43 measurement, the deliberate emptying of the legacy
  contribution, and the honest note that the gate is conservative rather
  than precise (a rule bought 3, not 30).
- The root refactor-state table carried stale counts (api 376,
  clientside 228, grinder 415). Now api 381, clientside 250, grinder 425,
  each re-derived from a real run rather than from memory -- the grinder
  results directory had been left holding a single test by an earlier
  filtered run, and Gradle then skipped the task as up-to-date, which is
  exactly the trap that column's own note warns about.

The root file also gains the general lesson, since it outlives this
sprint: a verdict that cannot name its own evidence cannot be audited, and
an environment defect looks exactly like a subject defect unless something
distinguishes them. One poisoned loader-cache entry produced identical
failures across all 90 boots against it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
merge: publish only crashes that are decisive evidence of sideness
All checks were successful
Documentation / Writerside webhelp (push) Successful in 3m8s
Continuous / Build JAR (push) Successful in 17m44s
Qodana / scan (push) Successful in 22m22s
Docker Test / build image (push) Successful in 29m9s
Documentation / Help image (push) Successful in 5m57s
Continuous / Build AppImage (x86_64) (push) Successful in 3m7s
Continuous / Build AppImage (aarch64) (push) Successful in 4m9s
Qodana / notify (push) Successful in 1m15s
Test / build (push) Successful in 19m12s
Continuous / Build Install4J Media (push) Successful in 13m14s
Continuous / Continuous Pre-Release (push) Successful in 7m52s
03047a7c04
Five real boot logs from the deployed grinder, four of which were scored
CRASHED by the bare exit-code rung and published as clientside on no
sideness evidence at all. Reading them found two systematic selection
defects, three classifier gaps, and -- via the audit built to grade the
rest -- a poisoned loader-cache entry producing identical failures across
all 90 boots against it.

What lands:

- BootDecision names which of the classifier's rungs settled a boot and
  marks exactly two as decisive: CLIENT_ONLY_CLASS and OPERATOR_RULE.
  /as-properties publishes nothing else, so a mixin that would not apply,
  a solver that gave up, a jar staged for the wrong loader or a bare
  non-zero exit can no longer reach a user's server pack.
- Three marker sets for the shapes the logs exposed, all BELOW the
  client-class marker so a mod reaching a client class through a mixin
  still reads CRASHED.
- JarSelfDeclaration asks the staged jar what it was built for before a
  container is spent, over the new additive ScannedMod.minecraftConstraint
  -- a value every scanner already parsed and discarded. It FAILS TOWARD
  ACCEPT: only a positive, readable contradiction refuses.
- GrinderAuditIT grades a live daemon's published verdicts against their
  own evidence, gated GRINDER_AUDIT_IT=1, because sample-and-fix had
  already failed twice with no committed instrument to close the loop.

Measured against the live store, 43 published HIGH verdicts:
16 defensible before, 19 after the shipped LWJGL rule, 24 still resting on
nothing. The gate is conservative rather than precise -- sodium-extra and
reeses-sodium-options are genuinely client-only but crash without proof --
and a verified rule recovers single digits, not tens.

Operator actions this merge does NOT perform: invalidate the
NeoForge 21.11.45 / MC 1.21.11 loader-cache tuple and re-grind it, and
requeue the verdicts published before the gate. Until a sweep re-grinds,
the grinder's contribution to the fallback list is deliberately empty --
an empty contribution beats a wrong one.

api 381, clientside 250, grinder 425, app 149 tests; 0 failed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Red, and the failure names the defect exactly:

  expected: <bookshelf> but was: <bookshelf-fabric>

i.e. the scan returned the FILE NAME as the mod id, which is
DescriptorScanner's documented fallback for "no descriptor could be
read" -- so the entire Fabric descriptor was discarded, dependencies and
Minecraft constraint with it.

QuiltPackScanner returns the Quilt scanner's results and substitutes the
Fabric one only where the two disagree about sideness (quilt == SERVER &&
fabric == CLIENT). Quilt deliberately runs Fabric mods and most ship no
quilt.mod.json at all, so the Quilt scan yields a default entry -- filename
id, SERVER, empty dependencies -- the Fabric scan yields the real one, both
read SERVER, they agree, and the empty entry wins.

Reported from the live grinder: bookshelf on Quilt 0.31.0-beta.3 /
Minecraft 1.21.1 died with "Bookshelf requires any version of fabric-api,
which is missing!". The dependency was never resolved because the scan
never reported it -- KnownModIds maps `fabric-api` correctly, it was just
never asked.

This is not only a grinder coverage bug. ModListCompiler's dependency
rescue reads the same scan, so a Quilt pack could have Fabric API stripped
while a kept mod depends on it -- the same class as the exclusion bug
fixed in B0, reached by a different route.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Behaviour-preserving, and separated from the change that follows it because the two are different
concerns: this commit makes the fold reachable, the next one alters what it decides.

`aggregate` folds the platform's declared support, the jar scan and the boot result into a
`Confidence`. It was a private instance method, so it could only be exercised through `report()`,
which needs a platform, a downloader and a boot. It is pure and its only collaborator,
`declaresServerSupport`, already lived in the companion — so it moves there wholesale as
`aggregateFor`, and the two in-class call sites become a rename. Three lines that the extra
indentation pushed past the column limit are wrapped.

Not one branch of the ladder changes: CRASHED → HIGH, metadataClient → MEDIUM, metadataServer → LOW,
everything else INCONCLUSIVE, in that order. Verified by diffing the `when` against its previous form.
No test changes, which is the check the conventions ask for on a `refactor:` label.

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

QuiltPackScanner returned the Quilt scan and preferred the Fabric one only
where the two disagreed about SIDENESS. Quilt deliberately runs Fabric mods
and most ship no quilt.mod.json, so the Quilt scan was DescriptorScanner's
"nothing could be read" fallback -- file name as modID, SERVER, empty
dependencies and provides, null minecraftConstraint -- while the Fabric
scan held the real descriptor. Both read SERVER, they agreed, and the empty
entry won. Everything the jar declared was thrown away.

ScannedMod gains descriptorRead, because the fallback is otherwise
INDISTINGUISHABLE BY VALUE from a real scan of a mod that declares nothing:
every field it sets is a value a genuine descriptor could also produce. It
defaults to false -- the fallback's own answer -- so only a scanner that
actually parsed something reports true, and the merge now prefers whichever
scan read a descriptor before it considers sideness at all.

Found from a live grinder boot: bookshelf on Quilt 0.31.0-beta.3 /
Minecraft 1.21.1 died with "Bookshelf requires any version of fabric-api,
which is missing!". KnownModIds maps that id correctly; nothing ever asked
it, because the scan never reported the dependency. The guard's red state
named it precisely -- expected <bookshelf>, got <bookshelf-fabric>, the
file name.

NOT ONLY A GRINDER BUG. ModListCompiler's dependency rescue reads the same
scan, so a Quilt pack could have Fabric API stripped while a kept mod
depends on it -- the same user-facing outcome as the `fabric` exclusion
bug, reached by a different route.

Recorded in API-BEHAVIOUR-CHANGES.md, landmined in the module CLAUDE.md,
and that file's own description of the merge is corrected: it still stated
the rule this commit replaces.

api 382, clientside 250, grinder 425, app 149 tests; 0 failed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Two INCONCLUSIVE populations the live store surfaced on 2026-09-01, both
throwing away something the engine had actually learned. Red:
Unresolved reference 'aggregateFor' / 'downloadFailureDetail'.

A SURVIVED BOOT COUNTED FOR NOTHING. aggregate falls through to
INCONCLUSIVE whenever the metadata says nothing -- and it says nothing
exactly when the jar scan errored AND the platform declares no sideness,
which is every CurseForge project. So the most expensive signal the engine
produces, a server that reached its ready-line, was discarded. Live:
better-stats, tcdcommons and yacl, all JarSideness=ERROR, all
"SURVIVED (exit 137)", all recorded INCONCLUSIVE -- while 2,318 other
survived boots recorded LOW or MEDIUM.

The guards pin the direction as well as the fix: a survived boot must NOT
overturn a client-only declaration (a client mod can start a server
without being any use on one), must not soften a crash, and an
INCONCLUSIVE boot must not be promoted the way a survived one is. Only the
"no metadata at all" case changes.

A REFUSAL THAT NAMES NOTHING. 21 verdicts say only "Could not download
<file>". Every one is CurseForge, and the names -- bwncr, tombstone,
entityculling, moreoverlays -- are the population this module already
documents as distribution-locked, which can only be fetched through the
headless browser. As written those 21 are indistinguishable from a 404 or
a flaky link, so nobody can tell a broken host from a broken mod.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A live grinder boot -- bookshelf on Quilt, "requires any version of
fabric-api, which is missing!" -- traced to QuiltPackScanner discarding the
Fabric descriptor whenever the jar carried no quilt.mod.json, which is most
of them. The Quilt scan was the "nothing could be read" fallback, the
Fabric scan was real, the merge compared only sideness, both said SERVER,
and the empty entry won.

ScannedMod.descriptorRead makes the fallback distinguishable, since by
value it is not. Also fixes the same loss in ModListCompiler's dependency
rescue, where a Quilt pack could have Fabric API stripped from it.

api 382, clientside 250, grinder 425, app 149 tests; 0 failed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Both come out of the live store's INCONCLUSIVE rows (2772 verdicts, read 2026-09-01).

**A survived boot was being discarded.** `aggregate` folds the platform's declared support, the
jar scan and the boot into a confidence, and it consulted `bootResult` only for CRASHED. So with
the jar scan errored and the platform declaring nothing -- which is *every* CurseForge project,
since CurseForge has no sideness field -- there was no metadata to fall back to and the most
expensive signal this engine produces landed in INCONCLUSIVE, meaning "we learned nothing", when
what it had learned was that the server started. Three live rows say exactly that: Modrinth
better-stats, Modrinth tcdcommons and CurseForge yacl, all JarSideness=ERROR, all decided
READY_LINE, all reading `SURVIVED (exit 137)` in their own detail column.

SURVIVED now yields LOW, placed *below* metadataClient in the same `when`, so the documented
asymmetry is untouched: a clean boot still cannot overturn a client-only declaration, because a
client mod can start a server without being any use on one. Only its absence of standing changed,
not its rank. `aCrashStillOutranksEverything` and
`aSurvivedBootDoesNotOverturnAClientOnlyDeclaration` pin both edges.

Exit 137 on those rows is normal and not a kill to investigate -- `ContainerServerRunner` stops
the container the moment the ready-line appears -- which is why the classifier reads them
SURVIVED and only the fold disagreed.

**A refusal now names its cause.** 21 verdicts said only `Could not download <file>`. Every one
is CurseForge, and the names -- bwncr, tombstone, entityculling, moreoverlays -- are the
population this module documents as distribution-locked: `allowModDistribution=false`, so
`downloadUrl` is null and the fetch can only go through the headless browser. Read as written
those 21 are indistinguishable from a 404 or a flaky link, so a broken *host* and a broken *mod*
produced the same sentence. `downloadFailureDetail` names the lock and the Playwright/Chromium
prerequisite it needs; an ordinary file's failure deliberately does not mention the browser, or
it would send an operator the wrong way.

The fold was moved into the companion as `aggregateFor` by the preceding `refactor(clientside)`
commit, deliberately kept apart: that one makes it reachable without a platform or a boot, this one
changes what it decides.

Suites: clientside 257, api 382 (1 skip), grinder 425 (29 skip), app 149 -- all green.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The confidence-model paragraph said only that a clean boot is not decisive, which was true and
was being read as "not evidence" — the exact conflation the fix removes. It now states both: the
asymmetry is unchanged (SURVIVED ranks below metadataClient, so it cannot overturn a client-only
declaration) and the boot is no longer discarded when there is no metadata to fall back to.

The browser-download landmine gains the refusal-detail half, and the clientside test count in the
root table goes 250 → 257, re-derived from build/test-results after the run.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`install-grinder.sh` detected a missing Chromium and printed the commands; it now runs them. Also
adds `--skip-browser` for a Modrinth-only or offline host, forwarded from `update-grinder.sh` by its
existing `--` passthrough, so the upgrade path inherits this with no change there.

**The browser is not the half that was missing, and the old section said it was.** Playwright's Java
binding downloads browsers itself on the first `Playwright.create()` — `DriverJar.installBrowsers()`,
with `PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD` to suppress it and "Failed to install browsers, exit code:"
when it cannot (read out of driver-1.62.0.jar; the Java docs do not state this and imply the
opposite). So a host that never ran an install still gets a browser. What nothing installs is the OS
libraries Chromium links against, absent by default on a headless server, and without them Chromium
launches and every navigation times out — which reads as CurseForge being slow. That is the shape of
the gap, and `clientside-boot.yml`'s reusable job corroborates it: for the same code path it installs
`install-deps chromium` and nothing else.

Both are now done. Pre-installing the browser is still worth it even though it self-installs: the
lazy download otherwise happens mid-grind, needs network at an arbitrary later moment, and its
failure surfaces as a staging failure on some mod rather than as anything about a browser. Neither
step is fatal — the daemon can still do the browser itself, and Playwright can only install deps on
Debian/Ubuntu, so killing a deployment over either would be wrong.

**Installed with Playwright's own CLI from our installed jars, never `npx playwright install`.**
Playwright pins one Chromium build per release and looks for that exact directory: 1.62.0 wants
`chromium-1234` (Chrome for Testing 151.0.7922.34, from driver-1.62.0.jar's browsers.json). `npx`
fetches whatever the npm package pins, landing beside it. That is not theoretical — this developer's
own machine holds `chromium-1223`, a different revision from another Playwright version, and the
check being replaced globbed `chromium-*`, so it would have reported success on a cache the binding
would then have ignored. Driving `com.microsoft.playwright.CLI` off `$PREFIX/lib/*` makes the version
match by construction, and makes a Playwright bump self-correct on the next upgrade rather than go
stale.

Verified, since no harness covers deploy shell scripts (the ceiling this repo set for buildSrc):
`bash -n` clean; `--help` renders the new flag and `--nonsense` is still rejected; and
`java -cp "lib/*" com.microsoft.playwright.CLI` against the real installed dist prints usage listing
both `install [browser...]` and `install-deps [browser...]`, which is the claim the change rests on.
The install itself was not run here — it would pull ~170 MB onto a machine that did not ask for it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Three commits: the red pin, the fix, the documentation. Both findings came out of reading the
live store's INCONCLUSIVE verdicts rather than from a failing test — a survived boot that had
no metadata to fall back to scored 'we learned nothing', and 21 refused downloads all said the
same sentence whether the host or the mod was broken.

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>
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Red: `StatusDashboardRenderer` does not exist yet.

Six guards, and the first is the one worth having. A static HTML page cannot fail a build, so the
failure that actually threatens this feature is not a 404 — it is a field being renamed on the server
and the page quietly rendering nothing while still returning 200. So the page declares what it reads
(`READ_FIELDS`), one guard resolves every one of those paths against the document a real
`ReportServer` serves over a real socket with every optional collaborator wired, and a second stops
the declaration drifting from the markup that consumes it.

Every optional collaborator really is wired — status, cursors, cache root and console rules — and
that is load-bearing rather than thoroughness: a field path that cannot resolve for want of a
collaborator is a red no implementation could ever turn green, so the pin would fail for its own
reasons and prove nothing.

The rest pin the properties that make it worth building this way at all: it polls; it loads nothing
off the network, so a browser reaching a loopback-bound report through a reverse proxy still gets a
working page; and it is a constant carrying no store data, which is how a page on an unauthenticated
server displaying internet-supplied slugs avoids escaping bugs entirely — every value is inserted
client-side as text. That one is pinned by serving it against a slug of `<script>alert(1)</script>`.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`/dashboard` renders the status document for a human and polls itself: current pass, what each worker
holds and for how long, crawl position per platform, loader-cache size, and boot-rule errors — with
durations as `2d 3h 2m` rather than `183742`. Interval selectable (2/5/15/60s) and pausable, and it
says the daemon is unreachable rather than freezing on stale numbers, since a dashboard silently
showing the last good poll is worse than none when the daemon is the thing that stopped.

No new dependency: the JDK's HTTP server it already uses, and vanilla JS. Asserted, not intended —
one guard fails on any `src`/`href` pointing off this server, because the report is documented as
loopback-bound behind a reverse proxy and a browser reaching it may have no route to a CDN at all.

**A second route, not content negotiation on `/status`.** That endpoint is scripted against; handing
a machine reader HTML because an `Accept` header looked browser-shaped would break what it is for.
`/status` is byte-for-byte unchanged.

**The page is a constant, which is a security property rather than a shortcut.** This server has no
authentication and displays internet-supplied mod slugs. Nothing is interpolated server-side, so
there is no escaping to get wrong: every value arrives as JSON and is written with `textContent`.
Pinned by serving it against a slug of `<script>alert(1)</script>` and asserting the bytes match what
the renderer produces with no store in sight.

Two guards exist because a string constant in a Kotlin file has none by default.

`READ_FIELDS` declares every field the page reads, resolved against a document a real `ReportServer`
serves over a real socket. This is not redundant with the compiler: `statusJson()` builds its
document from **string-literal keys nothing type-checks**, so renaming `"loaderCache"` compiles clean
and silently blanks a panel. Verified to have teeth — with that key renamed it is the only failure in
all 434 grinder tests. A Kotlin *property* rename is already caught by `GrinderStatusTest` at compile
time; this covers the untyped half.

A second guard, which executes the page's JavaScript under node, follows in its own commit — it goes
red on arrival, and the fix after it is what turns it green.

Grinder suite 425 → 430, 29 skipped, all green. Not visually verified: the Chrome extension is not
connected here, so the rendering claim rests on the served-page guards.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Red: `theLinkHelperOnlyAcceptsAbsoluteHttpUrls` fails.

The page is a string constant in a Kotlin file, so nothing compiles it and a typo ships a dashboard
that loads, polls, returns 200 and renders nothing. That is the same silent-failure class the shipped
shell templates have, and it gets the same treatment `ScriptTemplateContentTest` gives them: run the
real interpreter when the host has one, skip when it does not, so CI never needs the toolchain.

Two helpers are pure and carry the logic worth pinning — `duration`, which is the whole "human
readable" claim, and `safeHref`, the page's only attribute sink. The harness lifts them out of the
shipped page rather than copying them, because a copy would pass while the page was broken.

`safeHref` goes red immediately, which is why this is its own commit: parsed against
`window.location.origin`, a null or unparseable `projectUrl` resolves to a same-origin link like
`<report>/null` — a row that renders something looking like a project link and 404s on the report
itself. The guard also pins the injection cases (`javascript:`, `data:`, `vbscript:`) that already
pass, so the fix cannot trade one for the other.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Red: four new guards fail.

A descriptor depends on Fabric API by naming one of its ~45 *modules* —
`fabric-resource-loader-v0`, `fabric-block-getter-api-v2`, `fabric-rendering-fluids-v1` — and neither
platform has a project under any of those names. Modrinth's slug guess 404s, CurseForge refuses to
guess at all, so the single most common dependency in the Fabric ecosystem goes unstaged. The mod
then boots without it, its loader refuses the pack, and the candidate wears an INCONCLUSIVE for a
dependency the harness never supplied. Same shape as the Quilt solver failure already on record:
`fabric-resource-loader-v0 versions [*] (0 valid options, 0 invalid options)`.

The guards also pin why this has to be a rule rather than a table. The version suffix moves: the
current source tree ships `fabric-resource-loader-v1` and `fabric-block-getter-api-v2`, while the
corpus is full of older mods declaring `-v0` — ids that exist in no current tree — so a snapshot of
today's module list would be wrong for precisely the historical mods this grinder spends its time on.

A third guard pins that the many modules of one project collapse to a **single** staged dependency.
Not hypothetical bookkeeping: a typical mod names five or eight of them, and one jar reachable under
several names is the B6 shape that double-counted toward MAX_INJECTED_DEPENDENCIES and refused packs
which were within the cap, scoring them INCONCLUSIVE.

And the collision that stops a bare pattern being right: lucko's `fabric-permissions-api-v0` (plural)
matches the module shape exactly while being a separate project, verified against its own
`fabric.mod.json`, whereas Fabric API's `fabric-permission-api-v1` (singular) is one character away
and must still resolve. `fabric-language-kotlin` covers the other direction — a `fabric-` prefix with
no version suffix is its own project and keeps the plain slug guess.

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>
Reported live 2026-09-01: `fabric-resource-loader-v*`, `fabric-block-getter-api-v*` and
`fabric-rendering-fluids-v*` all going unresolved. They are not projects — they are modules of Fabric
API, which ships as ~45 nested jars, and a descriptor depends on the modules rather than on the
project. Neither platform publishes them separately, so Modrinth's slug guess 404s and CurseForge
refuses to guess: the single most common dependency in the Fabric ecosystem was never staged. The mod
then booted without it, its loader refused the pack, and the *candidate* wore the INCONCLUSIVE for a
dependency the harness never supplied. It is the same shape as the Quilt solver failure already on
record — `fabric-resource-loader-v0 versions [*] (0 valid options, 0 invalid options)`.

`KnownModIds` now resolves them to `fabric-api` / `306612` on the two platforms.

**A rule rather than a table, which is the whole design decision.** The API-version suffix moves
between releases: `FabricMC/fabric` today ships `fabric-resource-loader-v1` and
`fabric-block-getter-api-v2`, while the corpus is full of older mods declaring `-v0` — ids that exist
in no source tree now. A list snapshotted from the repository would therefore be wrong for precisely
the historical mods this is meant to fix, which is a sharper version of the "un-pinned data that goes
stale in silence" the class doc already warns about. The stable thing is the shape.

Measured against the 46 `fabric-*` directories of `FabricMC/fabric`: the rule matches 44. The two it
does not are `fabric-api-bom` and `fabric-api-catalog`, a Gradle BOM and a version catalog — build
artifacts no mod can depend on, so excluding them is correct. `fabric-api-base` and
`fabric-renderer-indigo` carry no version suffix and are named explicitly.

**One exclusion, and it is why a bare pattern would be wrong.** lucko's `fabric-permissions-api-v0`
(plural) matches the module shape exactly and is a separate project, while Fabric API's own
`fabric-permission-api-v1` (singular) is one character away and must still resolve — verified against
lucko's `fabric.mod.json`. Claiming it would stage Fabric API in place of the library the mod asked
for and report a dependency it never declared. `notFabricApi` stays limited to ids observed
colliding; guessing at more would rebuild the table this class exists to avoid.

Suites: clientside 257 → 263, api 382 (1 skip), grinder 434 (29 skip), app 149 — all green.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Red: `qslModulesResolveToQslOnBothPlatforms` and `theFabricAndQuiltFamiliesStaySeparate` fail.
`theQuiltLoaderItselfIsNotAQslModule` passes already and is here to stay passing.

Closes the observation audit iteration 32 recorded as unverified. It is now verified: reading all 47
`quilt.mod.json` files in `QuiltMC/quilt-standard-libraries` (branch 1.21.5) and collecting their
`depends` entries yields **33 distinct `quilt_*` module ids** — `quilt_resource_loader`,
`quilt_networking`, `quilt_registry` and so on, with `quilt_resource_loader_testmod` declaring
`["quilt_loader", "quilt_resource_loader"]`. A Quilt descriptor depends on the modules, never on the
project, and neither platform publishes them, so each one fell through to a Modrinth slug guess that
404s and to nothing at all on CurseForge — the same unstaged-dependency failure
`fabric-resource-loader-v0` was producing before the Fabric fix.

**The shape is not Fabric's, so the rule cannot be copied.** QSL ids are underscored and carry no
API-version suffix, so `fabric-<x>-v<digits>` matches none of them — which is also why the Fabric fix
left this open rather than closing it by accident.

Ran before committing, per the pin-first lesson audit iteration 32 recorded against itself: both
failures are the missing mapping, not a defect in the guards.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Closes the open observation from audit iteration 32, now verified rather than suspected.

A Quilt descriptor depends on QSL's **modules** — `quilt_resource_loader`, `quilt_networking`,
`quilt_registry` — and neither platform publishes them, so each fell through to a Modrinth slug guess
that 404s and to nothing at all on CurseForge. Identical to the Fabric API failure: the dependency
goes unstaged, the loader refuses the pack, and the candidate wears the INCONCLUSIVE.

Evidence: all 47 `quilt.mod.json` files in `QuiltMC/quilt-standard-libraries` (branch 1.21.5) were
read and their `depends` entries collected — 33 distinct `quilt_*` ids, every one lowercase words
separated by underscores, none carrying an API-version suffix. `fabric-<x>-v<digits>` matches none of
them, which is why the Fabric rule left this open instead of closing it by coincidence, and why the
QSL rule is `^quilt_[a-z0-9_]+$` rather than a copy.

`notQsl` holds `quilt_loader`: the loader itself, already dropped before staging by
`environmentProvidedIds`, so mapping it would change nothing observable today — which is the reason
to exclude it rather than a reason not to bother. A table other code is entitled to trust must not
record a false fact just because the falsehood is currently unreachable.

**Noted, deliberately not changed:** `quilt_base` *is* a QSL module (`library/core/qsl_base`, and
`quilt_base_testmod` declares `["quilt_loader", "quilt_base"]`), yet `QuiltScanner.dependencyExclusions`
in `-api` strips it at scan time as "the platform", and `BootVerifier.environmentProvidedIds` repeats
that. So it never reaches staging. Correcting it would mean changing existing assertions in the
published module — the conventions' stop-and-flag signal — for a case limited to a mod whose *only*
QSL dependency is `quilt_base`, since any other module now pulls QSL in anyway. Raised rather than
silently changed.

clientside 263 → 266, all green.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Audits `03047a7c0..HEAD` — the 17 commits of this session's live-defect work across -api, -clientside,
-grinder and the deploy scripts.

No HIGH. The published-API behaviour change (`21c912bd4`) did get its API-BEHAVIOUR-CHANGES row,
module boundaries hold, and a `--rerun-tasks` clean compile of all three changed modules produces no
new warnings — checked with `--rerun-tasks` specifically because incremental compilation has hidden a
broken test tree in this repository before.

Three MEDIUM. `cbc615edb` bundled a pure refactor (moving the fold into the companion) with the
five-line behaviour change it was labelled for, turning it into a 90-line diff. Two red-committed
pins carried bugs of their own that the implementation commit then fixed, so the committed red state
is not the clean "implementation missing" signal the pin-first rule exists to leave behind. And the
root CLAUDE.md api count had gone stale inside this very range — 381 against an actual 382, drifted
by `20a4e02af` adding a test — which is the fourth consecutive audit to find an instance of the
"suite counts left behind by the tests that were just added" class. That one is **fixed here**;
re-derived from build/test-results, with clientside 263 and grinder 434 confirmed already correct.

Four LOW, all in this session's own code: two guards added inside implementation commits rather than
pinned first, three `!!` in one test class, an undocumented `PAGE` constant, and a dashboard error
message that claims to be showing the last successful poll when the first one fails.

One open observation: the Fabric API module rule has no Quilt mirror. QSL ships as modules too, and
only `quilted_fabric_api`/`qsl` are mapped while `environmentProvidedIds` excuses only `quilt_loader`
and `quilt_base` — so any other QSL module id goes unmapped exactly as `fabric-resource-loader-v0`
did. Recorded as unverified: the QSL repository groups modules by category rather than published id,
so the id shape needs confirming against a real `quilt.mod.json` before a rule is written.

Also fixes the garbled `step "Checking preresudo nanoquisites"` in update-grinder.sh's preflight — an
accidental editor edit, back to "Checking prerequisites"; `bash -n` clean.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
LOW-2 — `StatusDashboardScriptTest` carried `val nodeBinary = node!!` three times, because
`Assumptions.assumeTrue(node != null, …)` aborts correctly but tells the compiler nothing. `requireNode()`
now makes the assumption and returns a non-null `String`, so all three call sites are one line and the
convention against new `!!` holds again.

LOW-3 — `StatusDashboardRenderer.PAGE` had no doc comment, the only member of that object without one.

LOW-4 — the dashboard said "showing the last successful poll" on a failure even when the *first* poll
failed, at which point every panel is empty and there is no such poll; `everLoaded` now picks the honest
wording. Verified by `theDashboardScriptParses`, which runs the edited script under node.

MED-2 is closed as a **rule**, since its two instances are already merged: root `CLAUDE.md` gains "Run the
pin before you commit it red, and read why it failed", carrying both 2026-09-01 cases as evidence. It sits
above the existing pin-first rule because it is the gap that rule leaves — a red commit proves nothing when
the red is the guard's own bug.

MED-1 and LOW-1 are **accepted rather than rewritten**. Both are commit-shape defects in history already
merged into `develop`, and this repository has decided that trade before: the `358675fbf` entry records that
the honest remedy for a commit found mis-shaped after merging is the audit entry, not a rebase of shared
history.

Docs: clientside 263 → 266 re-derived from the test XML; the clientside module file gains the QSL rule
beside the Fabric one, including the `quilt_base` sub-gap raised for Griefed rather than changed, since
correcting it means editing existing assertions in the published `-api` module.

api 382 (1 skip), clientside 266, grinder 434 (29 skip), app 149 — all green.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Red: four guards fail, each on the absent `quilt_base` and nothing else — checked against the
messages, per the pin-first rule added yesterday.

`QuiltScanner.dependencyExclusions` drops `(quilt_loader|quilt_base|java|minecraft)`, describing both
quilt ids as "the platform", and `BootVerifier.environmentProvidedIds` repeats it. `quilt_base` is not
the platform: it is QSL's base module, shipped by QFAPI. `library/core/qsl_base` exists in
`QuiltMC/quilt-standard-libraries` and its own `quilt_base_testmod` declares
`["quilt_loader", "quilt_base"]` — read 2026-09-01 on branch 1.21.5, alongside the 47-descriptor
harvest that produced the QSL module rule.

This is the same bug the *Fabric* half of these tests already exists to prevent. `FabricScanner`
excludes only `fabricloader` and deliberately keeps `fabric`, because Fabric API is a mod the server
needs and excluding it meant it "could never be reported as the dependency it is, nor rescued back
into a pack that had disabled it". Quilt drew the line one id too far, and QSL is exactly as much a
mod as Fabric API.

Both halves are pinned here rather than only the scanner, because they are one behaviour split across
two modules: `-api` decides what is *reported* as a dependency, `-clientside` decides what is
*staged*, and fixing one without the other leaves the mod unbootable for the same reason as before.

Two existing expectations change, which the conventions call a stop-and-flag: flagged in audit
iteration 32, put to Griefed, and changed on their explicit instruction. The label is `fix:` on the
following commit rather than `refactor:`, which is what the rule asks when behaviour genuinely moves.
Two further guards — `onlyTheQuiltRuntimeIsExcludedFromDependencies` and
`quiltBaseIsStagedWhileTheQuiltLoaderIsNot` — state the line on its own so it cannot be inferred from
a fixture that happens to list one of each.

`MinecraftConstraintTest.quiltReportsTheMinecraftItDeclares` also names `quilt_base` but asserts only
the Minecraft constraint, so it is unaffected and untouched.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Closes the sub-gap audit iteration 32 raised and left for Griefed, changed on their instruction.

`QuiltScanner.dependencyExclusions` dropped `(quilt_loader|quilt_base|java|minecraft)` and
`BootVerifier.environmentProvidedIds` repeated `quilt_base`, both calling it the runtime. It is not:
it is QSL's base module, shipped by QFAPI. `library/core/qsl_base` exists in
`QuiltMC/quilt-standard-libraries`, and its own `quilt_base_testmod` declares
`["quilt_loader", "quilt_base"]` — read on branch 1.21.5 during the 47-descriptor harvest that
produced the QSL module rule.

Consequences of the old behaviour, one per layer. In `-api` the dependency was never *reported*, so
nothing could name QFAPI/QSL as a dependency and dependency rescue could not pull it back into a pack
that had disabled it. In `-clientside` it was never *staged*, so a mod whose only QSL dependency is
`quilt_base` booted without it and failed on the very dependency the harness declined to supply. Both
layers are fixed together because they are one behaviour: fixing either alone leaves the mod
unbootable for the same reason as before.

This restores the line `FabricScanner` already draws and documents — exclude `fabricloader`, never
`fabric`, "because a dependency you refuse to record can neither be reported nor rescued back into a
pack that disabled it". Quilt had drawn it one id too far. `quilt_loader` stays excluded.

Two existing `-api` expectations changed, which the conventions treat as a stop-and-flag. That is the
flag working rather than being bypassed: it was raised in the audit, put to Griefed, and acted on when
they decided. The label is `fix:`, not `refactor:`, because behaviour genuinely moved.

One `API-BEHAVIOUR-CHANGES.md` row: no signature changes, but a Quilt jar's scan now returns one more
`ModDependency`, and a QFAPI/QSL jar becomes rescuable into a server pack that had disabled it — the
intended fix, though an embedder asserting a fixed dependency count will see it rise by one.

api 382 → 383, clientside 266 → 267, grinder 434 (29 skip), app 149 — all green.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
docs(audit): record the history rebuild that closed MED-1, LOW-1 and MED-2's instances
All checks were successful
Documentation / Writerside webhelp (push) Successful in 6m5s
Continuous / Build JAR (push) Successful in 17m49s
Test / build (push) Successful in 21m16s
Qodana / scan (push) Successful in 26m33s
Continuous / Build AppImage (x86_64) (push) Successful in 4m44s
Continuous / Build AppImage (aarch64) (push) Successful in 3m44s
Documentation / Help image (push) Successful in 20m37s
Qodana / notify (push) Successful in 1m37s
Continuous / Build Install4J Media (push) Successful in 16m18s
Continuous / Continuous Pre-Release (push) Successful in 13m12s
Docker Test / build image (push) Successful in 1h23m35s
0930519156
git rebase -i is unavailable here, so the range was replayed explicitly: every feature branch
re-created and every --no-ff merge restored with its original message. All nine merges survive and
the tree is byte-identical to the pre-rebase tip; 26 commits became 29.

The three splits were each verified by checking out the intermediate commits and running the suites,
rather than asserted: the new refactor commit is green with the confidence ladder diffed to confirm no
branch changed, the dashboard feat commit is green and the node guard after it is red on safeHref, and
the Fabric collapse guard is red in the pin commit it moved to.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Two corrections to the record, both consequences of yesterday's history rewrite.

**The rebase orphaned 13 commit hashes this file cited, across 26 occurrences.** Eight were killed by
that rebase; the other five had already been orphaned by an earlier one and are now reachable from no
ref at all — `git for-each-ref --contains` finds nothing for them, so they survive only in the object
store and would stop resolving entirely once gc runs. All 26 now name the **commit subject**, which is
what the *Cite names, not snapshots* convention asks for and what survives rebase, cherry-pick and
squash. Verified: every remaining hash in the file passes
`git merge-base --is-ancestor <hash> develop`.

The root `CLAUDE.md` convention gains the recurrence as evidence. It already cited "54 commit hashes
killed by a rebase"; that this happened again, in the same file, while the convention was in force,
is the part worth recording — `REFACTOR-AUDIT.md` is the one place in the repository that cites hashes
at volume, so it is the guaranteed casualty of every history rewrite.

**The rebuild was proposed on a premise nobody checked.** The iteration-32 entry stated that nothing
had been pushed and the history could therefore still be re-cut. `origin/develop` already held all 26
commits, so `358675fbf`'s precedent — a mis-shaped commit found after it reached a shared branch is
remedied by the audit entry, precisely because the alternative is force-pushing a shared branch —
applied in full rather than being inapplicable. The rewrite consequently required a force-push, which
Griefed performed on 2026-09-01 after being shown the divergence. Nothing was lost, since the tip
trees are byte-identical, but the entry now says so plainly and states the rule: check
`origin/<branch>` before proposing a history rewrite, because the cost of being wrong falls on
everyone who has already pulled.

Docs only; no code touched.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The previous commit fixed 26 citations and then, in the paragraph explaining the fix, listed the five
now-unreachable hashes verbatim — citations that die with gc exactly like the ones being replaced.
They are named by subject now (the CPU-cap series and the deployment-gaps docs commit), which is the
only form that still means anything once the objects are collected.

The verification is also widened and stated: every tracked `.md` was swept for hex tokens that
`git cat-file -t` resolves to a commit, each tested against
`git merge-base --is-ancestor <hash> develop`. Two files legitimately carry non-develop citations and
are left alone:

  - `.claude/rules/ci-workflows.md` names `50fd50f37`, a real release commit on `origin/alpha` —
    outside `develop` by design rather than orphaned, and it still resolves.
  - `CHANGELOG.md` names ~31 hashes that resolve to no branch, but it is generated by
    semantic-release and rewritten on every release, so editing it by hand would be futile.

Docs only.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Griefed reports locked CurseForge downloads still failing after the browser install landed. The
install cannot answer why, and that is the defect: it checks that a *download command exited zero*,
which is not the same as the browser working. The failure being seen — every locked file timing out —
is Chromium starting and then getting nowhere, and an install exit code is blind to it.

So the section now ends by launching it, as the service account, the way `BrowserDownloader` does:
`com.microsoft.playwright.CLI screenshot --browser chromium about:blank`. `about:blank` needs no
network, so it probes the browser and nothing else — which also makes the result diagnostic in the
other direction: a locked file still failing after this passes is a CurseForge or network problem,
not a missing prerequisite.

**Found while writing it, and it is the sharper half.** Since 1.49 Playwright serves
`setHeadless(true)` from a *separate* binary, so the path the daemon needs is
`chromium_headless_shell-1234`, not `chromium-1234` — read off the real error by running the CLI here
with `PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD=1`. `install chromium` does fetch both (verified against a
cache holding `chromium-1223` *and* `chromium_headless_shell-1223`), but a cache holding only the full
browser would satisfy every check the script previously made and still serve zero downloads. The probe
is the only thing here that would notice.

It covers two further causes nothing else could: a HOME the service account cannot write, since
Chromium needs a cache directory of its own, and a sandbox the kernel refuses — the unit sets
`NoNewPrivileges=true`, and a host with unprivileged user namespaces disabled leaves Chromium no
sandbox it can use. On failure the probe prints Playwright's own output, which is where the
host-validation package list appears, and names those three causes in order.

`mktemp` for the probe file is guarded with `|| true` and a fallback: a service account that cannot
mktemp is a finding to report, not a reason for `set -e` to abort a deployment that has already
installed everything else.

Verified by measurement, there being no harness for deploy scripts: `bash -n` clean, `--help` renders,
`--bogus` still rejected, and the probe invocation exercised against the real installed dist — it
fails with `Executable doesn't exist at .../chromium_headless_shell-1234/...`, which is exactly the
actionable shape an operator needs.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
merge: prove chromium launches for the service account
All checks were successful
Documentation / Writerside webhelp (push) Successful in 6m52s
Continuous / Build JAR (push) Successful in 14m6s
Docker Test / build image (push) Successful in 19m19s
Documentation / Help image (push) Successful in 2m28s
Qodana / scan (push) Successful in 16m12s
Continuous / Build AppImage (x86_64) (push) Successful in 2m39s
Continuous / Build AppImage (aarch64) (push) Successful in 3m23s
Qodana / notify (push) Successful in 24s
Test / build (push) Successful in 17m3s
Continuous / Build Install4J Media (push) Successful in 9m26s
Continuous / Continuous Pre-Release (push) Successful in 7m41s
1eaa85b043
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Red: `aLockedFileIsRecognisedAndHasNoBrowserFallback` and `noRoutingHelperSurvives` fail — the browser
downloader and the router are still here.

Griefed's decision, and the reasoning is theirs: Playwright was implemented to circumvent CurseForge's
third-party distribution block, while regular downloads for non-blocked content and all of Modrinth
never needed it, so it is extra weight with little to no benefit.

The guards state the end state rather than the deletion: a downloadable file is still fetched over
HTTP, a locked file is still *recognised* as locked — the flag is what lets a refusal explain itself —
and both `BrowserDownloader` and `selectDownloader` are asserted **absent from the classpath**, so the
removal cannot be half-done.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A distribution-locked CurseForge file (`allowModDistribution=false`) is now reported rather than worked
around: `HttpJarDownloader` returns null, `ClientsideVerifier` records `JarScan.DEFERRED`, and the
staging refusal names the lock and points at Modrinth, where the same project's files carry a URL. The
author opted out of third-party distribution, and driving the website was only ever a way to ignore
that.

It had also stopped working. CurseForge is behind a Cloudflare challenge the headless browser does not
clear, so every locked-file attempt died on `Timeout 60000ms exceeded` *after* Chromium had launched,
while a plain HTTPS fetch of the same file page returned 403 with challenge markers on any user agent.
The host was exonerated first — the installer's launch probe rendered a page as the service account and
the cache held both `chromium-1234` and `chromium_headless_shell-1234` — so this is not working around
a misconfiguration.

Gone: `BrowserDownloader`, its test, and `selectDownloader`. Removing the router left
`JarDownloader.kt` with no top-level function at all, so Kotlin now emits no `JarDownloaderKt` facade —
a stronger result than the guard assumed, which is why it accepts both an absent facade and one without
the method.

Two existing expectations changed, which for a deliberate removal is correct rather than the
stop-and-flag signal. `aLockedFileSaysWhyItCouldNotBeDownloaded` asserted the message named a
"browser"; it now asserts it names Modrinth and mentions **neither** browser nor Playwright, so a
refusal cannot send an operator looking for a mechanism that no longer exists.
`BootVerifierSelectionTest` lost one constructor argument with no assertion touched — the
reference-only carve-out.

**Behaviour change for app users:** `-verifyclientside` can no longer verify a distribution-locked
CurseForge project; it reports why. `-clientside` is not published to Maven, so there is no
API-BEHAVIOUR-CHANGES row, and the CLI surface is unchanged in shape.

The Playwright dependency itself goes in the next commit, where its cost can be measured.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The code stopped using the headless browser in the previous commit; this removes what it cost.

**Measured on this commit, with the code already gone: `serverpackcreator-app-dev.jar` 274.7 MB →
77.8 MB.** Re-runnable as `./gradlew :serverpackcreator-app:bootJar` either side of it.

The weight was `driver-bundle-1.62.0.jar`: **192.9 MB** of bundled node runtimes for five platforms
(`linux`, `linux-arm64`, `mac`, `mac-arm64`, `win32_x64`). Playwright's own driver code is the 3.0 MB
`driver` jar and the API is 0.6 MB — the rest was runtimes. It reached every artifact because
`-clientside` declared `api(libs.playwright)`, so ~72% of what each user downloaded existed for one CLI
verb most never run.

Also removed, being prerequisites for a route that no longer exists: the `libs.versions.toml` version
and library entries, the `playwright install-deps chromium` step in `clientside-report-reusable.yml`,
and the installer's whole headless-browser stage — its `--skip-browser` flag and the `service_java`
hoist that existed only to run the browser install, now assigned-but-unused. `bash -n` clean, `--help`
renders, and `--skip-browser` is correctly rejected as unknown.

Verified: `playwright` appears in **0** runtime-classpath entries for `-clientside`, `-grinder` and
`-app`.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Covers all seven documents, including the one the first attempt at this removal missed.

`serverpackcreator-clientside/README.md` is the important one: it is **operator-facing** and it listed
"Browser system libraries" as a prerequisite, described the headless-browser fallback across a dozen
lines, told readers to run `npx --yes playwright install-deps chromium`, and offered that same command
in two troubleshooting rows. All of it was instruction to install a capability that no longer exists.
It now states the limit honestly — a locked file publishes no URL, so it cannot be scanned or
boot-tested, and the project should be verified from Modrinth — with a short historical note so anyone
who read the old version knows why the prerequisite vanished.

**Why it was missed the first time, recorded because the lesson is mechanical:** the completeness sweep
grepped `"Playwright\|BrowserDownloader"` **case-sensitively**, and this file writes the tool lowercase
inside `npx --yes playwright install-deps` — 0 matches where `grep -i` finds 3. An earlier sweep had
listed the file and it was dropped on the strength of the case-sensitive re-check. Verify a removal with
`grep -i`, or the check confirms only what it can see.

The rest: `-clientside`'s `CLAUDE.md` (the landmine becomes a HISTORY entry carrying the measurements
and a do-not-reintroduce), its `module.md`, `-grinder`'s `CLAUDE.md` and `README.md` (host prerequisite
and troubleshooting row), `-app`'s `CLAUDE.md`, and the root suite count 273 → 262.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
docs(audit): close iteration 33, and add the convention MED-3 earns
All checks were successful
Documentation / Writerside webhelp (push) Successful in 2m42s
Docker Test / build image (push) Successful in 15m15s
Continuous / Build JAR (push) Successful in 17m41s
Qodana / scan (push) Successful in 15m11s
Continuous / Build AppImage (x86_64) (push) Successful in 2m13s
Documentation / Help image (push) Successful in 3m11s
Continuous / Build AppImage (aarch64) (push) Successful in 2m19s
Qodana / notify (push) Successful in 10s
Continuous / Build Install4J Media (push) Successful in 6m14s
Test / build (push) Successful in 15m20s
Continuous / Continuous Pre-Release (push) Successful in 3m49s
f397a11995
MED-1 fixed (the stale operator README), MED-2 fixed (the 23-file removal re-cut into four commits, its
measurement relocated to the build commit that causes it), MED-3's artifact removed from history by the
same re-cut, LOW-1 dissolved with the commit it described, LOW-2 correct as-is.

The report is rewritten as report-and-resolution together, because acting on it changed the history it
described — and cited by **subject rather than hash**, since the re-cut would have orphaned every hash
in it. That is the defect iteration 32 found twice; written correctly the first time here.

Root `CLAUDE.md` gains **"Question the requirement before you optimise the cost of meeting it"**, which
is MED-3 generalised: a circuit breaker was designed, pinned with 189 lines of guards, implemented,
wired through two modules and documented, then deleted 34 minutes later when Griefed asked whether the
route it protected was needed at all. Every commit in that sequence was correctly shaped, which is why
the shaping did not save it. Knuth one level up — "measure before optimising" presumes the thing should
exist.

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>
Deploy mode's first act is `rm -rf $SRC`, and $SRC/repo is exactly where the
previous run left a checkout -- so it is the copy of the script an operator
reaches for, and running it means deleting the file bash is still reading. Bash
reads a script incrementally, by offset, so the symptom is a syntax error part
way through a run that has already begun deleting things, with nothing naming
the cause.

The hazard predates the merge (update-grinder.sh sat in the same directory), but
the merge makes it likelier: the checkout's copy is now the same script you would
deploy with, and the README points at $SRC/repo as the place the tree is left for
debugging.

Guarded on $SRC rather than a hardcoded path, so overriding SRC moves the guard
with it. Skipped when the script has no resolvable path on disk, which is how a
script fed to bash on stdin arrives -- that case cannot be inside $SRC anyway.

Executed rather than assumed, in a debian:stable container:

- a copy at /opt/spc-grinder-src/repo/... with the default SRC is refused, and the
  error carries the curl line to re-fetch it
- the same copy with SRC=/opt/elsewhere is allowed through to the clone, proving
  the guard is SRC-relative
- a copy at /root is allowed through
- the script piped into `bash -s --` is allowed through

The test run also demonstrated the hazard by accident: the /root case wiped
/opt/spc-grinder-src out from under the copy sitting there, which is precisely
what the guard now prevents.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
merge: one deploy script for the grinder, with the mode decided by uid
All checks were successful
Documentation / Writerside webhelp (push) Successful in 1m47s
Continuous / Build JAR (push) Successful in 10m21s
Qodana / scan (push) Successful in 8m45s
Docker Test / build image (push) Successful in 12m42s
Documentation / Help image (push) Successful in 3m13s
Continuous / Build AppImage (x86_64) (push) Successful in 1m59s
Continuous / Build AppImage (aarch64) (push) Successful in 2m2s
Qodana / notify (push) Successful in 21s
Continuous / Build Install4J Media (push) Successful in 5m59s
Test / build (push) Successful in 11m30s
Continuous / Continuous Pre-Release (push) Successful in 4m7s
5b5eb04b73
install-grinder.sh and update-grinder.sh become one script. The two had inverse
root requirements -- install refused root because a Gradle build as root leaves
root-owned files in build/, update required it to drop to a build account -- so
the uid already decided which half could run, and is now the mode switch. No
--mode flag, because it could only agree with `id -u` or lie.

The name was kept so the copy of update-grinder.sh already on the grinder host,
which invokes install-grinder.sh by path, keeps working through the transition.

Also fixes the arg-quoting into `bash -lc` and refuses to deploy from a copy of
the script inside the directory deploy mode wipes.

Grinder suite green at 434 (29 skipped), 0 failures.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Red on purpose, and it does not compile: `RuntimeImagePreflight` and `ContainerEngine.hasImage` do not
exist yet, so the failure is `Unresolved reference 'RuntimeImagePreflight'` — the missing implementation
and nothing else. Verified by running it before committing, per the convention that a guard committed red
has to be red for the right reason.

What it pins, measured on the live daemon 2026-09-03: `spc-grinder-runtime:latest` was gone from Docker
(nothing in install-grinder.sh removes it; `docker system prune -a` does, since the image is only in use
during a boot). Every install threw `Status 404: No such image`, every tuple went on install cooldown, and
every candidate wanting one was scored INCONCLUSIVE with a sentence about a loader tuple. `record()`
replaces by identity and the re-verify TTL is 30 days, so projects that held a decisive HIGH lost it — and
with it their line in /as-properties.

Which is the loader-cache-poisoning lesson one level up: an environment defect looks exactly like a subject
defect unless something distinguishes them. The per-tuple cooldown disguised it, because one host-wide
failure is bookkept as one independent failure per tuple.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`ContainerEngine.hasImage` (default `true`, so no fake is affected) asks the daemon; the docker-java engine
answers it with `inspectImageCmd` and treats any failure as "no", logging which it was — a missing image and
an unreachable daemon are different causes with one consequence, and the refusal names both. `main` consults
`RuntimeImagePreflight.refusalFor` immediately after building the engine and exits non-zero, so the unit's
`Restart=on-failure` retries every 30s and `systemctl status` shows `failed` in between.

Exiting is the point rather than a warning: without the image nothing can boot, so continuing publishes an
INCONCLUSIVE verdict about every candidate it touches, replaces the decisive ones already in the store, and
the 30-day re-verify TTL leaves those wrong until somebody notices. That is not degraded service, it is the
daemon destroying the record it exists to keep.

Turns the pin of the previous commit green.

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>
`installThrewMessage` names the tuple, the exception's type, and its message where there is one — "(no
message)" where there is not — and the throwable itself now reaches the logger, so the stack trace is in the
journal instead of being dropped. Turns the previous commit's pin green.

Also removes a doc comment that sat above `companion object` describing its two constants; adding a third
member made it untrue, and the members carry their own docs.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Red on purpose: `ContainerCandidateVerifier.installedBase` does not exist. Run before committing — every
error is `Unresolved reference 'installedBase'` or a type-inference cascade from it, nothing else.

The defect it pins: `overlayLoaderInstall` asked `isInstallOnCooldown` *after* `ensureInstalled`, and
`ensureInstalled` records the cooldown on its way out of a failure. So the candidate whose boot actually
paid for the failed install was told the install "failed recently and is on cooldown, so it was not
retried" — and the failure branch was unreachable in production. During the 2026-09-03 outage that made
every one of thousands of identical verdicts claim to be a cheap skip, hiding how many installs were really
being attempted and failing.

The state before the attempt is the only moment the two cases differ, so that is when it is now asked.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`installedBase` reads the cooldown *before* asking the cache, so the candidate whose boot paid for the
attempt is told the install failed, and only the candidates behind it are told the retry was suppressed.
`overlayLoaderInstall` calls it; the two-branch message it has always carried finally reaches both branches.

Turns the previous commit's pin green, and makes `installUnavailableMessage`'s failure branch reachable in
production for the first time.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Red on purpose: `RequeueSelection.verifiedSince` does not exist, so every error is that reference or a
type-inference cascade from it. Run before committing.

`verifiedBefore` selects the complement of an outage window. On 2026-09-03 the runtime image was gone from
the daemon, so for the hours until anyone noticed, every candidate was published INCONCLUSIVE about a boot
that never happened -- replacing whatever the store held, with the 30-day re-verify TTL to keep it that way.
The population to re-grind is "everything verified SINCE it broke"; asking for it with
`--requeue-before <the fix>` queues the whole store instead, most of which the outage never touched.

Also pins that the two selectors partition the store, so an operator can say which population they queued.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`RequeueSelection.verifiedSince` selects every project verified at or after an instant, one candidate per
project, and `--requeue-since <ISO-8601 instant>` queues it. Inclusive of the instant, so it and
`--requeue-before` partition the store rather than overlapping.

The existing selector answers "a defect was found in the engine, so the past is suspect". It cannot answer
"the host was broken between 18:00 and now", which is the 2026-09-03 outage: with the runtime image absent
every candidate ground was published INCONCLUSIVE about a boot that never happened, and
`--requeue-before <the fix>` selects the exact complement of that damage.

`oneCandidatePerProject` is the grouping both selectors share, extracted rather than duplicated; the
rationale moves with it and `verifiedBefore` points at it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
README §9 gains the runtime-image refusal and loses a troubleshooting row for `No cached loader install
for …`, a message that no longer exists; the two install rows now separate the failure from its echo and
name the journal grep and the `install.log` path that find the cause. §6 documents `--requeue-since` beside
`--requeue-before` and says plainly that picking the wrong mirror queues what you did not mean.

The module files gain the incident as a landmine (grinder), the preflight seam (container) and the two
diagnosis fixes (loader). Root CLAUDE.md: grinder row 434 → 446 tests, plus the incident in one paragraph.
The blow-by-blow, including the host state that ruled out the two usual explanations, is in REFACTOR-LOG.md.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
merge: refuse to grind without a runtime image, and say why an install failed
All checks were successful
Documentation / Writerside webhelp (push) Successful in 1m23s
Qodana / scan (push) Successful in 7m51s
Continuous / Build JAR (push) Successful in 11m56s
Docker Test / build image (push) Successful in 13m35s
Qodana / notify (push) Successful in 17s
Continuous / Build AppImage (x86_64) (push) Successful in 2m10s
Documentation / Help image (push) Successful in 4m39s
Continuous / Build AppImage (aarch64) (push) Successful in 2m39s
Test / build (push) Successful in 12m54s
Continuous / Build Install4J Media (push) Successful in 8m22s
Continuous / Continuous Pre-Release (push) Successful in 3m47s
4fba2b8470
The live daemon published thousands of INCONCLUSIVE verdicts reading "Loader install for <tuple> failed
recently and is on cooldown, so it was not retried". The sentence is an echo: spc-grinder-runtime:latest had
been removed from the Docker daemon, so every install threw `Status 404: No such image`, every tuple went on
the 60-minute cooldown, and every candidate wanting one spoke about a boot that never happened. Since
record() replaces by identity and the re-verify TTL is 30 days, projects holding a decisive HIGH lost it, and
with it their line in /as-properties.

An environment defect looks exactly like a subject defect unless something distinguishes them -- the same
lesson as the poisoned loader-cache entry, one level up, and the per-tuple cooldown was disguising it by
bookkeeping a host-wide failure as one failure per tuple.

Four changes, each pinned red first:

1. ContainerEngine.hasImage + RuntimeImagePreflight; main refuses and exits 1 before taking a candidate.
2. LoaderCache.installThrewMessage names the exception's type, and the throwable reaches the logger -- one
   tuple's line had read "... / Minecraft 1.21.1: null".
3. The cooldown is read BEFORE ensureInstalled, so the candidate that paid for the failed install is no
   longer told it was skipped. The failure branch was unreachable in production until now.
4. --requeue-since <instant>, because --requeue-before selects the exact complement of an outage window.

Deploy order matters: rebuild the image first, or the daemon will now correctly refuse to start.

Grinder suite green at 446 (29 skipped), 0 failures. develop's unmodified test tree against this branch's
production code: 434 pre-existing guards, 0 failures, 0 compile errors.

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>
Three pins for the pure decision the fix needs: the newest version of a file that both the
host can boot and the jar's own declared range accepts — JEI's literal shape (tagged 1.21
and 1.21.1, declaring `[1.21, 1.21.1)`, answer 1.21), the host gate still applying, and
"no agreeable version" yielding null rather than quietly handing back the excluded one.

That last case is the one worth pinning: returning the version just rejected would turn a
refusal into an identical second refusal, and the caller must instead keep its original.

Run before committing; all three fail with `Unresolved reference 'newestVersionSatisfying'`
— the missing implementation, which is the accepted red for a pin whose subject does not
exist yet.

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>
docs: record why a jar's own range now re-selects instead of refusing
All checks were successful
Documentation / Writerside webhelp (push) Successful in 3m18s
Continuous / Build JAR (push) Successful in 15m11s
Qodana / scan (push) Successful in 15m50s
Docker Test / build image (push) Successful in 26m12s
Documentation / Help image (push) Successful in 8m7s
Continuous / Build AppImage (x86_64) (push) Successful in 3m20s
Test / build (push) Successful in 15m50s
Continuous / Build AppImage (aarch64) (push) Successful in 3m57s
Qodana / notify (push) Successful in 1m8s
Continuous / Build Install4J Media (push) Successful in 10m25s
Continuous / Continuous Pre-Release (push) Successful in 5m7s
b8a6081218
Root CLAUDE.md (clientside row, 262 → 266), the module's landmine list, and the refactor log.

The part worth having written down is the negative: `ForgeTomlScanner.getVersionRange` is verbatim
and `VersionConstraint.mavenRangeHolds` trims exactly like Maven's `parseRestriction`, so a
self-excluding range like JEI's `[1.21, 1.21.1)` is read correctly and the parser must not be
"fixed" by the next person who meets one. Evidence is cited by name — the jar's own mods.toml and
JEI's gradle.properties pairing `minecraftVersion=1.21.1` with `minecraftVersionRange=[1.21, 1.21.1)`
— rather than by a line number that moves.

Also records the one thing deliberately left open: whether Forge fatally enforces that range at
runtime is circumstantially unlikely but was not demonstrated, and the fix stands either way.

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>
Root CLAUDE.md (api 383 → 387, clientside 266 → 267), the api module's landmine list, the
published-API behaviour record, and the refactor log.

The landmine worth having written down is that optionality has **two** spellings — Forge's
`mandatory` and NeoForge's `type`, the latter defaulting to `"required"` — and that one reader
must serve both, because `NeoForgeTomlScanner` overrides only the file name and NeoForge on
Minecraft 1.20.2-1.20.4 still ships `mods.toml`.

The API row records what is deliberately *not* a behaviour change: `ScannedMod.dependencies`
still lists every declared dependency in the same order, so an embedder's existing reads are
unaffected. Only the ability to tell optional from required is new.

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 1 of the result-system redesign, and additive only: nothing consumes these yet, so
`BootResult` and `Confidence` are untouched and every existing guard stays green. Strangler-Fig,
so the migration of consumers can land in reviewable steps rather than one sweep.

`Verdict` carries `keepsLogs` on the enum itself, so the reaper asks the verdict instead of
re-deriving retention from a result plus a confidence — the arrangement that let artifacts and
the outcome that justified them drift apart.

`StagingOutcome` and `BootObservation` are separate types on purpose. Staging is the engine's own
work and its failure is an operator problem; a boot's failure is a statement about the mod. Fusing
them is precisely what produced the INCONCLUSIVE-for-a-boot-that-never-happened class of bug.

`VerdictPolicy.decide` encodes the ladder in its ordering: prevented outranks any rule (nothing
ran, so no console existed to match, and a confirmation arriving with a prevented grind is a
caller bug rather than evidence); only then may a rule confirm; a crash no rule explained is
INCONCLUSIVE, never CONFIRMED.

`BootObservation` is deliberately coarse — Survived, Crashed(exitCode), TimedOut. Reading a
console finely is a rule's job, and stage 2 moves the eleven hardcoded marker groups into the
rules file where they can be edited.

clientside 274/0, read from build/test-results (266 before, +8 pins).

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 2a. The eleven hardcoded marker groups are now `boot-rules.default.json`, bundled in the
jar, in precedence order, each carrying the evidence its former KDoc cited — the measured counts
included, because those are what justify a pattern's existence and are the first thing anyone
editing one needs. `BootRule`/`BootRuleSet` parse and evaluate them; order is precedence.

Additive so far: `BootLogClassifier` still holds its own copies, and 2b swaps it over to these.
Splitting there is deliberate — the existing `BootLogClassifierTest` and
`RealBootLogClassificationTest` are the equivalence guard for that swap, and they are only
meaningful if the rules being swapped in already exist and are themselves pinned.

`BootRuleSet.parse` drops what it cannot use — no id, no pattern, an uncompilable pattern, a
duplicate id — and reports each in `errors` rather than throwing. This runs per boot on a live
service: one bad edit must cost the operator that rule and a message, not every verdict. A missing
bundled resource degrades the same way, to "no console rules" rather than to no verdicts, because
the structural readings (ready-line, timeout, exit code) are not rules and still stand.

Those three stay structural on purpose. They are readings of how the process *ended*, not of what
it *said*, which is what `BootObservation` models; a rules file is for the console.

clientside 288/0, read from build/test-results (266 before, +8 stage-1 pins, +14 here).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Stage 2b, and behaviour-preserving: the ten hardcoded `Regex` literals are gone and each rung now
compiles its pattern out of `boot-rules.default.json` by rule id. One source of truth, so an
operator's edit reaches the engine and the two copies can no longer drift — the failure mode this
repo already paid for once, when `MetadataScanner` and `ModListCompiler` held the same Forge-era
version bug in duplicate.

**The ladder's order stays in code; only its content moved.** That is the conservative half of the
split: re-ordering rungs would change judgment, and the interleaved non-console readings (the
killed exit codes sit between the bootstrap guard and the operator rules) cannot be expressed by
file order alone. Stage 4 revisits that once the verdict vocabulary is migrated.

Each field keeps its KDoc. The file's `note` mirrors the substance for whoever is editing a
pattern, but the rationale and the measured evidence belong beside the rung that uses them.

`crashMarkers` deliberately stays a literal: it chooses where a crash *excerpt* begins and decides
no verdict, so it is not a rule.

**The equivalence evidence, which is the whole point of doing this as its own commit:** the 46
pre-existing guards across `BootLogClassifierTest` (29), `ConsoleRuleLadderTest` (10) and
`RealBootLogClassificationTest` (7) were not touched and all stay green — including the real
captured consoles, which are the ones that would notice a pattern that quietly stopped matching.
Re-run with `--rerun-tasks` after wiping build/test-results, since a green build off the cache has
executed nothing here before.

clientside 288/0, unchanged by this commit — a pure refactor adds no tests.

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>
Stage 3. `RuleSource` splits the rule engine into two streams, `MetadataFacts` renders what is
declared about a mod into one line, and four metadata rules join the bundled file. The platform's
`server_side` and the jar's own descriptor were the last hardcoded clientside determination, buried
in `aggregateFor`'s `when` where no operator could reach them.

**One fact line rather than a stream per source.** The old fold's most careful branch reads the two
signals *together*, to notice that the platform marks the server unsupported while the jar declares
server/both — a contradiction, and the case where confidence must fall rather than rise. A regex
matches one line at a time, so facts on separate lines could not express it; with all of them on
one line a pattern naming two fields is an AND. Both contradiction rules sit above both confirming
rules, so file order carries the caution the old code carried in a branch.

`firstMatch` is now scoped by source, defaulting to CONSOLE. The streams must not decide each
other's questions, and the default keeps every pre-stage-3 caller meaning what it meant.

**Not yet wired: `aggregateFor` still holds its own copy, so nothing changes in production.** That
is stage 4, and it carries a consequence worth deciding before it lands rather than discovering
after — see below.

**A CONFIRMED metadata rule is a widening, deliberately surfaced.** Today a Modrinth
`server_side: unsupported` yields MEDIUM confidence and is *not* published: `/as-properties` gates
on decisive boot evidence. Under "if a mod matches a rule, it is exclusion-worthy" the same signal
becomes CONFIRMED and would be published without any boot. That is what was asked for and the rules
are editable precisely so it can be tuned — set `enabled: false` on `platform-server-unsupported`
and `manifest-client-only` to keep publication boot-only. Flagging it because it changes what
reaches users' fallback lists, which is the highest-stakes output here.

One existing assertion changed: `onlyTheClientOnlyRuleConfirms` became
`onlyTheClientOnlyRuleConfirmsFromAConsole`, scoped to `RuleSource.CONSOLE`. Its subject was always
the console ladder; it could simply not say so while that was the only stream.

clientside 295/0 (288 before, +7), 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>
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>
Stage 4b-ii. `VerdictField.CONFIDENCE` becomes `VERDICT`, a `DECLARED` column joins it, and the rank
table follows. Because that enum is the single source for the table header, the CSV header, the query
key, the filter kind and the sort key, one declaration moves all five together — which is exactly why
the CSV and the table cannot drift into disagreeing about what "first" means.

The two columns belong side by side. A CONFIRMED row says a console proved the mod reaches
client-only code; `Declared` says whether the mod had claimed the server. That pairing is the finding
this engine exists to produce, and neither column states it alone.

Rank: CONFIRMED, INCONCLUSIVE, ERROR, CLEAR — the findings, then the consoles a new rule gets written
from, then the host's own problems, then the rows with nothing left to do.

An absent declaration renders blank rather than "UNKNOWN": every CurseForge project is in that state,
since the platform publishes no sideness at all.

**Ten existing tests changed, and the rename was not mechanical.** The ordering tests map by *rank
position* (HIGH→CONFIRMED, MEDIUM→INCONCLUSIVE, LOW→ERROR, INCONCLUSIVE→CLEAR) because what they
actually assert is the ordering, not the words. `ConfidenceSortRankTest` is renamed
`VerdictSortRankTest` after its subject, and its CSV cross-check now matches the Verdict column's own
*cell* rather than anywhere in the line: INCONCLUSIVE belongs to both the old and the new vocabulary,
so a loose `contains` would still find a stale value and quietly agree with itself.

Two self-inflicted detours worth recording, both caught by the compiler rather than by review: a
regex that double-applied and passed `verdict` twice, and a first pass that crashed part-way and left
one file half-migrated. Both were reverted with `git checkout --` and redone in a single pass. A bulk
rename across 19 files is exactly where a silent half-edit hides, which is why every step here ran the
suite rather than trusting the substitution.

grinder 455/0 (29 skipped), re-run with --rerun-tasks after wiping build/test-results.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Stage 5a, removing duplication I introduced myself. `BootObservation` was written in stage 1 before
the classifier's shape was in view, and `BootResult` already models exactly the same thing — so
`verdictOf` had been bridging them with a `when`, and passing a **fabricated `exitCode = 1`** because
`BootObservation.Crashed` demanded one that nothing had.

A fabricated value in a decision path is worth removing on its own: it reads as data and is not. The
exit code was never consulted, so nothing is lost by having no field to invent it into.

`VerdictPolicy.decide` now takes the classifier's own `BootResult`. CRASHED and INCONCLUSIVE share a
branch, which states the thing plainly: a crash no rule explained and a boot that ended for no
recognised reason say the same thing — the grind happened and taught us nothing — and neither is an
ERROR, because the container ran.

Behaviour-preserving: the collapsed mapping was 1:1, and no assertion's expected value changed. The
two policy tests changed only in the type they pass, which is the reference-only carve-out the
conventions describe, so this stays `refactor:`. One test was renamed to match what it now says —
`aTimeoutIsInconclusiveBecauseTheGrindDidRun` became `aBootThatRanButProvedNothingIsInconclusive`,
since the timeout is no longer a distinct case.

clientside 309/0.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
First of the re-cut that replaces one 33-file deletion (audit iteration 34, MED-1). The grinder drops
`GrindVerdict.confidence` and every read of it; `-clientside` still declares the type, so this commit
compiles and is green on its own.

Behaviour-preserving: nothing consulted `confidence` after `/as-properties` moved to
`Verdict.CONFIRMED`, so removing the field changes no decision. The stored JSON loses a key that was
already ignored on read.

**Two bridge sites are deliberate and temporary.** `LoaderVerdict.confidence` is a `-clientside` field
and still required here, so `GrindTestFixtures.loaderVerdict` and `ContainerCandidateVerifierReapTest`
keep supplying one, marked as such. The next commit deletes the field and both bridges with it. That
is the cost of a compiling intermediate, and it is the point: the alternative is the single sweep this
re-cut exists to undo.

Test fixtures that used two confidences to distinguish rows now use two verdicts; the distinguishing
values were recovered from the original diff rather than re-invented, since a sweep that flattened
them would leave those store tests green while proving nothing.

clientside 309/0, grinder 455/0 (29 skipped).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Second of the re-cut. With the grinder off the type, `-clientside` can delete it: the `Confidence`
enum, `LoaderVerdict.confidence`, and the `aggregateFor` fold that produced it. Breaking for anything
reading those, hence `!`.

`aggregateFor` also produced the report's **note**, so that moved rather than vanished. `verdictOf`
returns it now, carrying exactly the two observations the verdict alone cannot make: that a
*contradicted* server claim is what makes a confirmation interesting rather than routine, and that a
distribution-locked file was never readable at all — otherwise indistinguishable from a mod nobody
has got round to.

`supersededByLoader` takes a `VerdictAssessment` instead of a `Pair<Confidence, String?>`, so a
cross-loader disproof lands on INCONCLUSIVE: the crash is disproven, which means we learned nothing
about that loader, not that we learned something mild.

The two bridge sites the previous commit introduced are gone with the field they existed for.

`ClientsideVerifierServerSupportTest`'s five assertions were migrated by concern rather than by
rename, and one of them **inverted**: "a clean boot must not overturn a client-only declaration" was
true under the old precedence and is deliberately false under the new one, where the console decides.
It is kept as a test of the *new* rule, with the reversal stated in its doc rather than deleted
quietly — the claim is still recorded on the verdict, it simply no longer overrides the boot.

clientside 309/0, grinder 455/0 (29 skipped).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Third of the re-cut, and separated because it is a **behaviour fix, not part of deleting a type**
(audit iteration 34, MED-2). `GrinderAuditIT` parses `/export.csv` for a `Confidence` column and a
`HIGH` value, neither of which the export carries any more. It compiles perfectly and would have
failed against a live daemon — the class of defect the conventions ask to be surfaced in its own
commit rather than folded into a sweep, precisely because a sweep is where it disappears.

Now reads `Verdict` and grades `CONFIRMED` rows. `highConfidenceTuples` is renamed `confirmedTuples`
after what it returns.

Gated on `GRINDER_AUDIT_IT=1` and a live grinder, so nothing here exercises it — which is exactly why
it needed to be noticed by reading rather than by a red suite.

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>
Answers audit iteration 35, MED-1 — two stale references the previous fix left behind, both of which
it introduced.

The module doc said `stagingPrevented` "is set at the staging-refusal sites". True before the fix,
and wrong afterwards in the direction that matters: a reader adding a new never-ran path would
conclude the field was not their concern, which is precisely how the thrown-post-processor and
`RunResult.NotStarted` paths came to ship as INCONCLUSIVE. It now names all four, says which two were
missed and why that one mattered most, and points at where to pin a fifth — including the
counterweight that stops the flag swallowing real crashes.

`aThrownPostProcessorIsInconclusiveAndSkipsTheBoot` is renamed
`aThrownPostProcessorSkipsTheBootAndSurfacesTheCause`. Its assertions are unchanged and were always
correct — the `BootResult` really is INCONCLUSIVE — but the name described the old verdict semantics,
so anyone searching for "does a thrown hook produce an error?" would have found it and concluded the
opposite of the truth.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Answers audit iteration 36. The root CLAUDE.md's clientside row stated two contradictory rules in one
table cell: the 2026-09-01 "a clean boot cannot overturn a client-only declaration", and the
2026-09-04 "the console decides and the metadata only declares" that deliberately reversed it. The
file loads into every session and both sentences read as current, so whichever a reader met first won.

Marked as history rather than deleted, because **the finding that produced it survives the change in
what it produced**: a boot reaching its ready-line is the most expensive signal this engine makes and
must never read as "we learned nothing". That is now why `CLEAR` is its own verdict instead of
folding into INCONCLUSIVE, and `better-stats`/`tcdcommons`/`yacl` remain the measured rows behind it.

Count corrected 309 → 313, the four `PreventedGrindTest` guards. Caught by the instruction the count
carries — re-derive from `build/test-results`, do not trust the sentence — which it needed within one
commit of being written.

Also appends audit iterations 34, 35 and 36.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`advancement-plaques` was refused with "Required dependency unavailable for Forge / Minecraft 26.2:
prism", spending a `BootResult.INCONCLUSIVE` on a mod that never required prism. Its own
`META-INF/mods.toml` declares `prism` and `toastcontrol` `mandatory=false`, and Modrinth agrees.

The platform half was already right — Modrinth keeps only `dependency_type == "required"`, CurseForge
only `relationType == 3`. The manifest half never existed: neither `mandatory` nor `type` appeared
anywhere in `-api`, so `ModDependency` had no field to carry optionality and `stageableRequirements`
had nothing to filter on.

Both loader spellings are read by one reader, because `NeoForgeTomlScanner` overrides only the
descriptor's file name and NeoForge on Minecraft 1.20.2-1.20.4 still ships `mods.toml`. Absent means
required — NeoForge's own default, and the safe direction: a required dependency read as optional
boots a mod without what it needs and can publish a wrong verdict, while the reverse only refuses a
boot.

Optional dependencies are **flagged, not dropped**. Removing them from the scan would also remove
them from `ModListCompiler`'s dependency rescue and could strip mods from users' server packs,
against this module's own rule that dropping a needed mod breaks the pack while keeping a superfluous
one costs megabytes. The filter lives at the boot-staging consumer instead.

api 383 → 387, clientside 266 → 267.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
merge: the four-verdict result system
All checks were successful
Documentation / Writerside webhelp (push) Successful in 2m7s
Continuous / Build JAR (push) Successful in 17m55s
Docker Test / build image (push) Successful in 19m39s
Qodana / scan (push) Successful in 18m8s
Continuous / Build AppImage (x86_64) (push) Successful in 2m2s
Documentation / Help image (push) Successful in 3m34s
Continuous / Build AppImage (aarch64) (push) Successful in 2m38s
Qodana / notify (push) Successful in 19s
Continuous / Build Install4J Media (push) Successful in 7m21s
Test / build (push) Successful in 15m28s
Continuous / Continuous Pre-Release (push) Successful in 3m20s
aba2342143
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>
Reported from the live grinder: `iris` scored INCONCLUSIVE with
`java.lang.NoClassDefFoundError: org/lwjgl/Version` in its console. LWJGL is the client's windowing
and OpenGL binding, which a dedicated server never ships, so reaching it while the server starts is
as decisive as `net/minecraft/client` — arguably more so, since no environment failure can fabricate
it either.

**The cause was not rule ordering, and adding one would not have helped.** `boot-rules.example.json`
— the operator *template*, which the daemon never loads unless somebody copies it — carried
`lwjgl-on-a-dedicated-server` and `fml-invalid-dist`, while the bundled defaults carried neither.
Nothing matched the line at all, so there was nothing to order: it fell through every rung to the
bare exit code, which means "exited non-zero, nothing recognised why" and cannot confirm. Out of the
box, the engine had never caught either signature — this predates the four-verdict redesign rather
than being caused by it.

`fml-invalid-dist` earns its place for a second reason: FML prints "for invalid dist
DEDICATED_SERVER" when it refuses a client-only class, and NeoForge's ServerStarterJar can print that
crash in full and still **exit 0**, which the exit-code rung reads as inconclusive. A rule is what
makes the console outrank the status.

Pinned alongside the two orderings that must survive: a fair-run guard still outranks both (a mod
that never loaded cannot have been proven clientside), and both still outrank the excuses (a
clientside mod may also be missing a dependency).

`merelyNamingLwjglIsNotEvidence` is the counterweight — a server-side mod logging the word must not
be caught, so the pattern is anchored to the two loader-failure spellings rather than to the string.

`third-party-screen-class` stays an example deliberately: its own note says it is "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.

Red for the missing `BootDecision` constant only.

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>
`iris` scored INCONCLUSIVE with `java.lang.NoClassDefFoundError: org/lwjgl/Version` in its console.
LWJGL is the client's windowing and OpenGL binding, which a dedicated server never ships, so reaching
it while the server starts is as decisive as `net/minecraft/client`.

**Not an ordering problem.** No rule matched that line at all, so there was nothing to order: both
signatures shipped only in `boot-rules.example.json`, an operator template the daemon never loads.
The gap predates the four-verdict redesign — `iris` would have scored the same before it.

Both are now bundled defaults with rungs in the decisive band: above every excuse, below every
fair-run guard, and both orderings pinned. `fml-invalid-dist` also stops a zero exit hiding a crash,
since NeoForge's ServerStarterJar prints the refusal in full and exits 0.

clientside 314 → 321.

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>
merge: a dependency the candidate ships is never fetched or missing
All checks were successful
Documentation / Writerside webhelp (push) Successful in 1m54s
Continuous / Build JAR (push) Successful in 12m43s
Docker Test / build image (push) Successful in 18m18s
Qodana / scan (push) Successful in 17m28s
Continuous / Build AppImage (x86_64) (push) Successful in 2m14s
Documentation / Help image (push) Successful in 3m30s
Continuous / Build AppImage (aarch64) (push) Successful in 2m20s
Qodana / notify (push) Successful in 11s
Test / build (push) Successful in 14m16s
Continuous / Build Install4J Media (push) Successful in 8m10s
Continuous / Continuous Pre-Release (push) Successful in 3m2s
43330a9ca6
`xaeros-world-map` was refused with "Required dependency unavailable for Quilt / Minecraft 26.2:
xaerolib" while shipping `xaerolib` inside its own jar as `META-INF/jars/xaerolib-fabric-26.2-1.7.1.jar`.
Fabric and Quilt Loader load nested jars, so the requirement was satisfied before staging went looking.

**The near-miss is the mechanism.** A Modrinth project `xaerolib` exists, so the manifest id mapped —
but it publishes nothing tagged Quilt or 26.2, so nothing could be staged, and a mapped-then-unstageable
id refuses where an unmappable one would not have. Being almost resolvable was worse than being unknown.

**A class, not a quirk:** `sodium` bundles nine nested jars, `modmenu` one. Any bundled library that
also exists as a thinly-tagged standalone project reproduces this, each occurrence spending an
INCONCLUSIVE that overwrites whatever the store held.

Bundled wins unconditionally; only *declared* nested jars count, because a stray file under
`META-INF/jars/` is not on the loader's classpath and claiming it would skip staging something
genuinely needed.

Verified against the real `xaeroworldmap-fabric-26.2-1.45.0.jar`, not only fixtures.

clientside 321 → 329.

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>
`sodium` — a client renderer Modrinth marks `server_side: unsupported` — was published INCONCLUSIVE.
Its NeoForge boot crashed reaching LWJGL, and the other-version re-check then sampled a Fabric build
that booted cleanly, which `reconcileOtherVersionRecheck` treats as replacing the crash outright.

`BootDecision.provesClientOnly` marks the three rungs that are client-only evidence by construction.
Such a crash is not re-checked, not cleared by a survivor, not superseded by another loader — and
every loader of the project inherits CONFIRMED, because a mod's features do not change with the
loader. That last part matters concretely: the loaders carry different stems (`sodium-neoforge-` and
`sodium-fabric-`), so excluding only the proving loader would leave the other half shipping into
every server pack.

An *unexplained* crash is still disprovable by another loader — the `iron-chests` guard is untouched
and pinned. `OPERATOR_RULE` does not propagate despite being decisive.

clientside 329 → 337.

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>
`MinecraftClientMeta` and `MinecraftServerMeta` now build fresh collections and publish each in one
assignment to a `@Volatile` field holding an **unmodifiable** view. A reader sees the whole previous
state or the whole next one.

**This is a production defect, not a test artifact.** `VersionMeta` refreshes manifests on a
background coroutine — B31's ~392 ms startup win — while callers read, and both classes cleared and
refilled plain collections that `MinecraftMeta.serverReleases()` handed out directly. Reproduced with
the production path in its own stack trace: `VersionMeta$1.invokeSuspend → refreshManifests →
MinecraftMeta.update` throwing `ConcurrentModificationException`.

The silent half is the dangerous one. `BootVerifier.bootableCombination()` rebuilds its release set
from `serverReleases()` on **every staging call**, so a read landing between `clear()` and the
`add()`s yields an empty set, every candidate fails 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 — the failure mode this project keeps paying for.

**Unmodifiable views, not merely `List`-typed fields.** A `List` field still holds an `ArrayList` at
runtime, so a caller could cast and mutate the metadata's own state; the first attempt at this fix did
exactly that and the pin stayed red until the wrapper went in. The snapshot has to be a snapshot in
fact, not in the type.

**A second bug fixed as a side effect, and worth naming.** `MinecraftClientMeta.update()` cleared
`releases`, `snapshots` and `meta` but never `allVersions`, so every refresh appended the entire
manifest again — an unbounded, duplicate-filled list on any long-running process, which the grinder
is. Building fresh collections removes it without a separate change.

**Scope, stated rather than implied:** eight further classes in `versionmeta` share the
clear-then-refill shape (`ForgeLoader`, `NeoForgeLoader`, `FabricLoader`, `FabricInstaller`,
`QuiltLoader`, `QuiltInstaller`, `LegacyFabricInstaller`, `LegacyFabricVersioning`). They are read by
`LoaderVersionResolver` and carry the same race. This commit fixes the two that were demonstrated to
fail and that `serverReleases()` exposes; the rest are the same mechanical change and follow next,
recorded here so the gap is visible rather than forgotten.

api 387 → 389; full tree 1333/0 across five modules.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Extends the Minecraft pin to every loader meta. Eleven accessors go red across the eight classes that
still share the clear-then-refill shape — `ForgeLoader`, `NeoForgeLoader`, `FabricLoader`,
`FabricInstaller`, `QuiltLoader`, `QuiltInstaller`, `LegacyFabricInstaller`, `LegacyFabricVersioning`.

`LoaderVersionResolver` reads these on the same threads that read the Minecraft metas and the same
background coroutine refreshes them, so a fix covering only Minecraft would leave the identical race
behind a different accessor — which is precisely how it would come back.

`legacyFabric.supportedMinecraftVersions()` is the starkest: it is declared as returning a
`MutableList<String>`, so it does not merely leak the metadata's own state, it advertises it.

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>
Fixes a data race that had been producing verdicts about the engine's own timing. `VersionMeta`
refreshes manifests on a background coroutine — B31's ~392 ms startup win — while every `update()` in
`versionmeta` cleared and refilled plain collections that the accessors handed out directly.

A reader got either a `ConcurrentModificationException` or, silently, the empty window between the
`clear()` and the `add()`s. The silent half is the costly one:
`BootVerifier.bootableCombination()` rebuilds its release set from `serverReleases()` on every staging
call, so an empty read fails every candidate and refuses the boot with "No bootable
file/Minecraft/loader combination for <loader>" — a statement about the mod that was never about the
mod. Reproduced with the refresh coroutine in its own stack trace.

All ten classes now build fresh collections and publish each in one assignment to a `@Volatile` field
holding an unmodifiable view. Unmodifiable rather than merely `List`-typed: a `List` field still holds
an `ArrayList` at runtime, and the first attempt at the fix left the pin red for exactly that reason.

**Two further latent bugs fell out of the rewrite**, both recorded in their commits:
`MinecraftClientMeta.update()` never cleared `allVersions`, so every refresh appended the whole
manifest again — unbounded growth on any long-running process, which the grinder is; and
`NeoForgeLoader.update()` reversed the *published* map while iterating it, so a reader could see the
reversal half-applied.

Three published signatures narrowed (`MutableList`→`List`, `HashMap`→`Map`), hence the `!` on the
implementing commit; recorded in `claude-docs/API-BEHAVIOUR-CHANGES.md`.

api 387 → 403; full tree 1347/0.

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>
Answers audit iteration 38.

**HIGH-1 — the previous commit's `@Synchronized` did nothing for the case it was written for.**
`VersionMeta.update()` was locked, but the background refresh never calls it: `refreshManifests()`
invokes `minecraft.update()`, `fabric.update()`, `forge.update()` and the rest **directly**. So the
lock guarded the public caller and left the coroutine — the entire reason the finding existed —
unguarded, while the commit message claimed the race was serialised.

That is iteration 34's HIGH-2 shape repeated by me: a guard that looks like it covers a case and
cannot reach it. Both are instance methods of `VersionMeta`, so `@Synchronized` on `refreshManifests`
puts them on the same monitor and actually serialises them.

**MED-1 — the last four fixes are documented where a session will read them.** `BundledJars`,
`provesClientOnly`, the LWJGL/invalid-dist defaults and the snapshot rule existed only in commit
messages. Four landmines a reader is expected to respect — *only declared nested jars count*,
*an unexplained crash is still disprovable*, *ordering was not the problem*, *never hand out live
metadata* — now sit in the module files, with the near-miss mechanisms that make each of them subtle.

**LOW-1 — the two `!!` introduced while removing a vacuous-pass guard are gone**, replaced with
`requireNotNull`, which narrows in one step.

Recorded in the module docs because it is the kind of thing a future fix will get wrong the same way:
locking a public entry point proves nothing about the path a background job actually takes.

Full tree 1347/0.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Answers audit iteration 39, LOW-1. `minecraft.clientSnapshots()` and `minecraft.serverSnapshots()`
return the same snapshots as the three accessors already pinned and were simply not listed. Nothing
was broken; the set was arbitrary rather than reasoned, and an accessor added beside them would have
inherited the gap.

The set is now "every list accessor on `MinecraftMeta`", which is a rule a reader can apply, rather
than a list they have to trust was complete.

api 403 → 405; full tree 1349/0.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
merge: three audit passes over the post-redesign fixes
All checks were successful
Documentation / Writerside webhelp (push) Successful in 1m33s
Continuous / Build JAR (push) Successful in 12m18s
Qodana / scan (push) Successful in 13m1s
Docker Test / build image (push) Successful in 15m49s
Continuous / Build AppImage (x86_64) (push) Successful in 1m56s
Documentation / Help image (push) Successful in 3m10s
Continuous / Build AppImage (aarch64) (push) Successful in 2m25s
Qodana / notify (push) Successful in 10s
Continuous / Build Install4J Media (push) Successful in 8m12s
Continuous / Continuous Pre-Release (push) Successful in 3m37s
Test / build (push) Successful in 26m43s
afb512e00b
Iterations 37-39, each finding fixed before the next ran.

**37** — `BundledJars` spooled every nested jar to a temp file with `deleteOnExit()`, whose static
registry never shrinks: per staged jar, per boot attempt, per candidate, on a daemon that runs for
weeks. Fixed by removing the spool entirely — a `ZipInputStream` reads the nested descriptor with no
file at all. Also: a superseded `ERROR` kept its reason, `update()` took a lock, and two assertions
that could pass vacuously were made unconditional.

**38** — that lock did nothing. `refreshManifests()` calls each meta's `update()` **directly** and
never goes through `VersionMeta.update()`, so the background coroutine — the entire reason for the
finding — was still unguarded while the commit claimed otherwise. Iteration 34's HIGH-2 shape,
repeated. Both now share one monitor. The four preceding fixes were also documented in the module
files, where a session actually reads them.

**39** — no HIGH or MEDIUM. The lock was verified to reach both paths by reading both declarations,
and the race confirmed reachable in production rather than in theory: `VersionRefreshSchedule` is a
Spring cron job calling `update()`, so a scheduled refresh could overlap the startup coroutine. One
completeness nit fixed — the metadata pin now covers every Minecraft list accessor.

Verified against real artifacts, not only fixtures: the streamed reader extracts all nine nested ids
from the live `sodium` jar, and they are exactly the Fabric API modules this repo documents as the
most-commonly-missing dependency class.

Full tree 1349/0 across five modules.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`architectury-api`, `enchantment-descriptions` and `waystones` refused with "weird strings" as the
missing dependency. They are **platform refs**: `unsatisfied.add(dependencyRef)` records whatever
`ModFile.requiredDependencies` holds, which is Modrinth's opaque base62 `project_id` or CurseForge's
bare numeric id.

Measured against the live API:

  - `enchantment-descriptions` requires `uy4Cnpcm` and `aaRl8GiW` — **bookshelf-lib** and **prickle**
  - `waystones` requires `bi4iCmsw` and `MBAkmtvl` — **shogi** and **balm**

**`waystones` is the sharpest demonstration.** Its own `neoforge.mods.toml` declares `balm` and
`shogi` in plain words, and the manifest half of staging reports them that way — while the platform
half reports the very same two mods as `MBAkmtvl` and `bi4iCmsw`. One refusal, two vocabularies, one
unreadable.

Not merely cosmetic: `unsatisfied` is a `Set<String>`, so a mod missing by both routes is **two**
entries today and one once both halves speak slugs.

The unresolved case keeps the ref, because it is all we have, but must say what it is — otherwise a
reader cannot tell an opaque id from a mod whose name simply looks strange, which is the confusion
that produced this report.

Red for the missing `unsatisfiedLabel` only.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`BootVerifier.unsatisfiedLabel` resolves a platform ref to the resolved project's slug, and keeps the
ref plus the platform name only when nothing resolved. Answers the report that `architectury-api`,
`enchantment-descriptions` and `waystones` refused with "weird strings".

The strings were platform refs. `unsatisfied.add(dependencyRef)` recorded whatever
`ModFile.requiredDependencies` held — Modrinth's opaque base62 `project_id`, CurseForge's bare number
— so an operator was handed `uy4Cnpcm` where the mod is called **bookshelf-lib**.

**`waystones` is the case that makes it a defect rather than a cosmetic gripe.** Its own
`neoforge.mods.toml` declares `balm` and `shogi` in plain words, so the *manifest* half of staging
already reported them readably while the *platform* half reported the same two mods as `MBAkmtvl` and
`bi4iCmsw`. One refusal, two vocabularies for one dependency.

**The deduplication is the substantive part.** `unsatisfied` is a `Set<String>`: a mod missing by both
routes was two entries and is now one, so the refusal stops overstating how much is missing.

The unresolved case keeps the ref — it is genuinely all we have — but names the platform, so a reader
can look it up rather than mistake an id for a mod whose name merely looks strange. That confusion is
what produced the report.

The `no <loader> file for Minecraft <version>` warning now logs the slug *and* the ref, since the log
is where someone goes to check the platform page.

Evidence is the live API, resolved on 2026-09-04: `uy4Cnpcm`→bookshelf-lib, `aaRl8GiW`→prickle,
`bi4iCmsw`→shogi, `MBAkmtvl`→balm. Landmine recorded in the module file.

clientside 337 → 341; full tree 1353/0.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
merge: a missing dependency is named, not identified
All checks were successful
Documentation / Writerside webhelp (push) Successful in 1m59s
Continuous / Build JAR (push) Successful in 12m51s
Docker Test / build image (push) Successful in 17m43s
Qodana / scan (push) Successful in 15m59s
Continuous / Build AppImage (x86_64) (push) Successful in 1m44s
Documentation / Help image (push) Successful in 3m30s
Continuous / Build AppImage (aarch64) (push) Successful in 1m49s
Qodana / notify (push) Successful in 12s
Test / build (push) Successful in 15m39s
Continuous / Build Install4J Media (push) Successful in 8m11s
Continuous / Continuous Pre-Release (push) Successful in 3m9s
bd014aec82
Refusals reported Modrinth's opaque `project_id` (or CurseForge's numeric id) instead of the mod's
name — `uy4Cnpcm` for what everyone calls **bookshelf-lib**. `waystones` showed the shape best: its
manifest declares `balm` and `shogi` in words, so one half of staging reported them readably while the
other reported the same two mods as `MBAkmtvl` and `bi4iCmsw`.

Resolved refs are now named by slug, which also collapses the duplicate — `unsatisfied` is a set, so a
mod missing by both routes was two entries and is now one. An unresolved ref keeps the ref and names
its platform, so it reads as a lookup key rather than as a strange mod name.

clientside 337 → 341; full tree 1353/0.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Reported live: *"Required dependency unavailable for Quilt / Minecraft 1.20.4: 306612"* — still a raw
platform ref, where `306612` is CurseForge's id for **Fabric API**, the ref this module already
documents as the most-dropped one.

**The previous fix covered two of three branches.** `downloadWithDependencies` records an unmet
dependency in three places: the ref did not resolve, it resolved but published no usable file, and —
the one missed — it resolved, a file *was* picked, and the download then failed. That third branch
still added the bare ref, and `dependencyProject` is in scope there the whole time.

A second defect sits on the same line. **A distribution-locked dependency is not a download failure.**
CurseForge publishes no `downloadUrl` when an author opts out of third-party distribution, so
`JarDownloader` returns `null` and the dependency reads as "could not be downloaded" — the sentence a
404, a flaky link and a deliberate opt-out all produce. The *candidate* half of staging learned that
distinction when `downloadFailureDetail` was written; the dependency half never did, so an
unobtainable dependency looks like a transient failure worth retrying.

Red for the extended signature only.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Answers the follow-up report, *"Required dependency unavailable for Quilt / Minecraft 1.20.4:
306612"* — CurseForge's id for Fabric API.

**The previous fix caught two of three branches.** `downloadWithDependencies` records an unmet
dependency when the ref does not resolve, when it resolves but publishes no usable file, and when it
resolves, a file *is* picked, and the download then fails. The third still added the bare ref, with
`dependencyProject` in scope the whole time. All three now go through `unsatisfiedLabel`.

**And a locked dependency now says so.** CurseForge publishes no `downloadUrl` when an author opts
out of third-party distribution, so `JarDownloader` returns `null` and the dependency reported as
"could not be downloaded" — the sentence a 404, a flaky link and a deliberate opt-out produce
identically. That is the conflation `downloadFailureDetail` fixed for the *candidate*; the dependency
half never learned it, so an unobtainable dependency looked like something worth retrying. The label
reads `fabric-api (distribution-locked on CurseForge)` and the warning says the same.

Worth stating plainly: I fixed two branches last time and asserted the problem was solved, when a
third was sitting four lines below the two I edited. The pin now covers all three, and the module doc
says how many there are so a fourth gets labelled rather than discovered in a report.

clientside 341 → 344; full tree 1356/0.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
merge: the third refusal branch names its mod, and locked means locked
All checks were successful
Documentation / Writerside webhelp (push) Successful in 2m11s
Continuous / Build JAR (push) Successful in 12m9s
Qodana / scan (push) Successful in 10m19s
Docker Test / build image (push) Successful in 16m28s
Documentation / Help image (push) Successful in 4m59s
Continuous / Build AppImage (x86_64) (push) Successful in 2m24s
Continuous / Build AppImage (aarch64) (push) Successful in 1m44s
Qodana / notify (push) Successful in 14s
Continuous / Build Install4J Media (push) Successful in 7m55s
Test / build (push) Successful in 15m30s
Continuous / Continuous Pre-Release (push) Successful in 4m23s
5b18655579
`306612` still reached a refusal because `downloadWithDependencies` has three places that record an
unmet dependency and the previous fix taught two of them to say the slug. The third — resolved, file
picked, download failed — kept the raw ref.

A distribution-locked dependency also now says it is locked rather than reading as a failed download,
the distinction `downloadFailureDetail` already draws for the candidate. Retrying an author's opt-out
never succeeds, and the report should not imply it might.

clientside 341 → 344; full tree 1356/0.

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>
`pickDependencyFile` now prefers obtainable files, and obtainability outranks the loader match.

`pickForLoader` took the first file matching loader and Minecraft version and never asked whether it
had a `downloadUrl`. A distribution-locked build — the author's opt-out, for which CurseForge
publishes no URL — was therefore picked over an obtainable one, `JarDownloader` returned `null`, and
the dependency was reported unmet with an obtainable file sitting directly behind it. That is the
cause behind "Required dependency unavailable for Quilt / Minecraft 1.20.4: 306612".

**Obtainability outranks the exact-loader preference, which is the part worth arguing.** Quilt runs
Fabric mods, so an obtainable Fabric build is a working dependency while a locked Quilt build is
nothing at all. Leaving the loader preference on top let a locked exact match shadow the Quilt-to-Fabric
fallback — a fallback that exists precisely because libraries like Fabric API publish Fabric-only
files. The loader preference still applies among obtainable files, which the pins hold.

**Preference, never filter.** When every candidate is locked the final arm still returns one, so the
refusal reads `fabric-api (distribution-locked on CurseForge)` rather than "publishes no Quilt file
for Minecraft 1.20.4" — the first is true and tells an operator retrying is pointless, the second is
simply false. Returning `null` where a file exists trades a diagnosable refusal for a misleading one,
the same reason the version constraint is a preference in this function.

This is the third layer of one report: the label named the mod, the branch that recorded it was
missed, and this is why the download failed in the first place.

clientside 344 → 349; full tree 1361/0.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
merge: prefer a dependency file that can actually be downloaded
All checks were successful
Documentation / Writerside webhelp (push) Successful in 1m55s
Continuous / Build JAR (push) Successful in 11m57s
Docker Test / build image (push) Successful in 17m34s
Qodana / scan (push) Successful in 16m59s
Continuous / Build AppImage (x86_64) (push) Successful in 2m16s
Continuous / Build AppImage (aarch64) (push) Successful in 1m56s
Documentation / Help image (push) Successful in 5m44s
Qodana / notify (push) Successful in 14s
Test / build (push) Successful in 14m40s
Continuous / Build Install4J Media (push) Successful in 7m16s
Continuous / Continuous Pre-Release (push) Successful in 3m37s
867c0d9ebd
`pickDependencyFile` never asked whether a file had a `downloadUrl`, so a distribution-locked build
was picked over an obtainable one and the dependency was reported unmet — the cause behind
"Required dependency unavailable for Quilt / Minecraft 1.20.4: 306612".

Obtainability now outranks even the exact-loader preference: Quilt runs Fabric mods, so an obtainable
Fabric build is a working dependency where a locked Quilt build is nothing, and a locked exact match
was shadowing the fallback that exists for exactly this. Still a preference — an all-locked project
still yields a file, so the refusal can say "distribution-locked" instead of the false "publishes no
Quilt file".

clientside 344 → 349; full tree 1361/0.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Reported live: "Loader install for Quilt 0.31.0-beta.3 / Minecraft 1.20.6 failed, so the pack could
not be completed." `CachedLoaderVersions.firstInstallableVersion` already walks `availableVersions`
for the first build not on install cooldown — but the supplier returned `emptyList()` for Fabric,
Quilt and LegacyFabric, so there was nothing to walk and the tuple stayed dead for every candidate
wanting it.

**The premise behind that empty list was wrong.** It read: they "ship a single Minecraft-independent
loader line, so there is no sibling build to fall back to". True of *per-Minecraft* builds — Quilt
publishes no 1.20.6-specific loader the way Forge does — but the loader **line** is versioned. Measured
against the live metadata: Quilt publishes **306** builds and Fabric **253**, and Quilt's
`/v3/versions/loader/1.20.6` lists all 306 as valid for that Minecraft. There are 305 siblings.

**Prevention was ruled out before writing this, which is why the fix is recovery.** Every published
source says the failing combination is fine: it is in the per-Minecraft list, the intermediary exists,
and `.../loader/1.20.6/0.31.0-beta.3/server/json` answers 200. The start scripts' own checks are the
same signal `LoaderVersionResolver` already gates on — and Fabric's `server/json` 400 tracks *Minecraft
support*, not the pairing, since an ancient loader with the newest Minecraft still answers 200
(measured: 0.12.12 + 1.21.1 → 200; newest 0.19.5 + 1.12.2 → 400). Nothing in metadata predicts an
installer that fails to run.

Pinned with a **real `LoaderCache` driven through a real failing installer**, so the cooldown under
test is the production one rather than a fake that merely agrees with it. `latestVersion` staying
truthful is pinned too: the support gate and the crash re-check must keep measuring against the real
newest, or a crash on a stepped-down build would be "re-checked" against itself.

Red for the missing `LoaderStepDown` only.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`knownLoaderVersionsNewestFirst` now returns the Fabric, Quilt and LegacyFabric loader lines, so
`CachedLoaderVersions` can fall back when the newest build refuses to install. It returned
`emptyList()` for those three, so "Loader install for Quilt 0.31.0-beta.3 / Minecraft 1.20.6 failed"
had nothing to fall back to and every candidate wanting that tuple took the failure.

**The premise was wrong, not the mechanism.** The empty list read "a single Minecraft-independent
loader line, so there is no sibling build" — which conflates *per-Minecraft builds* with the *loader
line*. Quilt publishes 306 builds, Fabric 253, and Quilt's `/v3/versions/loader/1.20.6` lists all 306
as valid for that Minecraft. There were 305 siblings the whole time.

**Recovery rather than prevention, and that order was established by measurement.** Every published
source calls the failing combination valid — it is in the per-Minecraft list, the intermediary exists,
and `.../loader/1.20.6/0.31.0-beta.3/server/json` answers 200. The start scripts' own checks are the
same signal `LoaderVersionResolver` already gates on, and Fabric's 400 tracks Minecraft support rather
than the pairing (`0.12.12` + 1.21.1 → 200; newest `0.19.5` + 1.12.2 → 400). Nothing in metadata
predicts an installer that fails to run, so there is no pre-check to add.

`LoaderStepDown.newestFirst` filters nothing on purpose. The head of Quilt's line is four consecutive
betas and SPC cannot tell: its manifest reports `release: 0.31.0-beta.3` as well as `latest:` — upstream
marks the beta as the release — so "prefer stable" is not derivable here, and a pre-release filter
could empty the line exactly when the fallback is needed. The caller stops at the first build not on
cooldown, so a longer list costs nothing; that tuple converges on `0.30.1` after the dead betas each
take one cooldown.

Pinned with a real `LoaderCache` driven through a real failing installer, so the cooldown under test is
the production one. `latestVersion` stays truthful, which the pins hold — the support gate and the
crash re-check must keep measuring against the real newest.

grinder 455 → 460; full tree 1366/0.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The step-down that rescues Forge/NeoForge from an uninstallable build was disabled for
Fabric/Quilt/LegacyFabric because they "ship a single Minecraft-independent loader line, so there is
no sibling build". The loader *line* is versioned: Quilt publishes 306 builds, Fabric 253, all listed
as valid for a given Minecraft. Quilt 0.31.0-beta.3 / 1.20.6 failed to install with nothing to fall
back to.

Prevention was ruled out first by measurement — every published source calls that combination valid,
and the start scripts' checks are the same Minecraft-support signal the resolver already applies — so
recovery is the available lever.

grinder 455 → 460; full tree 1366/0.

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>
merge: hold the loader-line wiring with a guard that can reach it
All checks were successful
Documentation / Writerside webhelp (push) Successful in 2m0s
Continuous / Build JAR (push) Successful in 12m37s
Qodana / scan (push) Successful in 10m59s
Docker Test / build image (push) Successful in 15m27s
Documentation / Help image (push) Successful in 4m0s
Continuous / Build AppImage (x86_64) (push) Successful in 1m38s
Continuous / Build AppImage (aarch64) (push) Successful in 2m33s
Qodana / notify (push) Successful in 14s
Continuous / Build Install4J Media (push) Successful in 7m53s
Test / build (push) Successful in 15m11s
Continuous / Continuous Pre-Release (push) Successful in 4m31s
fa52c0cd01
The step-down pin injected its own `availableVersions`, so it never touched
`knownLoaderVersionsNewestFirst` — the one function the fix changed — and would have passed before the
fix. Zero tests reached it.

Now held by a source-level wiring assertion, the pattern this module already uses for joins that need
an `ApiWrapper` and cannot be executed. Proven by mutation: removing the Quilt branch turns it red and
names the missing line.

grinder 460 → 461; full tree 1367/0.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
**Green from the start, and deliberately so** — this is a characterization test, not a red pin. The
report needs a second, narrower entry than `suggestedEntry`, and `FilenameStemDeriver.deriveStem`
already produces it when handed a single file. Pinning that before building a column on top is the
conventions' "pin current behaviour before restructuring", not a TDD pin with the red omitted.

`iris` is the reported case and shows why one column was not enough. Measured against the live API:

  | loader   | files | published stem   |
  |----------|-------|------------------|
  | Fabric   | 191   | `iris-`          |
  | NeoForge | 42    | `iris-neoforge-` |
  | Quilt    | 143   | `iris-`          |

`suggestedEntry` is the longest common prefix over a project's **whole history**, which is correct for
a `startsWith` fallback list — it has to match every build ever published. iris's oldest Fabric files
are `iris-mc1.16.5-1.0.0.jar`, from before the loader went into the name, so that prefix collapses to
`iris-`. NeoForge kept `iris-neoforge-` only because it has no such history: all 42 of its files carry
the loader.

Sampling one file keeps whatever that file is called, which is the whole mechanism: `iris-fabric-`,
`iris-neoforge-`. And a Quilt row shows `iris-fabric-`, because Quilt boots Fabric builds — the
pattern describes the file, not the label on the row.

**One assumption of mine was wrong and the test corrected it**: I expected
`iris-mc1.16.5-1.0.0.jar` to yield `iris-mc`. It yields `iris-`, because `mc` is stripped as the
Minecraft marker it is. Documented behaviour, now asserted.

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>
Reported: iris returns `iris-` (Fabric), `iris-neoforge-` (NeoForge), `iris-` (Quilt) — three rows
where two do not say which artifact was examined, and one names a loader the row does not.

`suggestedEntry` is the longest common prefix over a project's **whole history**, and has to be: it is
what `/as-properties` publishes and the fallback list matches it with `startsWith`, so it must cover
every build ever released. iris's oldest Fabric files are `iris-mc1.16.5-1.0.0.jar`, from before the
loader went into the name, so that prefix collapses to `iris-`. NeoForge kept its token only for want
of such history — all 42 of its files carry it.

So the fix is a second column rather than a change to the first: `filenamePattern` runs
`FilenameStemDeriver.deriveStem` over the **sampled file alone**, where there is no older naming
convention to erode the loader out. `FilenameStemDeriver` needed no change; this is plumbing from
`ClientsideVerifier`'s existing `sample` through `LoaderVerdict` and `GrindVerdict` to one new
`VerdictField`, which both the HTML table and the CSV derive their columns from.

A Quilt row now reads `iris-fabric-`, and that is correct rather than a leak: Quilt boots Fabric
builds, and the column describes the file, not the row's label. It is exactly what a maintainer needs
to check a finding against the platform page, which the broad stem cannot do.

**What is deliberately unchanged:** `/as-properties` still serves `suggestedEntry`. Publishing the
narrow pattern would stop excluding every build the narrow form misses — for iris, its whole pre-2022
history. `theFilenamePatternIsNotWhatGetsPublished` fails the build if that ever swaps.

Four existing assertions name the column set and had to change — two CSV header literals, the
`ReportServer` header prefix, and the renderer's per-column sentinel list. That is the stop-and-flag
signal behaving correctly, and why this is `feat:` and not `refactor:`. The renderer guard got its own
`SENTINELFILENAME` rather than a bumped count, since counting is precisely what it exists not to do.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The `Filename` column is easy to mistake for a better `Name-pattern`, and acting on that would break the
published fallback list quietly — a mod would simply stop being excluded for the builds the narrow
pattern misses, with nothing failing. So the distinction is landmined in the clientside module file
where `LoaderVerdict` lives, summarised in the grinder's report section, and the measurement that
produced it (iris: 191 Fabric files against 42 NeoForge, and why only the latter kept its loader token)
is in the refactor log.

Status-table counts re-derived from this run's `build/test-results/test/*.xml`, not incremented:
clientside 314 → 354, grinder 455 → 465.

No `API-BEHAVIOUR-CHANGES.md` row: `-clientside` is not published to Maven, so `LoaderVerdict` gaining a
defaulted field is not an embedder-visible contract change.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
merge: name the artifact a verdict sampled, not just the pattern it publishes
All checks were successful
Documentation / Writerside webhelp (push) Successful in 1m47s
Continuous / Build JAR (push) Successful in 12m37s
Qodana / scan (push) Successful in 13m9s
Docker Test / build image (push) Successful in 16m11s
Continuous / Build AppImage (x86_64) (push) Successful in 1m43s
Documentation / Help image (push) Successful in 2m58s
Continuous / Build AppImage (aarch64) (push) Successful in 2m34s
Qodana / notify (push) Successful in 14s
Continuous / Build Install4J Media (push) Successful in 8m59s
Test / build (push) Successful in 14m30s
Continuous / Continuous Pre-Release (push) Successful in 3m36s
e3cd87eaa3
iris rendered `iris-` (Fabric), `iris-neoforge-` (NeoForge) and `iris-` (Quilt) — three rows where two
named no loader. The deriver was right: `suggestedEntry` is the common prefix over a project's whole
history because that is what `/as-properties` publishes and matches with `startsWith`, and iris's oldest
Fabric jars pre-date the loader token. So this adds a second column derived from the sampled file alone,
and guards that the broad one is still what gets published.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Red on three of four, with the live values verbatim: `306612` and `P7dR8mSH` where `fabric-api` was
expected. The fourth (an unresolvable ref) passes, which is what proves the fixture is sound rather than
the guard being broken.

Reported twice, and the second report is the interesting one. `unsatisfiedLabel` was written to name a
resolved project by its slug, and `DependencyLabelTest` proves it does — by handing it a `ProjectFiles`
the test built with the slug already correct. Production never builds one of those: **both** platforms'
`resolveDependency` pass `nativeRef` into the `slug` parameter positionally, so the label resolves the
project, reads back the ref it started from, and prints it. The earlier fix was a no-op for the branch
that actually fires.

Measured on the live daemon today, both `ERROR` on CurseForge:

  architectury-api  Quilt / MC 1.20.4   "Required dependency unavailable ... 306612"   (Fabric API)
  waystones         Forge / MC 1.21.11  "Required dependency unavailable ... 531761"   (Balm)

**The lesson is the test boundary, not the bug.** A unit test that constructs the value under test cannot
see a producer constructing it wrongly — the same shape as the loader step-down whose pin injected the
very versions it was meant to prove were fetched. So these drive the real `resolveDependency` with canned
JSON, and `theRefusalNamesTheModAcrossBothPlatforms` asserts the composition: it is the only arrangement
in which a positional-argument slip in either platform fails a test.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Red for the missing parameter. This is the harmful half of the `architectury-api` report — the
unreadable `306612` was cosmetic, this refused a boot that should have run.

`resolveDependency` reads a single page of 50 files, deliberately: a dependency needs *a* usable file,
not a history, and paging every dependency of every candidate would multiply the API key's quota. But it
asked for the newest 50 **unfiltered**, and CurseForge returns those newest-first across every loader and
every Minecraft version. For a library that publishes constantly, the window never reaches back: Fabric
API has well over a thousand files there, so its newest 50 are all current Minecraft.

Live, 2026-09-04: `architectury-api` scored ERROR on Quilt / Minecraft 1.20.4 with *"Required dependency
unavailable … 306612"*. Fabric API has shipped 1.20.4 builds since December 2023 — the file exists; we
asked in the wrong window. And a staging refusal publishes ERROR over whatever the store held.

The fixture reproduces the API's real shape: the unfiltered page holds only current-Minecraft builds and
the older one is reachable only by asking for it.

**`modLoaderType` is pinned as *not* sent**, though the API supports it. Filtering to Quilt would hide
Fabric API's Fabric-tagged files — precisely the fallback `BootCandidateSelector.fallbackLoaders` exists
for, with Fabric API as its canonical case. Version narrows the set; the loader stays in the selector.

Parameters verified against https://docs.curseforge.com/rest-api/ for `/v1/mods/{modId}/files`:
`gameVersion`, `modLoaderType`, `gameVersionTypeId`, `index`, `pageSize`.

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>
Two landmines worth more than the fixes themselves: `resolveDependency` reads a single page and is only
correct once narrowed by `gameVersion`, and `modLoaderType` — which the API does support — must never be
sent, or the Quilt-to-Fabric fallback loses the files it exists to find.

Also records the reusable lesson: a unit test that constructs the value under test cannot see a producer
constructing it wrongly, which is why the first labelling fix passed its tests and changed nothing in
production.

clientside 354 → 362, re-derived from build/test-results.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
architectury-api published ERROR for "Required dependency unavailable ... 306612". Two defects behind
one sentence: both platforms passed the ref into ProjectFiles' slug parameter positionally, so the label
resolved the project and read back the ref; and the dependency lookup read CurseForge's newest 50 files
unfiltered, which for Fabric API is all current Minecraft, so a 1.20.4 boot was refused for a file that
has existed since December 2023.

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 the two cases that matter: after `beginPass(2, …)` the counters still read the first pass's work
(1 instead of 0, 6 instead of 1).

`verified`, `failed` and `skippedFresh` are documented on `StatusSnapshot` as "this pass" and are rendered
by `StatusDashboardRenderer` directly beneath `Pass N (M candidates)` — which really is per-pass. They
were neither: three `AtomicInteger`s named `*Total`, incremented for the daemon's whole life, that
`beginPass` never reset. A dashboard therefore read "Pass 12 (25 candidates)" above "Verified 3,140", and
the ratio those two invite is meaningless.

Per-pass is the reading kept because it is what both the documentation and the only rendering of these
numbers already promise, and because it answers the question the block exists for: *is the pass now
running getting anywhere?* A lifetime verdict count is already available, and more accurately, from the
store — `/status` reports it as `verdicts`.

`uptimeSeconds` and `startedAt` are pinned as **not** pass-scoped in the same file, so the reset cannot
grow to cover them.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`beginPass` now zeroes `verified`, `failed` and `skippedFresh`, and the fields are renamed `*ThisPass`
so their scope is stated where they are declared rather than only where they are published.

They were lifetime totals published under per-pass documentation and rendered beneath
`Pass N (M candidates)`, so the dashboard invited a ratio between a whole run's work and one pass's slice.

`startedAt` and `uptimeSeconds` are deliberately untouched: the daemon's uptime is lifetime, and the
guard pins that so this reset cannot grow to cover them. Operators wanting a lifetime count still have
`verdicts` on the same document, which is better than these ever were because the store survives restarts.

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>
Red for the missing `awaitWithin`.

`GrinderApplication`'s shutdown hook budgets one `SHUTDOWN_GRACE` window across the whole stop: containers
first, then whatever is left to the workers, floored so an interrupt can still be observed. `close()` was
the one step in that budget that could not expire — it blocked on `Future.get()` with no timeout. The
per-container `stopContainerCmd.withTimeout(...)` bounds Docker's *internal* SIGTERM-to-SIGKILL window,
not the HTTP call that asks for it, so a wedged daemon socket parks the shutdown hook until systemd's
`TimeoutStopSec` fires — the SIGKILL that orphans containers, which is the outcome `close()` exists to
prevent.

Pinned as a pure helper rather than through the engine. [DockerJavaContainerEngine] needs a live daemon and
this module carries no mocking library, so the alternative was hand-writing a stub of an 80-method
interface; the decision that was wrong — *wait for these, but not past here* — needs no Docker at all.

Timing assertions are loose on purpose: what is asserted is the outcome and that it returns nowhere near
the blocked task's own duration. A tight margin would buy nothing but flakiness on a loaded box. One case
pins that four blocked tasks cost **one** budget between them, not one each, which is the shape that
turns a bounded wait back into an unbounded one.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`close()` waits through `awaitWithin(pending, shutdownGrace)` instead of an untimed `Future.get()`, and
says so in the log when the budget expires.

It was the only step in the shutdown hook's budget that could not expire. `stopContainerCmd.withTimeout()`
bounds Docker's internal SIGTERM-to-SIGKILL window, not the HTTP call that asks for it, so an unresponsive
daemon socket held the hook open until `TimeoutStopSec` fired — and that SIGKILL orphans the containers
`close()` exists to collect, turning the safety net into the failure.

**One budget across the whole set, not one per task**, which is the distinction that matters: a per-task
timeout multiplied by the abandoned containers is an unbounded wait wearing a limit. A task still running
when the budget is spent is left to `shutdownNow`; its container keeps the owner label and `reapOrphans`
collects it on the next start, which is the path already designed for a killed run. The warning names that
so an operator reading the journal knows the recovery is automatic.

A task that *threw* counts as finished — the drain cares whether it is still waiting, and the failure was
logged where it happened.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Red with three files named, one per failed write:
`[requeue1672910610270244357.json, requeue17294795878435009850.json, requeue4021562841657233531.json]`

`write` creates a temp file, fills it, moves it over the queue, and wraps the lot in `runCatching` because
a queueing problem must never stop a grind. The swallow is right; what was missing is that a failure
between "created" and "moved" left the temp file where it fell. `JsonVerdictStore` gets away with the same
shape because it writes to a fixed `<name>.tmp` and overwrites its own debris — this one asks for a fresh
random name every call, so failures accumulate one file each, forever, in a directory the reaper is
deliberately not entitled to touch (the queue is operator-authored state, not scratch).

The failure is provoked the only portable way: make the destination a **directory**, so the move can never
replace it. No permission games, no root-only setup, identical on every filesystem.

The other two cases pin what must not change — a failing queue still does not throw, and the successful
path still leaves exactly the queue.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Turns `aFailedWriteLeavesNoTemporaryFile` green — three leaked files became none.

The temp file is now created, used and deleted under `try`/`finally`. After a successful move the delete
is a no-op; after a failure it is the only thing that removes it. `JsonVerdictStore` survives the same
shape because it writes to a fixed `<name>.tmp` and overwrites its own debris — this one asks for a fresh
random name every call, so each failed write left one more file, in the queue directory, which the reaper
is deliberately not entitled to sweep because that is operator-authored state.

The `runCatching` around the whole write stays: a queueing problem must not stop a grind. That contract is
why the debris went unnoticed, not why it was there.

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>
Two small things the audit turned up, both the same shape — a name or a lifecycle that reads as correct
until you look twice.

**`store.close()` instead of `store.flush()`** in the shutdown hook. `JsonVerdictStore` declares
`AutoCloseable` and nothing ever honoured it, so the flusher executor was never stopped. `close()` is
`shutdownNow()` then `flush()`, which is strictly the better order here: the scheduled tick can no longer
race the final write. The flush remains the load-bearing half — writes are coalesced, so without it every
verdict since the last tick is lost on an orderly stop — and the failure message still says so.

**`queueBlamedDependencies`' local `store` renamed to `queue`.** It bound a `RequeueStore` over the class's
own `VerdictStore` property, in the one class that holds both, so two reads three lines apart looked like
the same collaborator. Behaviour untouched; the comment says why the obvious name is the wrong one here.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The provenance one above all: a cache-hit check must ask `isInstalled`, never `markUsed` alone, and the
reason it went unnoticed for so long — template changes fail silently, and the only test that could have
caught it asserted the predicate rather than the caller. Recorded with the pin style that does catch it
(installer call count), since a marker assertion passes against the broken code.

Also landmined: the per-pass counters and what must stay lifetime; the knob-coercion contract and which
boundary values are deliberately legal; and that every wait in the shutdown path is budgeted, with the
one-budget-not-one-per-task distinction spelled out.

Grinder count 465 → 490, re-derived from build/test-results.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Template provenance was write-only: LoaderCache.isInstalled compares a cached tuple's start-script digest
against the current one and had no production caller at all, because ensureInstalled decided a cache hit
with markUsed, which only asks whether the marker exists. A template change was served from the stale
layer indefinitely — the failure the mechanism was built to prevent, and one the docs described as fixed.

Also: per-pass counters that were lifetime, two knobs whose unusable values were only caught deep in the
run (or not at all), an unbounded wait that could hold the shutdown hook to TimeoutStopSec, six KDoc
blocks the compiler discarded, a leaked temp file, a shadowed field and an unhonoured AutoCloseable.

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>
Red with the wrong attribution named: expected `client-only-class`, got `operator-note`.

`Classification.firedRule` is documented as "the rule that decided **or annotated**" — a rule stating no
verdict rides along on whatever the ladder settles, deliberately, so its author can see the pattern matched
without it changing anything. `verdictOf` then read `firedRule ?: decidedBy?.ruleId` as the confirming
rule, crediting a rule that explicitly declined to state one.

The verdict itself was never wrong: CONFIRMED is gated on `BootDecision.decisive`, and an annotating rule
cannot change the rung. What it costs is auditability — this engine's stated standard is *a verdict that
cannot name its own evidence cannot be audited*, and the report's Rule column was sending an operator
asking "which rule excluded this mod?" to one that did not. Same family as the install reported as "not
retried" that had just been attempted.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`verdictOf` names the operator's rule as the confirming evidence only when `decidedBy` is
`OPERATOR_RULE` — i.e. when the rule is what decided. Otherwise the rung names itself, which is what
actually settled the verdict.

`firedRule` carries both the deciding rule and one that merely annotated (stated no verdict and rode along
on the ladder's decision, which is a deliberate feature), and the two were indistinguishable here. The
verdict was never wrong — CONFIRMED is gated on the rung being decisive — but the Rule column pointed an
operator at a rule that had declined to state a verdict.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Green from the start and deliberately so — the code was already right; six rungs simply had no position in
the guard that exists to pin position. That is the shape that lets a reorder pass unnoticed, so it is
mutation-verified rather than trusted: hoisting `mixin-apply-failure` above `client-only-class` now fails
with *"the client-class marker must outrank a mixin that could not apply"*, and before this it passed.

Rungs 9, 10 and 12–15 were asserted nowhere in `theGuardOrderIsPinnedAsAWhole`: the decisive pair
(`lwjgl-on-a-dedicated-server`, `fml-invalid-dist`) and the four excuses below them (`sandbox-network`,
`mixin-apply`, `loader-solver`, `runtime-mismatch`). Each excuse is now asserted below both decisive
markers and above the bare exit code, and the decisive pair is asserted to survive a zero exit — the
ServerStarterJar prints FML's refusal in full and exits 0 — while still yielding to a fair-run guard.

The doc block is rewritten because it was wrong in four ways at once: it said "eight ordered guards" while
listing fourteen, the list omitted `lwjgl` and `fml-invalid-dist`, a stray fragment of an older ladder sat
after the closing parenthesis, and the real count is sixteen. It already carried a note about having been
wrong twice; the instruction to re-derive from `classify` is now the first thing it says.

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>
`render` gates publication on `it.verdict == Verdict.CONFIRMED` alone, and has since the four-verdict
redesign made the decisive-rung check structural. `decisive()` was the old second gate, left behind with a
seventeen-line doc describing a rule it no longer enforces.

Its neighbouring comment already says asking twice "would only invite the two to drift apart" — and a dead
private function carrying the *old* criterion is exactly that invitation, one `git blame` away from being
restored by someone who reads the doc and assumes it is load-bearing. It would also now be **wrong**:
`propagateClientOnlyProof` mints CONFIRMED for loaders that inherit another loader's proof, and those rows
carry their own non-decisive `decidedBy`, so re-deriving decisiveness here would drop exactly the sodium
case the propagation exists to publish.

Behaviour-preserving: the function had no callers, and its `BootDecision` import went with it.

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>
bundledPattern resolved a missing rule id to a regex matching nothing, silently: lose a decisive rung and
the engine stops publishing while looking like it found nothing; lose a fair-run guard and host trouble
publishes as clientside. Now recorded, logged, and caught at build time.

Six of sixteen ladder rungs had no position in the guard that pins position, so reordering them passed —
extended and mutation-verified. A confirmation credited a rule that had declined to decide. And the
decisive set had grown from two to four without either doc following.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Closes ANALYSIS-AUDIT M-1. Green when written — the mapping was correct — so it is mutation-verified
rather than trusted. Two mutations, both caught:

  filenamePattern = verdict.suggestedEntry        -> "'SENTINEL_FILENAME' was dropped by the mapping"
  declaredClientSide = verdict.declaredServerSide -> "expected: <REQUIRED> but was: <UNSUPPORTED>"

`Grinder.grind` assigns eighteen fields by hand. `GrinderTest` — the only test that drove `grind` and
inspected the store — asserted five. Every report, CSV, query and filter test builds its `GrindVerdict`
through the `grindVerdict(...)` fixture, so none could see a producer filling a field wrongly: the same
boundary that let a dependency-label fix pass its tests while both platforms fed the labeller the wrong
slug.

The unasserted fields were the ones that matter most. **`verdict`** is what `/as-properties` gates
publication on; `declared`, `firedRule` and `decidedBy` are what make a published exclusion auditable;
`filenamePattern` and `detail` are report columns.

Every field gets a **distinct** sentinel, which is the mechanism rather than decoration — equal values
cannot detect a swap, so the two `DeclaredSupport` fields deliberately take different constants and no two
enums share a name.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Closes ANALYSIS-AUDIT M-2. Green when written, so mutation-verified: swapping arms 2 and 3 of
`pickDependencyFile` fails with `expected: <lib-0.9.0.jar> but was: <lib-1.5.0.jar>`.

The selector narrows four times — satisfying-and-obtainable, obtainable, satisfying, anything. Every
existing test varied one axis at a time: locked-versus-obtainable with no constraint in play, and
constraint-narrowing with nothing locked. The middle pair was therefore never separated, and swapping
them passed the suite.

An obtainable file must win even when the locked one is the only version the constraint accepts: a locked
file has no `downloadUrl` at all, so picking it guarantees the dependency is reported unmet, while a
version the constraint dislikes at least stages and boots. That is the `306612` / Fabric-API refusal fixed
on 2026-09-04, one layer down — and a staging refusal publishes ERROR over whatever decisive verdict the
store held.

The second test is the counterweight: with both files obtainable the constraint decides again, so
obtainability reads as the stronger preference rather than the only one.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Closes ANALYSIS-AUDIT L-1. `FilenameStemDeriver.deriveStems` (plural) had **no production caller anywhere
in the repo** — the singular `deriveStem` is used at three sites — while one test exercised it and two
KDoc blocks cited it as authoritative.

That citation is why this needed care rather than a delete. `ClientsideVerifier` and
`ClientsideVerifierCrossLoaderTest` both point at it as "the shape `deriveStems` documents", meaning the
`sodium-fabric-` versus `embeddium-` divergence — so the explanation lived on the one function nothing
ran. It now lives on `deriveStem`, which is what actually produces those stems, with the consequence made
explicit: that divergence is *why* `loaderDisprovingTheCrash` compares entries rather than loaders.

Same species as the grinder's `FallbackPropertiesRenderer.decisive()` removed earlier today — dead surface
that reads as load-bearing because a comment vouches for it, one `git blame` from being restored by
someone who trusts the doc.

Behaviour-preserving: no production call site existed, and the orphaned test went with it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Appends the resolution section to `ANALYSIS-AUDIT.md` (it is an accumulating evidence log, so earlier
sections stay), with the mutation results for each green-written guard — the reason those matter is that a
guard added green and never mutated is indistinguishable from one asserting nothing.

Also records the `REFACTOR-AUDIT.md` sweep as a table of where each candidate was verified closed, so the
four are not re-litigated: OBS-1's QSL rule, iteration 38's `!!`, iteration 39's snapshot accessors, and
iteration 40's wiring guard.

Status-table counts re-derived from build/test-results rather than incremented — the api figure had been
stale at 387 against an actual 405.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
M-1: Grinder.grind assigns 18 fields by hand and 5 were asserted end to end; every report/CSV test builds
its GrindVerdict through a fixture, so the producer was untested by construction. Now pinned with a
distinct sentinel per field, mutation-verified.

M-2: pickDependencyFile's arms 2 and 3 were never separated — obtainability versus the version constraint.
Swapping them passed the suite; it now fails.

L-1: deriveStems had no caller but carried the sodium-fabric/embeddium example two files cite as
authoritative. Deleted, with the explanation moved onto deriveStem.

The four candidate open items in REFACTOR-AUDIT.md were checked against the code and are all already
closed; the table in ANALYSIS-AUDIT.md records where, so they are not re-litigated.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
docs: complete the Refactor-state migration whose other half was committed by mistake
All checks were successful
Continuous / Build AppImage (aarch64) (push) Successful in 2m37s
Test / build (push) Successful in 14m35s
Continuous / Build Install4J Media (push) Successful in 7m33s
Continuous / Continuous Pre-Release (push) Successful in 3m6s
Documentation / Writerside webhelp (push) Successful in 2m3s
Qodana / scan (push) Successful in 8m18s
Continuous / Build JAR (push) Successful in 13m3s
Docker Test / build image (push) Successful in 14m30s
Qodana / notify (push) Successful in 16s
Continuous / Build AppImage (x86_64) (push) Successful in 2m15s
Documentation / Help image (push) Successful in 4m21s
7e484cc7ca
The `/doctor` run moved the `clientside` and `grinder` cells out of the root `CLAUDE.md`'s always-loaded
*Refactor state* table and into the two module files, which load only when working in those modules —
21,637 chars, ~5,400 est. tokens back in every session, and it took the root file from 52,186 to 30,549
chars, under the ~40,000-char large-memory-file warning threshold.

Those edits were deliberately left uncommitted for review. A `git add CLAUDE.md` in the preceding docs
commit — made to update stale test counts in the same table — swept the root half in with them, leaving the
migration split across a commit and the working tree: the cells removed from the root file, but the text
they were moved to still unstaged. This commits the destination half so the two agree.

Nothing is pushed (`origin/develop` is 34 commits behind), so this is recoverable either way; completing
the move was preferred over rewriting the merge for a documentation file. To undo the migration entirely:
`git revert` this commit and restore the two cells from `/tmp/root_CLAUDE.md.bak`, or ask.

Each cell was moved **verbatim** under a clearly-labelled heading rather than diffed against the sections
above it, so nothing could be lost — expect it to restate them in condensed form.

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 six guards of the previous commit green.

The route reuses VerdictQuery.parse + VerdictSelection.select — the same two
functions "/" and /export.csv call — so the three renderings agree because only
one of them picks rows, not because three row-pickers were kept in step. Same
q / f.<field> / sort / dir / page / size spelling everywhere, and defaultSize =
null so a bare call is unpaged, matching the documented /export.csv behaviour.

What it buys over the CSV is shape. The CSV flattens every field to a string, so
stagedDependencies arrives comma-joined; here it stays an array. That matters
because VerdictField, VerdictQuery and VerdictSelection are internal to this
module — a consumer outside it cannot reuse the selection and would otherwise be
re-parsing an export meant for a spreadsheet.

The shared mapper gains JavaTimeModule and loses WRITE_DATES_AS_TIMESTAMPS, the
same configuration JsonVerdictStore already uses, so verifiedAt is an ISO-8601
string on the wire exactly as it is on disk. /status writes only primitives and
is unaffected — pinned rather than reasoned about, since reshaping a neighbouring
document is exactly how a shared-mapper change goes wrong.

Full grinder suite green.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A pf4j plugin module modelled on serverpackcreator-plugin-example: kotlin and
dokka conventions, kapt(pf4j) for the extension index, the `pluginArtifact`
consumable configuration the root build copies from, plugin.toml expansion
through processResources, and the Plugin-* jar manifest attributes.

Depends on :serverpackcreator-api alone. A plugin compiles against the published
API surface; depending on -clientside or -grinder would tie this jar to modules
that are not published and churn freely. Everything it needs from the grinder
arrives over HTTP as JSON, which is what the previous commit's /verdicts.json is
for.

Two things worth stating rather than leaving to be rediscovered:

The plugin id is "grinder" and must stay so. ApiPlugins stores a plugin's
configuration as <pluginId>.toml in SPC's plugin-configs directory and only
extracts the shipped config.toml when that file does not exist, so a renamed id
orphans every user's saved selection.

copyPluginsApiUnitTests keeps copying the EXAMPLE plugin alone, and now says why
in a comment at the point somebody would add the second one: ApiPluginsTest
loops over every jar in the api test-resources plugins directory and asserts each
provides all six extension types. This plugin provides two, so adding it there
turns that suite red. The grinder plugin gets its own resolvable configuration
and reaches only the app's manual-test directory.

Verified: `./gradlew :serverpackcreator-plugin-grinder:jar` produces a jar whose
plugin.toml expands to id/name/description/author/version and which carries
META-INF/extensions.idx.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Red: every failure is an unresolved reference to a type this commit does not
yet create (GrinderUrl, GrinderClient, FetchResult, SelectionStore,
SelectionPane and the read model's properties). The one non-symbol error, a
missing get-operator at GrinderClientTest.kt:203, is a knock-on of
FetchResult.value being unresolved and goes with it.

Running the pins before committing them caught one fixture fault worth naming,
since it is exactly the failure mode the convention exists for: the config
fixture called TomlFormat.instance().createParser().parse(File), an overload
that does not exist, so the "red" would have been the test's own compile error
rather than the missing implementation. It now parses through
TomlParser().parse(file, FileNotFoundAction.THROW_ERROR, UTF_8) — the same call
ApiPlugins.registerPluginConfig makes.

Three things these guards fix in place:

GrinderClient is pinned against a real loopback HttpServer, not a mock, because
what is under test is behaviour at a socket — a refused connection, a 502 from a
proxy in front of a stopped daemon, a 200 carrying HTML. The governing rule is
that none of them throws: this client runs on a Swing worker and on the
generation path, where an escaped exception is a dead tab or an aborted server
pack, and a Failed carrying a reason is a sentence the operator can act on. It
also has to tolerate fields it has never heard of, since the daemon is updated on
a different schedule than the plugin.

SelectionStore is pinned against the config.toml this module actually ships,
parsed by SPC's own parser, so a key renamed in one place and not the other
fails here rather than at a user's next generation. Two behaviours are decided
rather than left to emerge: an entry the grinder no longer reports stays ticked
(pruning it would silently un-exclude a mod the user chose to exclude), and a
blank entry is refused (an empty exclusion matches every mod name under
startsWith/contains, which would empty a server pack's mods directory).

GrinderUrl exists so the Settings pane and the client resolve an address through
one rule, rather than the pane calling something valid that every fetch then
misses.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Turns the previous commit's 21 guards green. No new compiler warnings from this
module.

GrinderClient walks responses as a JSON tree rather than binding them to a
class. A plugin is updated on the user's schedule and the daemon on the
operator's, so a field this build has never heard of has to be ignorable rather
than a parse failure the user reads as "the grinder is broken". Nothing escapes
as an exception — an unusable address never reaches the network, a non-2xx
carries its status into the reason (the one fact that separates "daemon down"
from "something answered instead"), and an InterruptedException restores the
flag before returning, because a SwingWorker cancels by interrupting.

GrinderVerdict is the plugin's own read model rather than the daemon's
GrindVerdict, which lives in a module that is neither published nor a dependency
of a plugin. Its exclusionEntry deliberately offers only suggestedEntry, the same
field FallbackPropertiesRenderer publishes; filenamePattern is not a fallback,
because it is a regex over a filename and SPC's default matching mode is not
regex, so offering it would silently exclude nothing.

SelectionStore is a typed view over the CommentedConfig ServerPackCreator owns —
no state of its own, so it is cheap to construct wherever one is in hand. That
config object is the mechanism the whole feature rests on: ApiPlugins hands the
same instance to the tab and to the pre-generation extension, so a tick reaches
generation without a save, while saveConfiguration() carries it across a restart.
Every read tolerates a damaged value, because the file is hand-editable and this
object is constructed on the generation path, where throwing over a typo would
abort a server pack.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Red: seven unresolved references to ClientsideEntryInjector and two to
GrinderPreGenExtension, plus the type-inference errors that follow from an error
type sitting opposite emptyList() in an assertion. Nothing else.

GrinderPreGenExtensionTest pins the real extension point rather than a helper
standing in for it. ServerPackHandler.run calls runPreGenExtensions(packConfig,
…) and then reads packConfig.clientMods to compile the mod list, so mutating
that list there is what actually excludes a mod — and because the hook lives
inside ServerPackHandler.run it fires for the GUI, the CLI and the web backend
alike, which is how a headless run honours ticks made in the GUI. That is worth
pinning against the genuine `run` signature, so mockk arrives as a test-only
dependency for the three collaborators this extension never touches.

Two of the guards are about damage rather than the happy path. Idempotence:
generation runs repeatedly against one live plugin config, and a list that grew
by a copy of the selection each time would be visible to the user, since it is
the same list the GUI field shows. And a missing plugin configuration — what
ApiPlugins hands over when it could not parse the file — has to leave generation
alone rather than abort it.

ClientsideEntryInjector is a pure function over two lists precisely so it can be
pinned this hard: it is the only place where a mistake silently removes mods from
a server pack, or silently fails to. Case-insensitive de-duplication because
SPC's own matching is, order preserved because the exclusion list is applied in
sequence and re-sorting a user's list is not this plugin's business, and blanks
refused because an empty entry matches every mod name under startsWith/contains.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Turns the previous commit's guards green; the module's suite is 33 tests, no
failures, no new compiler warnings.

GrinderPreGenExtension is where the plugin actually changes a server pack.
ServerPackHandler.run calls runPreGenExtensions and then reads
packConfig.clientMods to compile the mod list, so appending there is what
excludes a mod — and because the hook sits inside run() rather than in the GUI,
one selection covers the GUI, the CLI and the web backend. The selection is read
out of the plugin configuration, the same CommentedConfig instance ApiPlugins
hands the tab, so a headless run honours ticks made in the GUI.

Nothing it does is destructive. The user's own list keeps its contents and its
order, entries are only appended, and serverpackcreator.conf is never written —
unticking an entry puts the next generation back exactly as it was. An absent
plugin configuration, which is what ApiPlugins provides when the file could not
be parsed, is a no-op: an exclusion the user cannot see is not worth aborting a
server pack over.

ClientsideEntryInjector is the merge itself, kept a pure function over two lists
because it is the only place here where a mistake silently removes mods from a
server pack or silently fails to.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Red: every failure is an unresolved reference to VerdictTableModel or one of its
members. Headless — an AbstractTableModel needs no display, so no Swing component
is instantiated here.

The model is pinned and the rendering is not, matching this project's stance on
tables elsewhere: trivial format lambdas against brittle component assertions.
But everything a user can get wrong by clicking is decided in the model, so it
gets guards.

Four of them encode decisions rather than mechanics. A row the grinder gave no
name-pattern for is not editable, because rendering it as an ordinary unticked
box invites a click that silently does nothing. Deselect-all clears only the rows
that table shows, since the two panes hold different lists over one saved
selection and clearing Confirmed must not untick anything in Other Verdicts. A
refresh replaces rows but never the selection — the same rule SelectionStore
keeps, so an entry the grinder stopped reporting stays excluded. And assigning
the selection wholesale, which is what loading a saved configuration does, must
not fire the change callback, or the tab writes its config on every refresh.

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 red for the defect rather than for itself: 4 tabs where 2 are correct,
and each plugin handed both plugins' TabExtensions.

ApiPlugins.getAllExtensionsOfPlugin(plugin, type) ignores its `plugin` argument
and delegates to pluginManager.getExtensions(type), which answers with every
plugin's extensions. Its callers iterate plugins and call it once each, so the
result multiplies: addTabExtensionTabs adds every tab once per installed plugin,
and runPreGenExtensions / runPreZipExtensions / runPostGenExtensions /
runConfigCheckExtensions run every extension that many times.

With one plugin installed the defect is invisible — one times one is one — and
until this branch added a second plugin this repository shipped exactly one. That
is why it survived. Reproduced 2026-09-06 by running ServerPackCreator with the
example and grinder plugins side by side: the tab strip read
"Grinder | Tetris | Grinder | Tetris".

Two plugins are the whole point of the guard, so it builds the second one at
runtime by cloning the example jar under a new id rather than checking in a
second fixture that would then need maintaining. The id is rewritten in both
places that carry it — the jar manifest, which pf4j's descriptor finder reads,
and plugin.toml, which ServerPackCreatorPlugin reads for its own fields — since
rewriting one leaves a plugin whose two identities disagree.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
getAllExtensionsOfPlugin(plugin, type) accepted its `plugin` argument and threw
it away, delegating to pluginManager.getExtensions(type) — every plugin's
extensions, for whichever plugin you asked about. Passing the plugin id turns the
previous commit's guards green: 2 tabs instead of 4, one TabExtension per plugin
instead of two.

Every caller loops over the installed plugins and asks once per plugin, so the
wrong answer multiplied. addTabExtensionTabs added each tab once per installed
plugin; runPreGenExtensions, runPreZipExtensions, runPostGenExtensions and
runConfigCheckExtensions ran each extension that many times. A ConfigCheck
extension reporting an error reported it N times.

It survived because one plugin times one plugin is one, and this repository
shipped exactly one plugin until this branch. It surfaced on 2026-09-06 the first
time two were installed together, as a tab strip reading
"Grinder | Tetris | Grinder | Tetris".

This is a behaviour change on the published API, so it has a row in
claude-docs/API-BEHAVIOUR-CHANGES.md. An embedder with one plugin sees nothing.
One with several sees each generation extension run once rather than N times —
which is the contract the method's name always claimed — and a plugin that had
come to rely on reaching another plugin's extensions through this call will no
longer see them.

Full -api suite: 407 tests, 0 failures. ApiPluginsTest, which loops over every
installed plugin, still passes.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Running the module's suite creates a `tests/` ServerPackCreator home — log4j2
initialises against it the moment an extension asks for the AddonsLogger — with
logs, manifests and a plugin-configs directory under it. Every other module
already has this pair of rules; the new one was missing them, so the whole home
showed up as untracked after the first test run.

Same shape as -app, -clientside, -grinder and -plugin-example: ignore the
contents, keep the .gitkeep so the directory itself survives a clone.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
New serverpackcreator-plugin-grinder/CLAUDE.md carries the module's own state:
how the shared CommentedConfig makes a tick reach a headless generation, why
suggestedEntry is the only field that becomes an exclusion entry, why a selection
is never pruned, and the copyPluginsApiUnitTests landmine.

Root CLAUDE.md gains the module-map entry and a refactor-state row, and the api
row moves 405 → 407 for ExtensionScopingTest. The "current phase" section records
two things that outlive the module's own docs:

The extension-scoping bug, with the lesson generalised — a defect whose
multiplier is the count of something the repository only ever has one of cannot
be found by testing what the repository ships. It took installing a second plugin
to see it, and it had been there all along.

And an open, pre-existing defect this work surfaced but did not cause: the
*example* plugin dies with a StackOverflowError in CustomPluginFactory when
started in CLI mode, because its init calls ApiWrapper.api() re-entrantly.
Generation still completes and the GUI path is fine. Confirmed pre-existing by
reproducing it against develop's unmodified ApiPlugins, and written down because
it appears in any CLI log with plugins installed and reads like a regression.

The grinder's module and report-subsystem CLAUDE.md files gain /verdicts.json:
why it exists (the selection is internal, so it must travel over the wire, and
the CSV flattens every field to a string), that it shares VerdictSelection.select
with the other two renderings, and the shared-mapper landmine — ReportServer's
mapper is used by /status too and needed JavaTimeModule for an Instant to be a
timestamp rather than an epoch object.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
495 → 501, the six VerdictsJsonEndpointTest guards; 29 skips unchanged.
Re-derived from serverpackcreator-grinder/build/test-results/test/*.xml after a
run of every affected module.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This section was already written and uncommitted when the grinder-plugin branch
started — an audit of the 2026-09-05 `develop` commits, verifying the pin-first
boundary by checking out every pin in a detached worktree and running it.

It is committed on its own rather than alongside the iteration-42 entry it has
nothing to do with. Iteration 41's own MED-1 is a commit that "carried an
unrelated edit and left it split", so folding this into another commit's diff
would have reproduced the finding it records.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Two accumulating logs, appended not overwritten.

REFACTOR-AUDIT iteration 42 finds no HIGH: there is no `refactor:` commit on the
branch to have mixed behaviour into, no module boundary crossed, and the one
changed plugin-API contract is labelled `fix:`, pinned red in its own commit and
recorded in API-BEHAVIOUR-CHANGES.md. The pin-first boundary held on all five
pairs, and `bbfdf42f1` is the strongest of them — it fails on a real assertion
(4 tabs where 2 are correct) rather than on a compile error.

Four MEDIUM findings, two of them defects in shipped code: the selection-pane
attribution in GrinderTab files every stale entry as CONFIRMED, and Swing renders
grinder-supplied text as HTML. The other two are missing guards over correct
code.

ANALYSIS-AUDIT carries the coverage map, the edge cases the existing guards miss,
and the security pass. Both reports cross-reference rather than restate.

One finding is WITHDRAWN in the same entry, and the withdrawal is the more useful
half. LOW-1/A-5 claimed ClientsideEntryInjector's `lowercase()` was
locale-sensitive; the guard written for it was green on first run. Measured under
a Turkish default locale: Java's `toLowerCase()` gives `ıceberg-`, but Kotlin's
`lowercase()` — which exists precisely because of that — compiles to
`toLowerCase(Locale.ROOT)` and gives `iceberg-`. The audit reasoned from the Java
API and attributed its behaviour to the Kotlin one.

That is this log's own recurring lesson one level up: an audit is unpinned
reasoning, and a finding from it is indistinguishable from a real defect until
something executes it. Writing the guard before the fix is what caught it. In the
other order, Locale.ROOT would have been added, the guard would have passed, and
a non-bug would sit here recorded as fixed.

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>
Both logs get a resolution section: every finding closed, LOW-1 withdrawn, and
the re-verification against real runtimes rather than only the suite — one
Grinder tab and one Tetris tab in the running GUI, where the same strip read
"Grinder | Tetris | Grinder | Tetris" before the ApiPlugins fix.

Four of the fixes became landmines in the module's own CLAUDE.md, because each
is a rule a future edit could quietly break: every component showing text from
outside the plugin comes from PlainTextRendering; pane attribution is
SelectionAttribution's decision and is *not* "whichever pane shows it"; a
wrong-shaped 200 is a failure rather than an empty list; and getColumnClass
returns javaObjectType, since Boolean::class.java is primitive boolean.class and
has no JTable renderer.

Counts re-derived from build/test-results after a run of every affected module:
api 409, grinder 503, plugin-grinder 69, app 149.

One process note is recorded rather than smoothed over. The audit did not find
the compiler warning in getColumnClass — the clean-warnings check had been run
after the core commit, before the GUI commit existed, and was never repeated. A
clean-warnings check is only worth what its most recent run covered.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Red on the first assertion: 1.20.4 should satisfy '[1.20.3],[1.20.4]'.

Found by reading the ERROR verdicts on the public grinder
(https://grinder.serverpackcreator.de) rather than by inspection. Two of its 54
ERROR rows are this bug: `distanthorizons` declares `[1.20.3],[1.20.4]` and was
refused for a 1.20.4 pack, `mru` declares `26.2,26.3` and was refused for a 26.2
pack. Executed against the parser before writing anything, both constraints
refuse *every* version they list, and Maven's own documented union example
`(,1.0],[1.2,)` refuses everything — the union form was never satisfiable.

One comma does two jobs. Inside a bracketed range it separates lower bound from
upper; between ranges it separates alternatives. mavenRangeHolds assumes the
first reading unconditionally, so `[1.20.3],[1.20.4]` parses as a single range
from `1.20.3]` to `[1.20.4`, and numbersOf maps the bracketed first component to
0 — making the upper bound 0.20.4, which nothing real can be below.

Neither VersionConstraintTest.readsMavenRanges nor VersionConstraintFuzzTest
covered it, and the fuzz test's own premise is that a wrong refusal "is
indistinguishable from the dependency being genuinely unsatisfiable". That is
exactly what happened; it just needed a well-formed constraint rather than a
malformed one.

It fails safe — a refused boot publishes nothing, so no wrong exclusion ever
reached a user. The cost is coverage: those mods are never boot-verified, and the
ERROR reads as a statement about the mod rather than about the parser.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Turns the previous commit's guard green. clientside 369 → 370 tests, api 409,
both zero failures.

Two changes, one for each spelling the live grinder produced. A bracketed
constraint is split on its top-level commas first, so `[1.20.3],[1.20.4]` becomes
two ranges and any of them may hold; `unionMembers` tracks bracket depth rather
than matching a regex, because the comma between ranges and the comma inside one
are spelled identically and only nesting distinguishes them. A bare
comma-separated list — `26.2,26.3` — is read as alternatives too: Maven would
call an unbracketed version a soft requirement rather than a constraint, but mod
authors write this meaning "either", and reading it as a single version refused
both.

An unbalanced string still yields one member, the whole input, so a malformed
constraint reaches mavenRangeHolds exactly as before and still resolves to
accept — the fail-open rule VersionConstraintFuzzTest exists to protect.

Not deployed: the public instance runs an older build, so its two affected ERROR
rows stay until it is updated and those projects are re-ground. `--requeue`
against distanthorizons and mru is the way to reclaim them without waiting for
the TTL.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Red on the two real gaps: KnownModIds returns null for 'mtlib' on CurseForge,
and CurseForgePlatform.resolveDependency answers null for a slug ref.

Reported from the live grinder: modtweaker on Forge/1.12.2 refused for `mtlib`,
a project CurseForge publishes under exactly that slug. Executed against the
registry before writing anything — `mtlib`, `crafttweaker`, `jei`, `athena`,
`flywheel` and `xaerolib` every one returned null for CurseForge and their own id
for Modrinth. So *every* manifest-declared dependency of a CurseForge candidate
was unmappable unless it was one of four hardcoded aliases.

The asymmetry had a real cause — CurseForge addresses projects by numeric id,
which cannot be guessed from a mod id — but the conclusion did not follow: its
search endpoint takes a slug, and CurseForgePlatform.resolve was already calling
it that way. resolveDependency simply began with `nativeRef.toLong()`.

Running the pins first caught a fixture fault, which is the whole reason for the
rule: `stillResolvesADependencyGivenByNumericId` failed because the fake fetcher
answered `/mods/search` and `/mods/238222/files` but not `/mods/238222`, which
the numeric path reads for slug and website. That is the fixture's fault, not the
implementation's, and it would have made a green look like a fix. The fake now
answers every URL these tests actually cause.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This reverts commit f5d13646ba.
Red: unresolved reference to newestReleaseSatisfying, five times.

Griefed's call, 2026-09-06, from the live grinder's ERROR rows.
`moonlight-1.20.4-2.9.9-forge.jar` is tagged 1.20.4 and only 1.20.4, while its
descriptor declares `[1.20,1.20.2)` — so platform and jar share no version at
all, newestVersionSatisfying returns null, and the candidate is refused outright.
"Bump the version to the one specced in the JAR, then run the grind."

The existing re-selection only reconsiders versions the *platform* tagged, which
is why JEI was rescued (tagged 1.21 and 1.21.1, declaring `[1.21, 1.21.1)`) and
moonlight was not. The jar is the better authority when the two disagree, and not
merely a different one: the loader enforces this range at runtime, so booting
inside it is what gets the mod loaded, while booting at a version the author
ticked on a web form gets it rejected by FML before it runs. Only the pack's
Minecraft version moves; the file is unchanged.

Two guards bound it rather than just asserting the happy path. The loader gate
still applies, so a release with no loader build is skipped for the next one
down. And a constraint that constrains nothing — empty, `*`, or unreadable — must
not bump at all: VersionConstraint deliberately accepts anything it cannot parse,
so without that check an unreadable descriptor would relocate every candidate to
the newest Minecraft in existence.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Turns the previous commit's five guards green. clientside 370 → 374, api 409,
grinder 503, zero failures.

reselectOnMinecraftContradiction only reconsidered versions the *platform*
tagged, which is why JEI was rescued and moonlight was not: JEI is tagged 1.21
and 1.21.1 while declaring `[1.21, 1.21.1)`, so an agreed pick existed, whereas
moonlight-1.20.4-2.9.9-forge.jar is tagged 1.20.4 and only 1.20.4 while
declaring `[1.20,1.20.2)` — the two share nothing and the candidate was thrown
away. It now falls back to the newest real Minecraft release the jar's own
descriptor accepts, still gated on the loader having a build there.

The jar is the better authority when the two disagree, and not merely a different
one: the loader enforces this range at runtime, so booting inside it is what gets
the mod loaded, while booting at a version the author ticked on a web form gets
it rejected by FML before it runs. The file is unchanged; only the pack's
Minecraft version moves, and the log line says when the version was one the
platform never tagged.

The bound that matters is `constrainsAnything`. VersionConstraint accepts
anything it cannot parse — deliberately, so a grammar gap can never mass-refuse —
so an empty, wildcard or unreadable descriptor would otherwise "satisfy" the
newest Minecraft in existence and silently relocate every candidate there. It is
decided by asking whether the constraint excludes anything in the very set about
to be searched, rather than by trying to re-detect which shapes the parser
tolerates, which would be a second copy of that grammar.

Still exactly one retry, via stageBootPack rather than prepareBootPack, so a
second contradiction surfaces instead of looping.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Three entries, from reading the public grinder's ERROR rows.

The comma-union defect, with the measurement that a union was never satisfiable
and the note that it failed safe, so the cost was coverage rather than a wrong
exclusion. Worth recording why the existing guards missed it: the fuzz test
sweeps malformed constraints and this one is well-formed.

The version bump, with the reason the jar outranks the platform tags — the loader
enforces the range at runtime — and the landmine that constrainsAnything is what
stops an unreadable descriptor relocating every candidate to the newest Minecraft
in existence.

And one left OPEN rather than fixed. A CurseForge candidate's manifest
dependencies are unresolvable, which is what Griefed reported via modtweaker and
mtlib. The obvious fix was implemented and reverted: it fails four deliberate
guards and would move ids that map-then-fail from `unmapped` into `unsatisfied`,
which refuses — the documented xaerolib trap, where being almost resolvable was
worse than being unknown. The fix that has both is to key the refusal split on how
confident the mapping was, and that changes what this file calls the whole safety
property, so it is a decision rather than a quiet edit.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Red: unresolved ModIdMapping and mappingFor, plus the parameter changes on
planManifestDependency. Nothing else fails.

The split keys on **how far a ref got** — mapped-then-unstageable refuses,
unmappable does not — which makes being *almost* resolvable worse than being
unknown. That is this file's own xaerolib case: a real Modrinth project of that
name exists but publishes nothing for the pack's loader and Minecraft version, so
a guess that happened to hit refused a boot an outright miss would have allowed.
It is also why CurseForge was given no guess at all, and therefore why
`modtweaker` never staged `mtlib`.

It now keys on **how the ref was arrived at**. An alias is a project we know the
id names, so failing to honour it is a real gap and may refuse. A guess is an
optimistic slug that may name nothing or something else, so it never refuses at
any stage — which is what makes guessing safe to extend to CurseForge, whose
search endpoint resolves a slug to the numeric id its other routes need.

Existing assertions changed, and that is the stop-and-flag signal working rather
than being bypassed: this is a deliberate behaviour change, asked for explicitly
after the tradeoff was put to Griefed, and it is labelled `fix:` not `refactor:`.
Three are signature-only (refFor → mappingFor, same expectations). Four are
substantive:

- anUnknownIdIsNotGuessedOnCurseForge is replaced by
  anUnknownIdIsGuessedOnBothPlatforms; its old rationale — that a guess costs a
  refusal — is exactly what no longer holds.
- The three "must not be fabricated on CurseForge" assertions become "must not be
  claimed for the alias", which was always the intent: lucko's
  fabric-permissions-api-v0, fabric-language-kotlin and quilt_loader must not
  resolve to Fabric API or QSL. They are now guesses at their own slugs, which
  refuse nothing.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Turns the previous commit's guards green. clientside 374 → 382, api 409, grinder
503, app 149, zero failures.

ModIdMapping carries how a ref was arrived at: Alias for a project we know the id
names (the table, or a recognised Fabric API / QSL module shape), Guess for the
optimistic slug, None for nothing to try. planManifestDependency reads it instead
of a bare String?, and the confidence rides on Stage so the download-failure
branch obeys the same rule.

An alias refuses exactly as before. A guess never refuses, at any stage — not
when it resolves to a project publishing nothing usable, not when the download
then fails. That closes the xaerolib trap directly: a real Modrinth project of
that name exists but publishes nothing for the pack's loader and Minecraft
version, so a guess that happened to hit used to refuse a boot an outright miss
would have allowed. Being almost resolvable is no longer worse than being
unknown.

Which is what makes the guess safe to extend to CurseForge, and that closes
Griefed's modtweaker report: a manifest id is offered as a slug on both
platforms now, and CurseForgePlatform.resolveDependency resolves a non-numeric
ref through the same slug search `resolve` was already using, so `mtlib` is found
and staged instead of being unmappable. Exact-match on the slug, so it finds the
project the id names or finds nothing — it cannot substitute a similarly-named
one.

Worth stating plainly, since it reverses a documented decision: CurseForge was
given no guess *because* a guess could refuse. That premise is gone, so the
conclusion goes with it. The safety property the old split protected is intact
and now stated directly rather than emerging from how far a lookup happened to
get.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Replaces the OPEN entry with the closed one, and marks the older
"how far did it get" description as superseded rather than deleting it — the
reasoning below it is why the split exists at all and still holds; only its key
changed.

States the premise that moved, so nobody re-derives the old conclusion from the
old rationale: CurseForge was given no guess *because* a guess cost a refusal.
It no longer does, so the guess is safe to offer, and mtlib is resolvable.

Counts re-derived after a run of every affected module: clientside 382, api 409,
grinder 503, app 149.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Red on the mtlib case: expected MTLib-3.0.7.jar, got null.

This is the actual cause of Griefed's modtweaker report, and finding it needed
the live CurseForge API — the earlier diagnosis was wrong. `modtweaker-4.0.20.11`
declares dependency `253211`, a *numeric* ref, so the refusal came from the
platform path and never touched the manifest-id mapping I had been changing.
`253211` resolves to mtlib and returns 7 obtainable files for 1.12.2 — every one
carrying `loaders=[]`, because CurseForge had no modloader facet before Minecraft
1.13. `pickForLoader` requires `loader in it.loaders`, which no empty set
satisfies, so the dependency was unpickable and the boot was refused.

Measured with Griefed's key, 2026-09-06: mtlib is 15/15 files untagged,
iron-chests 106/138, waystones 70/494, crafttweaker 28/500 — essentially all
pre-1.13, plus modern stragglers (journeymap, 5 files at 26.1.2). jei and athena
have none.

Three guards bound the fallback rather than just asserting it fires. Untagged is
the last resort, so a tagged file wins and this can only add a pick where there
was none. The Minecraft version stays exact — untagged excuses the loader, never
the version. And a file tagged for a *different* loader is still refused, because
untagged means "the author told us nothing", which is not the same as "the author
told us this is Fabric".

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Turns the previous commit's four guards green, and verified against the live
CurseForge API: pickDependencyFile(Forge, 1.12.2) for mtlib now returns
MTLib-3.0.7.jar where it returned nothing. clientside 382 → 386, api 409, grinder
503, app 149, zero failures.

pickUntagged is the last arm of pickFrom, after the exact loader and the
Quilt-to-Fabric fallback, so a file whose author did state a loader always wins
and this can only add a pick where there was none. The Minecraft version stays
exact — untagged excuses the loader, never the version — and a file tagged for a
different loader is still refused, because that tag is a statement and an empty
set is the absence of one.

Worth recording that the first diagnosis of this report was wrong, and only the
live API showed it. modtweaker-4.0.20.11 declares dependency `253211`, a numeric
ref, so the refusal came from the platform path and never touched the manifest-id
mapping the previous two commits changed. Those commits are independently correct
— they close the xaerolib trap and make CurseForge manifest ids resolvable — but
they would not have fixed what Griefed reported. A cause that survives a code
read can still be the wrong one.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The measurements belong here rather than in a commit message, since the next
reader of pickForLoader needs them: CurseForge had no modloader facet before
1.13, so mtlib is 15/15 files untagged, iron-chests 106/138, waystones 70/494,
crafttweaker 28/500, plus modern stragglers.

Also records that the first diagnosis was wrong and why it looked right — the
refusal names a slug because unsatisfiedLabel resolves the ref, which reads like
a manifest mod id, while modtweaker actually declares the numeric 253211.

And leaves the candidate half OPEN with its own measurement: mtlib as a candidate
for Forge returns nothing, so a project whose files are all untagged is never
ground at all. Widening pickBootableCandidate decides which mods get ground
rather than which dependency is staged, so it is Griefed's call — with the note
that the risk is bounded to wasted boots, since a mod under an unsupported loader
fails to load and reads INCONCLUSIVE, never CONFIRMED.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Red on the mtlib case: an all-untagged project must still be ground, got null.

The candidate half of the untagged-loader rule. pickBootableCandidate has the
same `loader in it.loaders` test pickForLoader had, so a project whose files are
all untagged was never selected under any loader — measured against the live
CurseForge API, pickBootableCandidate(mtlib.files, "Forge") returns nothing, all
15 of its files carrying loaders=[]. iron-chests and waystones escaped only
because their newer files are tagged.

Widening it here needs a different argument than for a dependency, and the guards
carry it. Picking an untagged file for the wrong loader could in principle stage
a jar that loader ignores, boot cleanly, and publish a false CLEAR — "proven
server-safe" for a mod that never loaded. Two things prevent that.
loaderVersionAvailable covers the dominant case: untagged files are
overwhelmingly pre-1.13, where Fabric and Quilt have no builds at all, so only
Forge is reachable and untagged means Forge. For anything newer,
refuseForSelfDeclaration reads the downloaded jar's own descriptor before the
boot and refuses one carrying only another loader's — the "carries only Forge
descriptor(s), so it is not a NeoForge mod" refusal already visible in the live
store. So the cost of being wrong is a refused attempt, not a wrong verdict.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Turns the previous commit's four guards green, and verified live:
pickBootableCandidate(mtlib.files, "Forge") now returns MTLib-3.0.7.jar @ 1.12.2
where it returned nothing. clientside 386 → 390, api 409, grinder 503, app 149,
zero failures.

The untagged arm is last, so a file whose author stated a loader always wins and
this only adds a candidate where there was none. pickBootableCandidate's body is
split into newestOf so both arms share the ordering and the availability gate
rather than restating them.

The safety argument, since it differs from the dependency half: an untagged file
picked for the wrong loader could stage a jar that loader ignores, boot cleanly
and publish a false CLEAR, which is the worst outcome this engine has — it claims
proof about a mod that never loaded. loaderVersionAvailable covers the dominant
case, because untagged files are overwhelmingly pre-1.13 where Fabric and Quilt
have no builds at all, so only Forge is reachable and untagged means Forge. For
anything newer, refuseForSelfDeclaration reads the downloaded jar's descriptor
before the boot and refuses one carrying only another loader's.

Measured consequence for mixed-era projects, verified live on iron-chests: a
Fabric attempt now picks an untagged 1.16.2 Forge jar and is refused by the
descriptor gate, where before it was refused at selection. Same verdict class
either way, a more precise reason, and one download's worth of extra work — the
project publishes no Fabric-tagged file at all, so that attempt was never going
to succeed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Replaces the OPEN entry with what was decided and measured, keeping the safety
argument — it differs from the dependency half and a future reader will need it
before touching either arm.

Records the live consequence for mixed-era projects rather than only the win: an
iron-chests Fabric attempt now downloads an untagged Forge jar and is refused by
the descriptor gate instead of at selection. Verdict-neutral, better reason, one
extra download.

clientside 390.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
merge: the grinder plugin, and four engine defects its ERROR rows exposed
Some checks failed
Qodana / scan (push) Successful in 11m24s
Documentation / Writerside webhelp (push) Successful in 1m37s
Docker Test / build image (push) Successful in 16m28s
Documentation / Help image (push) Failing after 4m36s
Continuous / Build JAR (push) Successful in 18m33s
Qodana / notify (push) Successful in 10s
Continuous / Build AppImage (x86_64) (push) Successful in 1m53s
Continuous / Build AppImage (aarch64) (push) Successful in 2m11s
Continuous / Build Install4J Media (push) Successful in 8m51s
Continuous / Continuous Pre-Release (push) Failing after 1m37s
Test / build (push) Successful in 56m34s
09a740e68c
35 commits. Two strands: a new GUI plugin, and the clientside fixes that came out
of reading what the public grinder had actually published.

The plugin (serverpackcreator-plugin-grinder) reads a grinder's verdicts over
HTTP, lets the user tick individual entries across all four verdict classes, and
folds those ticks into packConfig.clientMods just before the mod list is compiled
— so one selection covers the GUI, the CLI and the web backend. It needed a
machine-readable feed, so the daemon gained /verdicts.json, sharing
VerdictSelection.select with the table and the CSV so the three cannot disagree.
Verified end-to-end against a live ReportServer: three ticked entries produced a
pack holding only the unticked and the unknown mods, with the GUI never opened.

Installing a second plugin exposed ApiPlugins.getAllExtensionsOfPlugin ignoring
its plugin argument — every tab added once per installed plugin, every generation
extension run that many times. Invisible while the repository shipped exactly one
plugin, and visible immediately as "Grinder | Tetris | Grinder | Tetris". Fixed,
pinned, and recorded in claude-docs/API-BEHAVIOUR-CHANGES.md.

Then four engine defects, each found by reading the live ERROR rows rather than
the code, each pinned red before its fix:

- A comma-separated union of version ranges was never satisfiable, so
  [1.20.3],[1.20.4] refused both versions it lists and Maven's own documented
  example refused everything.
- A jar whose declared range shares no version with its platform tags was thrown
  away; it now bumps to a release the jar itself accepts.
- The refusal split keyed on how far a lookup got, which made being almost
  resolvable worse than being unknown. It now keys on mapping confidence: an
  alias may refuse, a guess never does.
- A CurseForge file carrying no loader tag was read as incompatible rather than
  unknown, so pre-1.13 dependencies were unstageable and all-untagged projects
  were never ground at all.

The last of those is the one Griefed reported via modtweaker and mtlib, and the
first diagnosis of it was wrong — only driving the real CurseForge API showed
that the refusal came from the platform path via a numeric ref, not from the
manifest-id mapping two earlier commits had changed.

Suites: api 409, clientside 390, grinder 503, app 149, plugin-grinder 69,
plugin-example 3. Zero failures.
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>
Turns the two red guards green. NeoForge 20.1.x is a fork of Forge 47
that kept the net.minecraftforge packages, the javafml language provider
and META-INF/mods.toml, so on Minecraft 1.20.1 a Forge build and a
NeoForge build are the same file. NeoForge renamed those packages for
1.20.2, which is where the compatibility ends -- the band is one version
wide, and the rule says so literally rather than as a range.

Two call sites held separate, silently diverging copies of "which loader
runs whose builds": JarSelfDeclaration.alsoRuns (the pre-boot descriptor
gate) and BootCandidateSelector.fallbackLoaders (dependency selection).
Both were `Quilt to Fabric`, and only one of them could ever have learned
this. They now share LoaderCompatibility.alsoRuns(loader, minecraft),
which takes the Minecraft version because the NeoForge claim is
worthless without one.

What it fixes, live: CurseForge/mantle published an ERROR row 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 reached a ready-line under Forge minutes earlier in the
same run. A verdict about the grinder's own descriptor table, published
as a verdict about the mod.

The dependency half closes the same gap one step earlier: a dependency
publishing only Forge files was unpickable for a NeoForge 1.20.1 boot,
and refuseForMissingDependencies scores an unstageable requirement
INCONCLUSIVE, so the whole boot was lost.

The concession stays one-way in both directions it could have leaked:
Forge still cannot read neoforge.mods.toml, and a real NeoForge build
still beats the Forge fallback where the project publishes one.

:serverpackcreator-clientside:test 395/395 green (390 before these five
guards); :serverpackcreator-grinder:test and :serverpackcreator-app:test
green. No new compiler warnings.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
One landmine, two halves. The fact -- NeoForge 20.1.x is Forge 47 under
another name, and the 1.20.2 package rename ends it -- plus the reason it
must be stated as the single version rather than a lower bound: a range
boots Forge jars under NeoForge 1.20.2+, where FML rejects them and the
failure is scored against the mod.

Also records why LoaderCompatibility exists at all (two diverging copies
of the same question, only one of which could ever have learned this) and
the live mantle ERROR row that produced it.

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>
What the marker is, why the redirect substitutes the scanner's input
rather than its dispatch, why it keys on the marker and not on carrying
both descriptors, and -- so nobody re-litigates it from the symptom --
that the failing Forge boot was NOT a staging gap: the newest Connector
and the newest Forgified Fabric API for 1.20.1 were both present.

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>
Behaviour-identical: JarSelfDeclarationTest and MetadataScannerTest stay
green with no assertion touched.

nightconfig's Config.valueMap() is deprecated, so the two calls added by
"scan a Connector placeholder as the Fabric mod it wraps" raised two new
compiler warnings -- which that commit's message claims it did not. It
was wrong; this corrects it rather than rewriting the commit.

UnmodifiableConfig.get(path) replaces both, which also reads better here:
the property key carries a colon rather than a dot, so passing the path
as a list is what keeps nightconfig from splitting it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Turns the ten red guards green. Staging resolved each dependency alone --
the newest file that project publishes for the pack's Minecraft -- and
never asked whether the resulting SET was coherent. Where it is not, the
loader refuses the pack, ~70 s of container is spent, and the CANDIDATE
wears the INCONCLUSIVE: the same "the mod never got a fair run" shape
this engine keeps a dozen guards for, arriving one layer earlier.

After staging and before generation, the pack is now judged against
itself: each staged jar's declared requirements against the versions
actually staged. A dependency whose demand cannot be met is dropped a
build and the pack is re-staged, up to DependencyBacktrack.MAX_BACKTRACKS
(10).

The live case, Modrinth/zoomify on Quilt / Minecraft 1.20.5:
yet_another_config_lib_v3-3.6.6+1.20.6-fabric.jar is tagged for 1.20.5
and declares "minecraft": "~1.20.5", so neither selection nor the
descriptor gate objects -- while demanding "fabric-api":
">=0.100.0+1.20.6". Verified against the live API: Modrinth publishes
exactly four fabric-api files for 1.20.5, 0.97.5 through 0.97.8. 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.

Four things it deliberately does not do:

- It never demotes the candidate. That is the subject of the experiment;
  swapping it would answer a question about a different mod.
- It never refuses. Every uncertainty -- no scanner, an unreadable jar, a
  version the platform never reported, a range VersionConstraint cannot
  parse, an exhausted budget -- proceeds to the boot exactly as before.
  A gate that refused on doubt is the mass-INCONCLUSIVE shape this module
  has already paid for twice.
- It ignores optional dependencies. The loader loads the mod without
  them, so one being older than a `recommends` asked for cannot be why a
  pack is refused.
- It ignores a requirement naming something not staged at all. That is
  refuseForMissingDependencies' case; demoting over a gap that dropping a
  jar cannot close would burn the budget and change nothing.

Cost, stated rather than optimised away: a backtrack re-stages from
scratch, so it re-downloads the candidate and every dependency. The
zoomify case needs seven of them (YACL ships 3.6.6 down to 3.6.0 tagged
for 1.20.5, every one a +1.20.6 build with the same demand). That is
still cheaper than the wasted boot it replaces, and skipping files
already on disk is an optimisation to make only if the rate warrants it
-- 2 of 250 live verdicts currently reach DEPENDENCY_FAILURE.

InjectedDependency gained `version`, which is how the judge learns what
is really staged without a second accumulator threaded through every
level of the recursion.

--rerun-tasks: clientside 410/410, grinder 503 (29 skip), app 149/149,
all green. No new compiler warnings.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The gap (each dependency resolved alone, the set never judged), the
measured zoomify case, the four things the judge deliberately does not
do -- each of which is what keeps it from becoming a refusal gate -- and
the re-download cost with the measurement that would justify optimising
it away.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Suite 390 -> 410, re-derived from
serverpackcreator-clientside/build/test-results/test/*.xml after a full
./gradlew build, and one line naming what landed so a reader knows which
module file to open.

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>
merge: three field reports from the live grinder, and the drift a build exposed
All checks were successful
Documentation / Writerside webhelp (push) Successful in 3m43s
Continuous / Build JAR (push) Successful in 12m52s
Qodana / scan (push) Successful in 21m17s
Docker Test / build image (push) Successful in 27m2s
Continuous / Build AppImage (x86_64) (push) Successful in 3m35s
Test / build (push) Successful in 18m20s
Continuous / Build AppImage (aarch64) (push) Successful in 3m43s
Qodana / notify (push) Successful in 51s
Documentation / Help image (push) Successful in 14m16s
Continuous / Build Install4J Media (push) Successful in 11m2s
Continuous / Continuous Pre-Release (push) Successful in 5m0s
300a4aae6c
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>
Committed red: three of the four fail, and the red is the missing implementation, not a broken
fixture -- `expected: <[]> but was: <[Conflict(... requiredModId=balm, versionConstraint=>=26.2.0,
stagedVersion=Balm 26.2.0.7)]>`.

`VersionConstraint` promises to fail toward accepting, and `DependencyBacktrack`'s own class doc
repeats the promise: "VersionConstraint accepts anything it cannot read, so an unparseable range
never produces a conflict". The promise is kept on the *constraint* side only. On the version side
`numbersOf` splits on `.` and maps a digit-less component to `0`, so `Balm 26.2.0.7` reads as
`[0, 2, 0, 7]` and `balm-fabric-26.2-26.2.0.7.jar` -- everything before the first `-` -- as `[0]`.
Both then sit below almost any range, and a conflict is manufactured out of decoration.

Text arrives there by design: CurseForge has no version field, so `CurseForgePlatform.toModFile`
fills `ModFile.version` with the author-typed `displayName`, documented in place as "often
decorated". Every CurseForge dependency therefore carries a version this parser cannot read.

Measured on the live daemon 2026-09-07, one day after DependencyBacktrack landed: 1014
`re-staging ... without it` lines and 146 `publishes no ... file for Minecraft` lines in one day,
ending in 47 published ERROR verdicts for files that exist. Asked directly (misc/cf-dependency-probe.sh),
the CurseForge API returns every one of them correctly loader-tagged -- balm-fabric-26.2-26.2.0.7.jar,
architectury-9.2.14-fabric.jar, thermal_foundation-1.20.1-11.0.6.70.jar and ten Fabric-tagged
create-fabric 1.20.1 builds. The demote loop had exhausted the file list first, and `withoutExcluded`
left `pickDependencyFile` nothing to pick.

`aReadableVersionIsStillCompared` also catches a second, older instance of the same defect that the
fuzz test carries in its version list and never asserts on: `v2.1` parses as `[0, 1]`.

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 defect verbatim -- all three cases produce the byte-identical
sentence `Required dependency unavailable for Fabric / Minecraft 26.2: yacl.`, differing only in
whether the project published nothing usable, the download died, or staging dropped every build itself
while backtracking.

`unsatisfiedLabel` distinguishes two of the five ways a dependency reaches `unsatisfied` (an unresolved
ref, a distribution-locked file); the remaining three print the bare slug. That is the same standard
this module already enforces on boot verdicts -- `BootDecision.decidedBy` exists precisely so a verdict
can name its evidence -- not yet applied to a staging refusal, which publishes ERROR just the same.

The cost is measured, not hypothetical. Diagnosing 2026-09-07's 47 such rows needed a CurseForge API
probe against Griefed's key to establish the files existed and were correctly tagged, plus a log grep
on the daemon host to find 1014 `re-staging ... without it` lines against 4 real staging failures. The
verdict itself said none of that, and the backtrack case says the opposite of the truth: the project
published builds, and we excluded them.

Harness is DependencyBacktrackStagingTest's -- fake platform, real jars written to disk, generation
made unreachable so every assertion is about staging.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Turns UnmetDependencyReasonTest green; clientside is 417 tests, zero failures, and -app and -grinder
still compile against it.

`UnmetReason` carries the evidence: UNRESOLVED, NO_USABLE_FILE, DROPPED_BY_BACKTRACK,
DISTRIBUTION_LOCKED, DOWNLOAD_FAILED. `refuseForMissingDependencies` renders it per entry, so the three
failures that shared one sentence now read differently:

  ... : yacl (nothing published for this loader and Minecraft version).
  ... : yacl (download failed).
  ... : yacl (every usable build was dropped resolving a version conflict).

DROPPED_BY_BACKTRACK is the one worth the work. `backtrackReason` re-runs the same pick over the
*unfiltered* file list, so "the project publishes nothing usable" is distinguished from "it does, and
we excluded all of it" — the second having been reported as the first, which is the opposite of the
truth and is what hid 1014 re-stagings behind 47 verdicts on 2026-09-07. It costs no request: the
project is already resolved and in hand.

**The reason travels BESIDE the name, not inside it.** `unsatisfied` is now `Map<name, reason>` rather
than `Set<label>`, because the dedupe that keeps one mod one entry when it is missing by both the
platform and the manifest route (the `waystones` case) keys on the name — and two routes can fail for
two different reasons. `unsatisfiedLabel` therefore lost its `file` parameter and does one job: naming.

STOP-AND-FLAG, per the refactor conventions: this changed the *expected value* of one existing
assertion. `everyMissingDependencyIsNamedInAStableOrder` asserted the substring `alpha, zeta`, and the
two names are no longer adjacent now that each carries a reason. It asserts the ordering by position
instead, which is the rule it was always about. Every other test edit is argument adaptation with
assertions untouched (`setOf` -> `mapOf`, the new `platformName`), except the two locked/obtainable
label tests, which now make the same claim through the refusal because that is where locked moved to.

`planManifestDependency` gained an `excluded` parameter (defaulted, so its existing tests are
unchanged) and now applies the exclusions itself rather than being handed a pre-filtered project —
without the unfiltered list it cannot tell the two refusals apart either. `withoutExcluded` moved to
the companion for the same reason.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Two entries under the DependencyBacktrack section, where the next reader of that code will be: the
platform release name that cannot be compared as a number (with the live counts and the probe that
established the files existed), and the UnmetReason vocabulary a staging refusal now publishes.

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 NestedDependencyConflictTest green; clientside is 419 tests, zero failures, and no existing
assertion changed -- BundledJars' behaviour for `idsIn` is identical, only its internals were split so
both questions read the same jars by the same rules.

`BundledJars.versionsIn` reads each nested descriptor's own `version` beside the ids `idsIn` already
returned, and `BootVerifier.nestedVersions` folds those into `stagedVersions` **under** the top-level
entries -- a jar-in-jar copy can only fill a gap, never overwrite the build staging deliberately chose,
which is also the build a demotion would act on.

Ambiguity is dropped rather than guessed, at both levels: an id bundled at two different versions
(within one jar, or across two staged jars) contributes nothing. Which copy a loader picks is its own
resolution behaviour, and this module fails toward proceeding -- no opinion costs a missed conflict,
a wrong one manufactures a demotion.

An id whose nested descriptor states no version still counts as *present* via `idsIn`, so
`stageableRequirements` keeps skipping its download; it simply has no version to be compared against.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Beside the other two entries from this investigation, where the next reader of dependencyToDemote will
be: what the map was missing, the live createaddition/ponder evidence, and the two deliberate
restraints (nested loses to top-level, ambiguity contributes nothing).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Reported as unresolved dependencies for createaddition, crafting-tweaks and cooking-for-blockheads;
found to be 47 published ERROR rows on the live daemon, for files CurseForge returns on request.

Three defects, each pinned red before its fix:

- A CurseForge `ModFile.version` is the author-typed `displayName`, and `numbersOf` mapped its
  digit-less first component to 0 -- `Balm 26.2.0.7` read as `[0, 2, 0, 7]`. Practically every
  CurseForge dependency therefore looked older than its declared range, DependencyBacktrack demoted
  it, and the demote loop walked each project's file list to the end before staging refused. Measured
  live the day after the backtrack shipped: 1014 re-stagings and 146 "publishes no ... file" lines in
  one day against 4 real staging failures.
- A staging refusal printed the bare slug for three of the five ways a dependency goes unmet, so
  "publishes nothing usable", "the download died" and "we dropped every build ourselves" were one
  sentence. Diagnosing the 47 rows needed a CurseForge API probe and a log grep on the daemon host
  purely because the verdict named no evidence.
- A jar-in-jar library was invisible to the coherence check, so a pack whose own jars contradict each
  other still booted and the candidate wore the INCONCLUSIVE (createaddition: create demands ponder
  [1.0.82,), the pack held the 1.0.64 nested in another jar).

Suites after: clientside 419, grinder 503 (29 skipped), app 149, zero failures.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Findings A-2, A-3, A-4, A-5 and A-7 of claude-docs/ANALYSIS-AUDIT.md, all green on landing: they
characterise behaviour that is already correct but was asserted nowhere, so each one is a mutation the
suite could not previously see.

- BundledVersionTest (new, 8 guards) pins `versionsIn`: the version read from a nested descriptor, a
  `provides` alias carrying it too, a nested mod with no version staying *present* via `idsIn` while
  contributing none, the Quilt spelling, unreadable input — and both ambiguity rules, which are the
  safety property and were the mutation nobody would have caught.
- NestedDependencyConflictTest gains `aTopLevelJarOutranksABundledCopyOfTheSameId`. That rule lived
  entirely in the operand order of `nestedVersions(...) + scanned…`. **Mutation-verified**: swapping the
  operands makes it fail with `create-6.0.8.jar` where `create-6.0.10.jar` belongs, and nothing else in
  the suite notices. Its sibling also moves from `contains` to an exact-set assertion (A-7), and the
  duplicated jar builder in it is gone — one parameterised harness now serves every case.
- UnmetDependencyReasonTest reaches the two reasons that were asserted only where the string is
  *rendered*, never where it is chosen: DISTRIBUTION_LOCKED and UNRESOLVED, both through real staging,
  plus `backtrackReason`'s three branches directly.

Two of these were red first for reasons worth recording, because both were faults in the test rather
than the code, and running them before committing is what separated the two:

- The fake downloader ignored `downloadUrl`, so a distribution-locked file "downloaded" fine and the
  case came back as a backtrack. It now mirrors `HttpJarDownloader`'s first line. A fake that is more
  capable than the real thing cannot test the path where the real thing refuses.
- The middle `backtrackReason` branch as first written asked the function about a state it is never
  called in (a filtered pick that would have succeeded). Reframed to the branch that does occur —
  exclusions present, nothing usable published either — and the precondition is now stated in the test.

clientside 419 -> 433, zero failures (re-derived from build/test-results, not incremented).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Committed red on exactly one guard, and the red is the finding: `20260908120000 exceeds Int.MAX_VALUE,
and reading it as 0 puts the version below the bound ==> expected: <true> but was: <false>`.

A-1 of claude-docs/ANALYSIS-AUDIT.md. `numbersOf` ends in `toIntOrNull() ?: 0`, and `readableVersion`
admits any component that is all digits — so ten-plus digits, which is what a date or a CI counter looks
like, parses to null and is read as **zero**. That is the `Balm 26.2.0.7` defect reached one door along:
all digits is not the same question as a number we can hold.

Latent, not live: nothing in the observed corpus hits it, and the cost is a wrong demotion rather than a
wrong sideness verdict. Pinned anyway, because this exact shape published 47 verdicts last week.

Two boundary guards land green beside it and stay: `Int.MAX_VALUE` itself still compares, and the
not-a-version edges (`v`, `1..2`, `1.`, `.1`, blank, `1_0`, `one.two`) all accept.

`1.0.0-rc1+build` is deliberately excluded from that edge list — first written into it, and it went red
for the right reason: its core is `1.0.0`, which is readable, and refusing it against `>=99.0` is
correct. It is now asserted as such, so nobody widens the guard into "anything ornamented accepts".

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>
Behaviour-preserving: clientside 436, zero failures, and not one assertion, argument or expected value
changed. Findings M-2 (REFACTOR-AUDIT) and A-6 (ANALYSIS-AUDIT) of the same-day pass.

M-2 — the "drop every id that resolved to more than one version" rule was written twice, once inside a
jar and once across a pack's jars. `BundledJars.unambiguous` is now the single implementation and
`BootVerifier.nestedVersions` calls it. Two copies of one rule is precisely the
MetadataScanner/ModListCompiler drift this module's context file opens with; it was caught the day the
second copy appeared, which is the cheapest it will ever be to merge them.

Safe to do now rather than earlier: BundledVersionTest and `aTopLevelJarOutranksABundledCopyOfTheSameId`
landed first and pin both levels of the rule, including the mutation that swaps the precedence.

A-6 — `explain()` returned `String?`, `null` meaning "the label already says this", and two log sites
interpolated it directly. Neither can reach that value today, so the literal `null.` would have appeared
only after some later edit, silently. It now always returns a sentence, and whether to append it to a
refusal is a rendering decision that lives in the renderer as `worthAppending`. The published refusal
strings are byte-identical, which is what keeps the label `refactor:`.

`nestedVersions` also becomes `internal` — it is pure, it takes its input as a parameter, and the
companion is where this file's other pure decisions already live.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The second half of A-2, reachable now that `nestedVersions` sits in the companion: two staged mods each
bundling a different build of one library yield no version at all, and two bundling the same build agree.

Asserted on `nestedVersions` directly rather than through staging on purpose. The end-to-end route would
have to infer which wrong version a broken fold kept, and that depends on `File.listFiles()` order — a
guard that fails only on some runs is worse than no guard, and this repository has paid for flaky
evidence before.

clientside 436 -> 438, zero failures.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Findings L-1, L-2 and L-3 of claude-docs/REFACTOR-AUDIT.md. Documentation only; suite green at 438.

L-1 — `DependencyBacktrack.Conflict` carried a one-line class doc and five bare properties. Each now
says what it is, and the type doc says what the pair of `versionConstraint` and `stagedVersion` is for:
together they are the whole finding.

L-2 — `Requirement` used a class-level `@param` block, which the root conventions call out specifically:
dokka does not attach those to the properties, so they render undocumented. Reshaped to one documented
parameter per line. Names, types, order and defaults are untouched, which is the only thing that reshape
is allowed to change.

L-3 — pre-existing, from `39d340340` (2026-08-23, `fix(clientside): re-check a crash across loaders and
Minecraft lines`), surfaced rather than deferred: two KDoc blocks were stacked before `bootableReleases()`,
so the block describing `bootableCombination()` sat above the wrong function and that one had no doc at
all. Moved to the function it describes. Nothing else in either file was touched — the Boy-Scout fix is
the move, not a rewrite.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Two appended sections, one per log, covering `300a4aae6^1..HEAD` — the 2026-09-06 field reports and the
eight commits that fixed what the third of them did to the live daemon.

REFACTOR-AUDIT carries the per-commit red/green verification: eight commits checked out in a detached
worktree and run individually, every pin red at its own commit and green at the next. It also carries the
methodology landmine that nearly produced two false findings — reusing one build directory across
checkouts made Gradle answer `No tests found` for a class that was in the source tree, which is the same
incremental-compilation trap `18f59b4bf` is already recorded for. Wipe the module build directory between
checkouts, and treat `No tests found` as its own outcome rather than as RED.

ANALYSIS-AUDIT carries the depth pass: one HIGH (the `Int.MAX_VALUE` repeat of the decorated-version
defect), four MEDIUM coverage and duplication findings, and a "do not re-litigate" list for the four
things that look wrong and are not — `backtrackReason` ignoring a constraint whose null-ness cannot
differ, `idsIn` being behaviour-identical after its extraction, the absence of any new warning or unused
import, and the absence of any new concurrency or security surface.

Both are appended, never overwritten: earlier sections are the evidence that stops the same ground being
re-argued.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Findings M-4 and M-5 of claude-docs/REFACTOR-AUDIT.md.

M-4 — REFACTOR-LOG's last entry was 2026-09-05, so neither the 2026-09-06 field reports nor the
2026-09-07/08 storm fix had a narrative anywhere outside their commit messages and the module context
file. Two entries appended: the three field reports in short form (each one's detail lives in
`serverpackcreator-clientside/CLAUDE.md`), and the storm with its three defects, the live measurements,
and the two lessons the same-day audit produced.

M-5 — the root status table said clientside 410 and its prose stopped at 2026-09-06. Now 438,
re-derived from `build/test-results` rather than incremented, with the header date moved off 2026-08-31.
The row also says what the backtrack cost before it was corrected, because a reader arriving at
`DependencyBacktrack` should meet that in the same breath as the feature.

The suite figures are deliberately given as two spans rather than one: 410 is what the tree carried at
`300a4aae6` and is measured, the 2026-09-06 batch reported 395 at its own commit, and chaining those
into a single number would state a figure nobody took.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
docs: close both audit logs — every finding resolved or accepted
All checks were successful
Documentation / Writerside webhelp (push) Successful in 4m50s
Continuous / Build JAR (push) Successful in 15m5s
Qodana / scan (push) Successful in 14m14s
Documentation / Help image (push) Successful in 8m44s
Continuous / Build AppImage (x86_64) (push) Successful in 3m34s
Test / build (push) Successful in 16m42s
Docker Test / build image (push) Successful in 33m33s
Continuous / Build AppImage (aarch64) (push) Successful in 3m1s
Qodana / notify (push) Successful in 1m46s
Continuous / Build Install4J Media (push) Successful in 10m40s
Continuous / Continuous Pre-Release (push) Successful in 4m43s
786b7bfe93
A resolution table per log, naming the commit that closed each finding, so the next reader can tell
"fixed" from "still open" without re-deriving it.

One finding is accepted rather than fixed: M-3, the behaviour-preserving refactor merged inside
`06c3ac3c5`. Splitting it now would rewrite shared history for a move that was disclosed in its own
commit message and changes no behaviour; it is recorded so the next audit does not re-raise it.

Both tables end on measured numbers rather than assertions: clientside 438, grinder 503 (29 skipped),
app 149, zero failures, result files timestamped after the last code commit — which is the check L-4
existed to demand.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Red on purpose, every guard failing with the one NotImplementedError from the two TODO() stubs
(`LoaderVersionDemand.unmetIn`, `BootVerifier.shouldRecheckOnNewestBuild`), so the test tree compiles
and nothing fails for a second reason.

BootVerifierCrashRecheckTest's own doc states the hazard this covers: "a mod needing a newer loader
than the cached build fails to load, the server exits non-zero, BootLogClassifier reads that as
CRASHED". `shouldRecheckCrash` re-boots such an outcome on the newest build before it may stand. Then
`dependencyFailureMarkers` was widened on 2026-08-29 and that console became INCONCLUSIVE, which the
re-check never looks at — the guard's premise moved out from under it.

Measured on the live daemon 2026-09-08, ten hours after a full clear: all 511 Fabric boots ran loader
0.19.3 while Fabric's stable is 0.19.5, and 17 of 42 DEPENDENCY_FAILURE rows are exactly this —
fabric-language-kotlin, a dependency of a great many mods, demands fabricloader >=0.19.5. Each is an
INCONCLUSIVE charged to a candidate over the harness's choice of loader build.

`CachedLoaderVersions` logs the property that does not hold when it reuses an older cached build:
"(a crash on it is re-checked against <newest> before it counts)". True of a crash; false of the
dependency failure that same choice actually produces.

Consoles are verbatim from live boot logs — Fabric from Modrinth/libipn, Quilt from
CurseForge/fzzy-config (which words it "requires version [0.19.5, ∞) of fabricloader"), and the
negative from CurseForge/createaddition's ponder demand. Only the FML loader case is constructed, from
the same message shape with the loader as the unmet id rather than a mod; that is stated in the doc.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Turns LoaderTooOldRecheckTest green; clientside 446, zero failures. `BootVerifierCrashRecheckTest`'s
four existing assertions are unchanged in substance — only the function name they call moved, and the
crash cases still behave exactly as they did.

`shouldRecheckCrash` becomes `shouldRecheckOnNewestBuild` and gains a second arm: a crash, **or** any
outcome whose console says the loader itself was too old. `LoaderVersionDemand.unmetIn` reads that,
matching a version-demand phrase and a runtime-provided loader id **on the same line**, which is what
separates "replace fabricloader 0.19.3 with 0.19.5" from `Mod ID: 'ponder' … Expected range '[1.0.82,)'`
— a demand no newer loader can satisfy, and `DependencyBacktrack`'s case, not this one.

SURVIVED is never re-run: it already answered the question.

Why it is not a BootRule: the rules file maps a console onto a verdict, this maps one onto "try again
differently". An operator's typo should cost accuracy at worst, never containers.

`mentions` requires a whole-word match so `forge` does not fire inside `forgeconfigapiport` — which is
a real id in the live store, and would otherwise have re-booted every pack that names it.

The re-check's log line and `reconcileRecheck`'s three notes said "crash" of an outcome that is now
often a dependency failure; they say "the boot"/"the failure" instead. The existing assertions match on
"52.1.16", "confirmed" and "could not be re-checked", all of which survive.

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: `expected: <Alias(ref=yacl)> but was: <Guess(ref=yet_another_config_lib_v3)>`.

YACL's mod id carries its major version (`yet_another_config_lib_v3`); both platforms publish the
project as `yacl`, so the optimistic slug guess resolves to nothing and a guess never refuses — the mod
boots without it and the loader refuses the pack instead.

Observed twice before being added, which is the bar `KnownModIds` sets for growing the table:
Modrinth/do-a-barrel-roll on Fabric / Minecraft 26.2 failed with "requires any version of
yet_another_config_lib_v3, which is missing", and the zoomify backtrack case reached the same project
by its platform ref.

What makes it unreachable by any other route: **Modrinth marks YACL `optional` for do-a-barrel-roll
while the jar declares it under `depends`**. The platform half therefore filters it out — correctly,
that filter exists and was added deliberately — leaving the manifest half as the only way in.

Pinned as a shape (`yet_another_config_lib_v<digits>`) rather than one literal, for the reason the
Fabric API modules are: the suffix tracks the library's major version and has already moved once, so a
literal entry would go stale at the next major and cost the same debugging session again. The
counterweight guard keeps the shape narrow — another library with a versioned id is still only a guess.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Turns the pin green; clientside 448, zero failures, no existing assertion touched.

`yet_another_config_lib_v<digits>` joins the Fabric API and QSL module shapes as a recognised alias,
resolving to `yacl` on Modrinth and `667299` on CurseForge — both verified against the live APIs today
(Modrinth `slug=yacl id=1eAoo2KR`, CurseForge id 667299, title "YetAnotherConfigLib").

Third and last of the three fixes agreed for this round. Note that the id-learning work Griefed asked
about next would subsume this particular entry — YACL is *linked* from do-a-barrel-roll's Modrinth
page, so a learned mapping could read the id straight out of the downloaded jar instead of being told.
It is added anyway because it also covers the case learning cannot reach: a descriptor naming an id no
platform links at all.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Red: all seven guards fail with the single NotImplementedError from the TODO() stub, none for a second
reason.

Reported by Griefed from the live daemon: `CurseForge/aether` on Forge / Minecraft 1.20.2 published
"Required dependency unavailable … owo-lib (nothing published for this loader and Minecraft version)".
Both halves of that sentence are true — owo-lib publishes no Forge build at all — and the conclusion is
still wrong, because the Forge/NeoForge jar's mods.toml does not list owo-lib. Only Aether's Fabric and
Quilt builds need it; CurseForge's per-file relations carry it anyway.

The rule this pins: a platform's dependency list is a self-report by the author, while the jar's
descriptor is what the loader enforces, so where they disagree the descriptor wins. Same relationship
the ladder already encodes one layer up — the console decides, the metadata only declares.

The matching is fuzzy on purpose and the doc says why: an unstageable project can never be downloaded,
so its declared mod id is unknowable at this point and the comparison runs between the project slug and
the ids the descriptor names. `kleeslabs` declaring `balm-fabric` against a project published as `balm`
is the case that forbids an exact match, and a two-letter fragment is the case that forbids a loose one.
Both mistakes cost at most one container and neither can produce a wrong sideness verdict.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Turns PlatformDependencyDemandTest green and adds the end-to-end half; clientside 456, zero failures.

`CurseForge/aether` on Forge / Minecraft 1.20.2 was published ERROR for `owo-lib`. Every fact in that
refusal is true — owo-lib publishes no Forge build — and the conclusion is wrong: the Forge/NeoForge
jar's mods.toml does not list owo-lib, only the Fabric and Quilt builds do, and CurseForge's per-file
relations carry it regardless. The candidate wore a verdict about its project page.

A platform dependency that cannot be staged now refuses only when the staged jar's **own descriptor**
asks for it; otherwise it is recorded in `unmapped` — reported, never fatal — and the boot proceeds.
Both failure branches obey it, the unstageable one and the failed download, because the question is the
same in each.

The descriptor is read **once per staged jar** (`declaredDependencies`) and handed to both halves of
staging. They used to scan the same file separately, which is duplicated work and two chances to
disagree about what it said. `null` (no scanner, or a scan that threw) and empty are kept distinct:
`null` means we do not know and the platform stays in charge, which is the behaviour that predates this.

**Mutation-verified, because the end-to-end guard landed after the code it covers.** Disabling the
demand check makes `aPlatformDependencyTheJarNeverNamesDoesNotRefuseTheBoot` fail at the assertion that
the refusal is absent. The pure predicate was pinned red first, in its own commit; this is the
production wiring, and a predicate proved correct in isolation says nothing about whether production
consults it — the lesson `DependencySlugTest` exists for.

`aProjectPublishingNothingUsableSaysSo` is the counterweight and still passes: there the descriptor
does declare `yacl`, so the refusal stands exactly as before.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Red: all eight guards fail on the one TODO() stub.

Griefed's question was whether `KnownModIds` can be automated, and the answer is that most of its
entries are facts the grinder already held. Every entry today costs a human noticing a wasted boot,
reading a log and looking the project up on two platforms — while staging had the project downloaded
and its descriptor states the mod id.

What this pins: a jar staged under ref R that declares id X proves this platform serves X at R. That is
evidence, so a learned mapping is an Alias with the refusal rights an alias carries, while an id no jar
has proved stays a Guess. It compounds across candidates —
`yet_another_config_lib_v3` is unmappable by spelling, and the moment any candidate stages YACL through
a platform ref, every later candidate declaring that id resolves for free.

Two rules worth the guards: a ref is only valid on the platform it came from (a Modrinth base62 id is
not a CurseForge number), and the FIRST project to prove an id keeps it — two projects declaring one id
is an upstream collision this cannot adjudicate, and overwriting would make the answer depend on grind
order, the same reason `BundledJars.unambiguous` drops a contested version rather than picking one.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Turns LearnedModIdsTest green and proves it through the staging join across two grinds. clientside 466,
grinder 503 (29 skipped), app 149, zero failures.

Griefed asked whether `KnownModIds` can be automated. Most of it, yes: every entry is a fact staging
already held. It downloads the project and the jar's descriptor states the mod id — the table exists
only because nothing wrote that pair down.

`LearnedModIds` is that pair, recorded as each dependency is staged. The ref now travels with the
recursion (`stagedFromRef`), and `identityOf` reads the jar's own `id` + `provides` from the descriptor
it already has on disk. `stageManifestDependencies` consults what has been learned before the table, and
`platformRefFor` does too — they must agree, or the dedupe re-downloads what the learned map matched.

LearnedMappingStagingTest is the claim end to end: a library whose mod id (`mysterylib_v9`) resembles
its ref (`weird-slug`) not at all, is in no table, matches no shape, and which the platform will not
answer to by id. Grind the mod whose page links it, then grind a mod that only names the id — it stages.
Its counterweight runs the second grind with a fresh map and asserts the candidate stages alone, so the
pair cannot pass against a platform that simply answered to ids.

Bounded deliberately. It records what staging fetches anyway and never fetches a project to find out
what is inside it — the last step Griefed described, downloading a linked project *because* an id is
unresolved, closes the remaining gap (an id reachable only through an `optional` link, which is exactly
how Modrinth lists YACL for do-a-barrel-roll) at the cost of downloads on a path that currently fails
for free. That is a decision to take on its own, not one to smuggle in here.

Two rules keep it honest: only a jar's own identity is learned, never what it bundles — a nested
`fabric-api-base` belongs to Fabric API, and recording its host would send a later candidate to the
wrong project — and the first project to prove an id keeps it, because a collision this cannot
adjudicate must not resolve differently depending on grind order.

`learnedModIds` sits before `bootArtifactSink` in the constructor, which the parameter-order landmine
requires to stay last.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Re-derived from build/test-results after the loader re-check, the aether fix and the learned id
mapping, not incremented.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Red on both platforms, with the empty pool the field currently returns: `YACL is linked as optional and
is exactly the project this pool exists for: []`.

`requiredDependencies` is what gets staged and stays exactly as strict as it is. `relatedDependencies`
is a second, wider list that stages nothing by itself — it is the pool of projects worth *asking what
they are* when a required mod id resolves to nothing, which is the last step of the algorithm Griefed
described.

Optional links have to be in it or the reported case is unreachable: Modrinth/do-a-barrel-roll declares
`yet_another_config_lib_v3` under `depends` in its jar while Modrinth lists YACL for it as **optional**,
so the required list never mentions YACL and the id resolves to nothing by spelling. The link was on the
page the whole time.

Incompatible links are excluded deliberately — downloading a project the author declared incompatible to
read its id would be reading the right file for the wrong reason, and a match would then stage the one
jar that must not be there. Modrinth's `embedded` is out for a different reason: it is already inside
the jar, which `BundledJars` covers.

Fixtures use the live shapes: Modrinth's four dependency_types on do-a-barrel-roll's real version, and
CurseForge's numeric relationTypes 3/2/1/5.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Turns LinkedDependencyTest green; the whole clientside suite is green and `requiredDependencies` is
byte-identical on both platforms — every existing dependency guard still asserts exactly what it did.

Modrinth filters `dependency_type` to `required`+`optional`, CurseForge relationTypes to 3+2. Embedded
(Modrinth `embedded`, CurseForge 1) is already inside the jar, which is `BundledJars`' case, and
incompatible (5) is excluded on purpose: fetching a project the author declared incompatible in order
to read its id would be the right file for the wrong reason.

Nothing consumes the new list yet — this is the model half, split from the behaviour that uses it so
the widening can be reviewed on its own.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Red where it must be: `expected: <[Learner-1.0.0.jar, MysteryLib-9.0.0.jar]> but was:
<[Learner-1.0.0.jar]>` — the library the jar hard-depends on is not staged, because the only trace of
it anywhere is an optional link on the candidate's own page.

That is `Modrinth/do-a-barrel-roll` in miniature: it declares `yet_another_config_lib_v3` under
`depends`, Modrinth lists YACL for it as optional, and the id resolves to nothing by spelling. The
required list never mentions it, so today the boot goes ahead without it and the loader refuses the
pack — one container for a fact the page was carrying all along.

`nothingIsProbedWhileTheIdStillResolves` lands green and is the cost rule: probing is only worth a
download because the alternative is a wasted container, so a pack whose ids already resolve must not pay
for it. It asserts the exact set of files fetched, via a recording downloader, rather than trusting that
no extra work happened.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Turns the probe pin green. clientside 471, grinder 503 (29 skipped), app 149, zero failures.

The last step of the algorithm Griefed described, and it closes `do-a-barrel-roll`: the jar declares
`yet_another_config_lib_v3` under `depends`, Modrinth lists YACL for it as **optional**, and the id
resolves to nothing by spelling — so the required list never mentions it and the container was spent
booting a pack the loader immediately refused.

`askLinkedProjects` downloads what the page links, reads what each one is, and stops at the first that
declares the wanted id; the ordinary planner then runs again and stages it, so one code path still
decides what enters the pack and the injection record, recursion and dependency cap all still apply.

Gated exactly as asked — only for a requirement that is required (optional ones never reach that loop),
declared by the jar, and unresolvable by the learned map, the table and the slug guess. Against the
alternative, which is a whole wasted container, a jar download is cheap; against a pack whose ids
already resolve, it costs nothing at all, and that is pinned rather than assumed by asserting the exact
set of files fetched.

Everything probed is learned whether it matched or not, so a project identified once is never fetched
to be identified again. The probe copy is written outside `mods/` and deleted immediately: a project
that turns out to provide something else must not end up in the pack.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Re-derived from build/test-results after the linked-project probe, not incremented.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Red: two guards on the TODO() stubs, and `onlySomethingNewAnnouncesItself` on `expected: <1> but was:
<0>` — nothing announces anything yet.

Griefed asked for the map to be persisted to SPC_GRINDER_HOME, so clientside needs three things and no
more: a snapshot as plain data, a restore, and a hook telling an owner when there is something new worth
writing. The file, its format and its location stay in the grinder, where every other piece of daemon
state already lives (`JsonVerdictStore`, `JsonCursorStore`).

Two rules the guards state. Only a genuinely new pair announces itself: every staged dependency
re-declares its own id on every candidate that uses it, so persisting on each `learn` would mean a write
per staged jar for a document that did not change. And restoring is silent — loading a file at startup
must not immediately ask to write it back.

The snapshot shape is `platform -> id -> ref`, nested rather than flat, because a ref is meaningless on
the other platform and a joined-string key would let that mistake through the file too.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Six guards, all red on the one TODO() stub. The clientside half of the same change lands here green —
`snapshot`, `restore` and the news-only `onLearned` hook, which the previous commit pinned red.

The contract is `JsonCursorStore`'s deliberately, because the failure modes are identical and the
daemon's answer to them must be too: a missing file is a first start, a corrupt one is logged and
treated as empty rather than refusing to boot the service, and each write is temp-then-atomic-move so a
crash cannot truncate the document. Everything in this file is re-derivable by grinding, so losing it
costs some probe downloads while refusing to start costs the whole service.

`reLearningTheSameThingDoesNotRewriteTheFile` is the one that matters for cost: every staged dependency
re-declares its own id on every candidate that uses it, so a write per `learn` would be a write per
staged jar for a document that did not change. It asserts the mtime does not move.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Turns JsonLearnedModIdsTest green and wires it through the daemon. clientside 475, grinder 509
(29 skipped), zero failures.

What one run proves about which project serves which mod id, the next run now starts with — otherwise a
restart re-pays every probe download, which is the one cost that route exists to avoid.

`JsonLearnedModIds` follows `JsonCursorStore` exactly, because the failure modes are the same: loaded on
construction, whole document rewritten temp-then-atomic-move so a crash cannot truncate it, and an
unreadable file logged and treated as empty rather than refusing to start. Everything in it is
re-derivable by grinding, so losing it costs downloads while refusing to boot costs the service. Writes
are guarded too — an unwritable disk must cost the memory of what was learned, not the boot in progress.

`SPC_GRINDER_LEARNED_IDS` defaults to `~/.spc-grinder/learned-mod-ids.json`. Adding it to `KNOBS` made
`ReadmeConfigurationTest` and `SystemdUnitConfigurationTest` fail until the README table and the unit
file described it, which is exactly what that landmine promises — the red was the documentation, and it
is now written.

`ContainerCandidateVerifier` takes the map and shares one instance across every grind; its default keeps
a verifier built in a test free of a home directory. The daemon hands in the file-backed one.

Known and accepted: a learned pair is identity, not availability, so it does not go stale the way a
version does — but a project that renames its mod id would keep answering to the old one until the file
is deleted. That is a `rm` of a pure cache, and it is documented as such in the README, the unit and the
module context file.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
docs: clientside 475, grinder 509 in the root status table
All checks were successful
Documentation / Writerside webhelp (push) Successful in 2m7s
Continuous / Build JAR (push) Successful in 14m36s
Qodana / scan (push) Successful in 12m46s
Docker Test / build image (push) Successful in 19m30s
Documentation / Help image (push) Successful in 5m39s
Continuous / Build AppImage (x86_64) (push) Successful in 2m32s
Continuous / Build AppImage (aarch64) (push) Successful in 1m51s
Qodana / notify (push) Successful in 43s
Continuous / Build Install4J Media (push) Successful in 8m37s
Continuous / Continuous Pre-Release (push) Successful in 4m5s
Test / build (push) Successful in 35m31s
955319dda1
Re-derived from build/test-results after the learned-map persistence, not incremented. app stays 149.

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>
The root CLAUDE.md listed this as open, pre-existing and CLI-only with the GUI unaffected. Two of those
three were understated: it is unbounded recursion rather than one failed instantiation, and nothing about
it is specific to CLI — it needs a populated plugins directory at first startup, which is every real run.

Both files now carry the measurement (53 wrappers / 268 log lines / OOM, against 0 / 7 / 0) and the
mechanism, including the part worth knowing well beyond this bug: Kotlin's SynchronizedLazyImpl re-runs
its initialiser on a re-entrant same-thread read rather than blocking, so two lazies that can reach each
other can loop.

Also records why the suite stayed green with the example plugin installed the whole time — a fixture
installed after the thing it is meant to exercise has already run is not a fixture. api 409 -> 412 in the
status table, re-derived.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Red on both halves: the client returns `null` where the feed says `CLIENT`, and the table's columns are
still `[, Name, Entry, Verdict, Loader, Platform, Scanned, Detail]`.

`Verdict` is the conclusion; `declared` and `jarScan` are the two readings it was concluded from, and
they disagree often enough to be worth reading beside it — on the live feed today, **161 of 2057** rows
are `CONTRADICTORY`, meaning the platform and the jar say different things about the same mod. The two
columns therefore sit immediately after `Verdict`: conclusion first, then what it rests on.

Values taken from the live feed rather than imagined: `declared` is CLIENT / SERVER / CONTRADICTORY or
absent, `jarScan` is CLIENT / SERVER_OR_BOTH / DEFERRED / ERROR. **18 of 2057 rows carry
`declared: null`**, so a guard pins that an unrecorded reading renders as an empty cell — "null" in a
table cell reads as a value rather than as its absence, which is the same conflation `textOrNull` exists
for one layer down.

The fields are on `GrinderVerdict` with `null` defaults so the tree compiles; nothing reads them yet.
Existing column guards iterate the columns rather than naming indices, so they neither changed nor
needed to.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Turns the pins green; plugin-grinder 69 -> 72, zero failures.

`GrinderVerdict` gains `declared` and `jarScan`, `GrinderClient` reads them off the feed, and the table
renders them immediately after `Verdict`: the conclusion first, then the two readings it was drawn from.

They earn the width. On the live feed today **161 of 2057** rows are `CONTRADICTORY` — the platform's
declaration and the jar's own descriptor disagreeing about the same mod — and that is precisely the row
a maintainer wants to look at by hand rather than take on trust. Reading the verdict without them says
what was concluded and not what from.

Both stay nullable and render as an empty cell when absent: 18 of 2057 rows carry `declared: null`, and
"null" in a table cell reads as a value rather than as its absence — the same conflation `textOrNull`
was written for one layer down.

Named `jarScan` after the feed's own field so the mapping is one hop, and labelled "JAR sideness", which
is what it means to someone reading the table.

Inherited, not designed, and worth knowing: the search box filters through a column-less
`RowFilter.regexFilter`, so it now matches these values too — typing `CONTRADICTORY` filters the table.

Not visually verified: the pane renders whatever the model reports, `AUTO_RESIZE_LAST_COLUMN` leaves
`Detail` absorbing the slack, and the only index anything outside the model names is `TICK_COLUMN`,
which is still 0.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Found by running the GUI with the plugin installed against the live daemon and looking at the tab, which
is the only way this class of thing shows up: at an equal share of the table the column rendered
`SERVER_OR_BO...` — and `SERVER_OR_BOTH` is not an edge case, it is 1718 of 2057 rows, so it clipped on
nearly every row of the table it had just been added to.

A *preferred* width of 140, not a minimum: the column still shrinks with the window, it just does not
start clipped. Same idiom the tick column already uses, one line below it.

`VerdictTableModel.JAR_SIDENESS_COLUMN` names the index rather than spelling `5` in the pane, and a guard
asserts the constant points at the column it names — the failure mode of a bare index is silently sizing
a *different* column, which nothing else would notice.

plugin-grinder 72 -> 73, zero failures.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Re-derived from build/test-results after the column-width guard.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
fix(plugin-grinder): widen Declared so CONTRADICTORY stops clipping
All checks were successful
Documentation / Writerside webhelp (push) Successful in 2m21s
Continuous / Build JAR (push) Successful in 14m51s
Qodana / scan (push) Successful in 13m0s
Docker Test / build image (push) Successful in 17m48s
Continuous / Build AppImage (x86_64) (push) Successful in 2m4s
Documentation / Help image (push) Successful in 6m33s
Continuous / Build AppImage (aarch64) (push) Successful in 2m1s
Qodana / notify (push) Successful in 24s
Continuous / Build Install4J Media (push) Successful in 8m15s
Continuous / Continuous Pre-Release (push) Successful in 4m17s
Test / build (push) Successful in 28m42s
adb21743a4
Second width found the same way as the first — by looking at the running GUI. The Other Verdicts tab
rendered `CONTRADICTO...` on every `3dskinlayers` row, which is the one value these two columns were
added to surface: 161 of 2057 rows, and the case where the platform and the jar disagree about a mod.
Clipping *that* defeats the point of having the column.

Preferred width 130, beside the 140 the JAR sideness column already had. `VerdictListPane` builds both
the Confirmed and the Other Verdicts pane, so one place sizes both.

`VerdictTableModel.DECLARED_COLUMN` joins `JAR_SIDENESS_COLUMN` as a named index, and the guard now
covers both — a bare index's failure mode is silently sizing a different column.

Verified after the change on the Confirmed tab, which carries a CONTRADICTORY row (`sodium` / NeoForge):
both `CONTRADICTORY` and `SERVER_OR_BOTH` now render in full.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`pickDependencyFile` refuses any Minecraft version but the exact one being
booted. That is right across a version-line and too strict inside one:
1.20.1, 1.20.2 and 1.20.3 run each other's mods, and a library that skipped
a patch release is not a missing dependency.

Measured against the live Modrinth API on 2026-09-09, six published ERROR
verdicts on grinder.serverpackcreator.de name a dependency that exists one
patch away -- playeranimator for Forge 1.20.2 (published 1.20, 1.20.1), yacl
and forgified-fabric-api for Forge 1.20.6, cobblemon for Fabric 1.21.11
(published 1.21.1), and QSL for Quilt 1.21.1/1.21.11 (published 1.21). QSL is
the case that shows the width is right: its last release is Minecraft 1.21 and
the project is discontinued, so every Quilt mod declaring a quilt_* module on
1.21.1 or later is refused permanently.

Red: 5 of the 9 guards fail for the missing fallback. The other 4 are the
boundaries it must not cross and pass already -- the version-line, the
NeoForge/Forge loader rule at 1.20.1 only, the exact match winning, and a
pre-release not being a patch neighbour.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A dependency publishing nothing for the exact Minecraft version being booted
now falls back to another patch release of the same version-line, nearest
first, ties going to the newer build. Only versions the project actually
publishes are considered, so the search is bounded by its real history.

The line stays the boundary the old refusal was right about -- a 1.19.4 jar in
a 1.20.1 pack is the conflict that rule exists to prevent -- and the fallback
widens nothing else. Cross-loading is still asked about the version the pack
BOOTS at, not the one the file carries, so a Forge 1.20.1 build is still not a
dependency for a NeoForge 1.20.2 pack.

The three preferences are now ordered explicitly in `preferenceLadder`:
obtainability, then the Minecraft version, then the declared constraint. That
promotes obtainability over the version match for the same reason it already
outranked the loader match -- a distribution-locked file has no download URL,
so an obtainable neighbour is a working dependency where a locked exact match
is nothing. The locked half of the ladder still runs last but does run, so a
project publishing only locked files still yields one and the refusal can name
the opt-out instead of claiming nothing is published.

484 tests, 0 failures (475 before, plus the 9 pins).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`stageableRequirements` drops a requirement that is optional, bundled inside
the candidate, environment-provided, or whose platform *ref* was already
resolved. It never asks whether the id is already in mods/. The ref dedupe is
not that question: a project is reachable under two equally-valid refs -- the
one the platform page links, and whatever LearnedModIds/KnownModIds maps the
manifest id to -- and when those differ the same id is looked up a second time
against a different project, whose "publishes nothing for this loader and
Minecraft version" then refuses the boot.

Ten published ERROR verdicts on grinder.serverpackcreator.de are this, measured
2026-09-09: create (copycats, create-steam-n-rails, createaddition on both
platforms), farmersdelight (ends-delight) and sophisticatedcore (both
unofficial Fabric ports). All ten declare the project as a platform dependency
too, so the jar was staged before the refusal was raised -- and Modrinth
project LNytGWDc publishes 17 Forge 1.20.1 and 11 NeoForge 1.21.1 files, so the
project publishing "nothing" is not the one in the pack.

Red: `aDependencyAlreadyInThePackDoesNotRefuseTheBoot` fails with the live
message verbatim, while its first assertion confirms the dependency really was
staged. `aDependencyMissingFromThePackStillRefusesTheBoot` passes already, so
the fix has to stay a dedupe rather than an amnesty.

The pure `stageableRequirements` guards land with the parameter they exercise
in the fix commit -- a parameter that does not exist yet cannot go red, only
fail to compile.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`stageableRequirements` now drops a requirement whose id the staged pack
already answers to, alongside the optional, bundled, environment-provided and
already-resolved-by-ref cases it dropped before. Every staged jar's own
identity (its `id` plus everything it `provides`) accumulates into a `provided`
set threaded through the staging recursion.

That closes ten published ERROR verdicts, measured 2026-09-09: create for
copycats/create-steam-n-rails/createaddition on both platforms, farmersdelight
for ends-delight, and sophisticatedcore for both unofficial Fabric ports. Each
declares the project as a platform dependency as well, so the jar was staged
and then the same id was resolved a second time -- under the ref the learned
map holds rather than the one the page links -- against a different project
that publishes nothing for the loader being booted.

Compared lowercased on both sides, unlike the neighbouring `bundledIds`, which
compares two ids read by the same scanner; a case mismatch here costs a boot.

The descriptor is now read once per staged jar for all three of its readers,
where `declaredDependencies` and `identityOf` scanned the same file separately
-- two chances to disagree about what it said. `scanStagedJar` returns the
`ScannedMod` list, `identityIn` derives the ids from it, and null still means
"could not be read" as distinct from "declared nothing".

489 tests, 0 failures (484 before, plus the 5 pins).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`LearnedModIds` records one ref per id and keeps whichever project proved it
first. That is the right call about overwriting -- grind order must not decide
the answer -- and the wrong one about forgetting: cross-loader forks and
unofficial ports deliberately keep the original's mod id, so whichever is
ground first owns the id for every loader afterwards. And a learned mapping is
an Alias rather than a Guess, so the wrong project's "publishes nothing for
this loader and Minecraft version" carries the right to refuse the boot.

The live row is `chefs-delight` on Forge / Minecraft 1.20.1, published ERROR
for `farmersdelight` (2026-09-09). It can only have come from the manifest
route: the platform route labels an unmet dependency with the resolved
project's slug, which is `farmers-delight`, and the manifest route refuses only
on a confident mapping, which KnownModIds does not give that id -- it gives a
Guess. So the alias came from the learned map, while the real project publishes
FarmersDelight-1.20.1-1.3.4.jar for Forge 1.20.1, verified live the same day.

Red: `aSecondProjectIsTriedWhenTheFirstCannotStage` stages only the candidate,
because the first ref's project has no build for the boot and nothing tries the
second. `anIdNoProvenProjectCanStageStillRefuses` passes already, so the fix
must not become an amnesty.

The unit-level guards over `refsFor`/`mappingsFor`/`planManifestDependency`
land with those signatures in the fix commit; a parameter that does not exist
yet cannot go red, only fail to compile.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`LearnedModIds` now keeps every ref that has proved an id, in the order they
proved it, and `planManifestDependency` tries each in turn. The first prover
still leads -- grind order must not decide the answer -- but it no longer owns
the id, and a project with no build for the boot in hand no longer ends the
search. Keeping every prover is what makes this loader-aware without adding a
loader dimension: `pickDependencyFile` already filters by loader and Minecraft
version, so whichever project fits is the one that stages.

That closes `chefs-delight` on Forge / Minecraft 1.20.1, published ERROR for
`farmersdelight` while Farmer's Delight publishes FarmersDelight-1.20.1-1.3.4
.jar for exactly that combination (verified live 2026-09-09). It is also the
general fix behind the create / farmersdelight / sophisticatedcore collisions
that the provided-ids dedupe closes from the other side.

The safety property is untouched: a refusal needs every mapping to have failed
and only an alias may raise one, so a guess that is almost resolvable twice is
still no worse than being unknown once. The registry's answer is tried last
rather than instead, and is skipped when it names a ref already learned.

The ref that actually staged is now claimed in `visited` too, since with
several mappings per id the ref `platformRefFor` claimed need not be the winner.

The persisted document's values become lists. `JsonLearnedModIds` reads the old
bare-string shape as well, because rejecting it would silently re-pay every
probe download the deployed daemon has ever made.

Existing expectations wrapped for the new return shapes (`mappingFor` ->
`mappingsFor`, ten call sites in ManifestDependencyTest, three in
LearnedModIdsTest, one `restore`): the values are unchanged, only the arity.
`aContestedIdKeepsTheFirstThingThatProvedIt` is renamed `...InFront` and its
doc says leading is not owning; its assertion is untouched and still green.

clientside 501 tests, grinder 509 (29 skipped), 0 failures.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Verdict.ERROR promises "an operator's problem, never evidence about the mod"
and carries three unrelated things: the host being broken, CurseForge
withholding a download, and a loader/Minecraft combination nothing upstream
ever published for. Only the first is an operator's problem, and mixing the
other two into the one bucket somebody is expected to read and fix is what
makes the bucket unreadable.

Measured over the public grinder's 53 ERROR rows, 2026-09-09: 15 are the mod's
own file being distribution-locked (corail-tombstone, entityculling,
not-enough-animations, skin-layers-3d, structory), 2 are a required dependency
being locked (better-combat-by-daedelus), ~14 are an upstream gap, and 4 are a
jar carrying only another loader's descriptor.

These guards assert what a prevented grind is NOT, because that is the whole
claim expressible before the verdicts that replace it exist -- an added enum
constant cannot go red, only fail to compile. It also stays the claim worth
keeping afterwards: whatever the vocabulary grows into, a CurseForge opt-out
must never be filed as ServerPackCreator's failure.

Red: the three "not our failure" guards all return ERROR.
`theHostsOwnTroubleIsStillAnError` passes already and is the counterweight --
generation failures and a broken loader cache have to stay visible.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Verdict.ERROR promises "an operator's problem, never evidence about the mod".
Over the public grinder's 53 ERROR rows on 2026-09-09, 17 were a CurseForge
allowModDistribution=false opt-out -- the mod's own file (corail-tombstone,
entityculling, not-enough-animations, skin-layers-3d, structory) or a required
dependency's (better-combat-by-daedelus) -- and ~18 were a loader/Minecraft
combination nothing upstream ever published for. The bucket an operator reads
to find work was mostly things nobody can fix.

LOCKED and UNVERIFIABLE now carry those. They are separate from each other
because a distribution opt-out is a named fact with a project, a file and an
author's decision behind it -- worth filtering for, and reversible by that
author -- where "nothing published for this combination" is an absence. Both
keep no logs: no container ran.

StagingOutcome.Prevented and Prepared.Failed now carry a typed
PreventionCause (HOST / DISTRIBUTION_LOCKED / UPSTREAM_UNAVAILABLE) instead of
only a sentence, BootOutcome.prevention replaces the boolean flag with
stagingPrevented derived from it, and VerdictPolicy.decide is the only place
that maps cause to verdict. Every default is HOST, so a refusal site that
forgets to say stays in the loud, actionable bucket.

UnmetReason owns its own cause, so a reason added later cannot reach a refusal
without somebody deciding whose problem it is. preventionCauseFor folds a set
to the most actionable present -- HOST beats a permanent fact because it is the
only one anybody can retry, and a named opt-out beats an absence.
DROPPED_BY_BACKTRACK is deliberately HOST: staging dropped those builds itself.

Verdict.grindRan exists because propagateClientOnlyProof asked
`== Verdict.ERROR` and would have silently missed both new verdicts; asking
"did anything run?" as a list of verdict names is how such a list loses one.

Grinder rank: CONFIRMED, INCONCLUSIVE, ERROR, LOCKED, UNVERIFIABLE, CLEAR, with
everyVerdictHasARank failing the build if a verdict is added without one -- an
unranked verdict sorts to 99, behind everything, silently. /as-properties still
gates on CONFIRMED alone. The plugin keeps the verdict as a string, so both
names render without a plugin release.

PreventedGrindBlameTest was rewritten to drive the real prepareBootPack and the
real refuseForMissingDependencies: the cause is now a field rather than
something inferable from a detail string, so a fixture passing it in would
assert only that a `when` branches on its argument. Its expectations are
unchanged. Mutation-verified -- forcing either cause site to HOST fails exactly
the three "not our failure" guards and leaves the counterweight green.
VerdictAggregationTest's fixture helper maps its boolean onto HOST; its
arguments and assertions are untouched.

clientside 515, grinder 512 (29 skipped), plugin-grinder 73, api 412 (1
skipped), app 149 -- all green, all re-derived from build/test-results. The app
suite needs a local MongoDB on 27017; without one its Spring context tests time
out and take the Gradle worker with them, which is unrelated to this change and
was confirmed by running it against mongo:8.0.5 in Docker.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Module notes for this batch, with the measurements they rest on:

- clientside: a new entry per fix -- the provided-ids dedupe, the learned
  multi-ref map, the patch-version fallback -- plus the prevention-cause /
  verdict split, each with its landmine. Three are worth reading before
  touching that code: every PreventionCause default is HOST on purpose, the
  patch fallback must not widen the loader rule (`compatibleAt`), and the
  blame guard cannot be a fixture that passes the cause in.
- grinder: the rank table and the log-retention rule now name six verdicts,
  and say that everyVerdictHasARank fails the build if a seventh arrives
  without one.
- grinder README: the "Interpreting confidence" section described HIGH/MEDIUM,
  a vocabulary retired on 2026-09-04, and the column list still said
  Confidence. Both replaced by a table of the six verdicts and what to do with
  each, with a pointer to VerdictField as the authority rather than the prose.
- root: status table counts re-derived (clientside 475 -> 515, grinder 509 ->
  512), and the two lessons that generalise past this module -- a category
  named after a consequence accumulates everything with that consequence, and
  a published report can be enough to locate the bug without host access.
- REFACTOR-LOG: the blow-by-blow, including what all 53 ERROR rows actually
  were and why three of the four red commits pin behaviour rather than the new
  signatures (an added enum constant cannot go red, only fail to compile).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`preferenceLadder` passed `whole + narrow` to `patchNeighboursOf`, and `narrow`
is a subset of `whole` in both arms -- the satisfying files intersected with the
obtainable ones, against the obtainable ones. The concatenation could only ever
repeat versions the function already de-duplicates, while reading as though the
narrowed set contributed something of its own.

Behaviour-preserving: same neighbours, same order. The nine patch-fallback
guards and the twenty-two in BootCandidateSelectorTest stay green with no
assertion touched.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
475 pre-existing clientside guards, zero failures against this branch's
production code, with exactly three files uncompilable -- each one of the
signature changes already enumerated, adapted by argument only with every
assertion byte-identical. Also notes that the app suite's green needs a local
MongoDB, so the next reader does not chase a Gradle worker dying on socket
timeouts.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
LOCKED and UNVERIFIABLE split out of Verdict.ERROR, plus the three ways a
dependency read as unavailable while being obtainable.

Read from the public grinder's 53 published ERROR rows on 2026-09-09: 17 were a
CurseForge allowModDistribution=false opt-out, ~18 a loader/Minecraft
combination nothing upstream ever published for, and 17 were resolution
defects. ERROR now means what its own doc promised -- an operator's problem --
because everything else has somewhere honest to go.

- LOCKED / UNVERIFIABLE, carried by a typed PreventionCause on
  StagingOutcome.Prevented and Prepared.Failed, with UnmetReason owning its own
  cause so a reason added later cannot reach a refusal unblamed.
- A dependency already in the pack can no longer refuse its own boot.
- LearnedModIds keeps every project that proves a mod id, not only the first.
- A dependency is staged from a neighbouring patch release of the same version
  line, nearest first, never across a line.

clientside 475 -> 515, grinder 509 -> 512, plugin-grinder 73, api 412, app 149.
Equivalence-checked against develop's unmodified test tree: 475 pre-existing
guards, zero failures.
Audit finding M-1 (claude-docs/ANALYSIS-AUDIT.md, 2026-09-09).

`refuseForSelfDeclaration` asks whether a jar's own descriptor accepts the
Minecraft being booted -- of the candidate only. Nothing asks it of the
dependencies: `DependencyBacktrack.conflicts` matches mod-id -> version
requirements and never reads `ScannedMod.minecraftConstraint`, although
`dependencyToDemote` already holds a `ScannedMod` for every staged jar.

That dimension used to be protected by the exact-Minecraft rule in
`pickDependencyFile` -- a dependency was never staged for another version, so
its descriptor could not disagree about one. The patch-version fallback
deliberately relaxed that and left the dimension ungated: a cobblemon Fabric
1.21.1 build now stages into a 1.21.11 pack, the loader refuses the pack, and
the candidate wears an INCONCLUSIVE that overwrites a decisive verdict. No
false CONFIRMED is reachable (a wrong-Minecraft library produces none of the
four decisive rungs), so the cost is a wasted container plus a downgraded
verdict.

The same gate closes the identical exposure in the cross-loader and untagged
fallbacks, both of which predate the patch fallback.

Red: `aDependencyWhoseDescriptorExcludesThePacksMinecraftIsDemoted` stages the
2.0.0 build that declares '~1.16.5' into a 26.2 pack. The other four pass and
are what keeps the gate narrow -- the fixture's range is asserted to really
exclude the release, silence and an unreadable range are both left alone, and
the candidate's own range never demotes a dependency.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Closes audit finding M-1. `dependencyToDemote` now demotes a staged dependency
whose own `minecraftConstraint` positively excludes the version being booted,
before the version-conflict pass and before any container is spent.

It is the dependency half of what `refuseForSelfDeclaration` does for the
candidate, and the gate the patch-version fallback needs: relaxing the
exact-Minecraft rule removed the only protection in that dimension. It also
closes the same exposure in the cross-loader and untagged fallbacks, which
predate it.

Everything uncertain accepts, which is what keeps it from becoming a
mass-demotion: an unreadable descriptor is already filtered by `descriptorRead`,
a jar declaring no range yields null, and VersionConstraint accepts any range it
cannot parse. It fires only on a positive, readable contradiction. The candidate
is excluded outright -- demoting it would verify a different mod, and dropping a
dependency over a range the candidate declared would blame the wrong jar.

Asked before the version conflicts deliberately: a jar naming another Minecraft
is one the loader refuses outright, where a version range is one mod's opinion
about another.

`UnmetReason.DROPPED_BY_BACKTRACK` now reads "every usable build was dropped
making the pack coherent" instead of naming a version conflict, because two
things reach it and the old sentence would be false for the new one. That is one
existing expectation edited in `UnmetDependencyReasonTest` -- a deliberate
behaviour change to a published refusal string, which is why this commit is
`fix:` and not `refactor:`.

Known residue, recorded rather than fixed: a project whose *every* build
declares the wrong Minecraft now ends as ERROR/DROPPED_BY_BACKTRACK rather than
UNVERIFIABLE, because the cause cannot tell "we dropped it" from "we dropped it
because upstream's builds do not fit" without a second exclusion channel.
Strictly better than the wasted boot it replaces.

clientside 520 tests, 0 failures.

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>
Four guards over code that is already correct. Green on arrival, and each
mutation-verified rather than assumed.

M-3 `onlyConfirmedIsEverPublished` — the publication gate was pinned by four
hand-written rows plus twenty ERRORs, so LOCKED and UNVERIFIABLE joined the
population it exists to protect against without appearing in one assertion.
Now driven over `Verdict.entries`, so a seventh verdict is covered with no edit.

M-4 `everyVerdictIsClassifiedForRetention` replaces
`everyVerdictButClearKeepsItsLogs`, whose assertions stayed green while its
*name* became false — LOCKED and UNVERIFIABLE discard too. Asserted as the
partition of `Verdict.entries`, so an unclassified verdict fails the build, with
the per-verdict reason in the doc. Worth recording: the obvious-looking rule,
partitioning on `grindRan`, is **wrong** — ERROR keeps its logs despite nothing
having run, because an admin has to diagnose the host. The first draft of this
guard asserted that invented rule and went red against correct code.
`VerdictColumnTest`'s half of the drift guard is derived the same way.

M-5 `concurrentLearnersKeepEveryRefExactlyOnce` — `LearnedModIds`' class doc
ends "thread-safe: the grinder shares one instance across its grind workers",
and nothing in either module started a second thread. The value behind an id
was an immutable String under `putIfAbsent` until this batch; it is now a
CopyOnWriteArrayList mutated after a `computeIfAbsent`. Sixteen writers off one
latch, each proving a distinct ref for the same id, twice each. Mutation:
`addIfAbsent` -> `add` fails it.

L-1 `anExactVersionTheConstraintRejectsBeatsANeighbourItAccepts` — the middle
rung of `preferenceLadder`'s three-way ordering was claimed by the KDoc and
asserted nowhere. Mutation: hoisting the constraint tier outside the version
loop fails it.

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

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Audit finding L-2. `LearnedModIds.restore` claims the map entry with
`computeIfAbsent` before filtering the refs, so an id carrying a JSON null, a
number or an empty array leaves an empty list behind -- which `snapshot()` then
writes back as `"id": []`. The document accumulates entries that assert nothing
and grow on every restart, and it is harmless to read, which is exactly why
nothing would have noticed.

The fixture is a mixed-shape document -- the legacy bare-string form beside the
current list form -- because that is what a daemon mid-upgrade really holds, and
it covers the reader for both shapes at the same time.

Red: three junk ids survive the round trip as empty entries.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Closes audit finding L-2. `restore` filters the refs before claiming the map
entry, so an id whose stored value contributes nothing leaves nothing behind
and `snapshot()` no longer writes it back as an empty array.

`learn` never had this: its ref is non-blank-guarded at the top.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Closes audit finding L-3. The dedupe mapped the learned aliases a second time
inside its own filter and nested two `it`-shadowing lambdas; comparing against
the ref list directly says the same thing once.

Behaviour-preserving: same list, same order. `theRegistryDoesNotRepeatALearnedRef`
and the rest of the suite stay green with no assertion touched.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A-2 — three new `!!` in `PreventedGrindBlameTest` on
`refuseForMissingDependencies`' nullable return. The conventions forbid new `!!`
in refactored code and do not exempt tests; `requireNotNull` says the same thing
and fails with a sentence rather than a NullPointerException.

A-3 — `var refusal` in `planManifestDependency` now carries its reason. It is a
genuine accumulator: the loop stops at the first mapping that stages, so a
second project is never resolved for nothing, while remembering the first
alias's reason in case none does. Without the note the next reader sees only a
`var` where the conventions ask for a `val`.

A-5 — `[BootObservation]` in `Verdict.kt` linked a type that exists nowhere in
the repository, twice, so dokka resolved it to nothing. Pre-existing, but that
file was rewritten substantially in this batch, so the Boy-Scout rule reaches
it. Now `[BootResult]`, which is the type actually meant, and the sentence names
all three prevented verdicts rather than only ERROR.

Behaviour-preserving throughout: no assertion, argument or expected value
touched. clientside 522, 0 failures.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
- clientside: a new entry for `outsideThePacksMinecraft` — why the patch-version
  fallback needed it (the exact-Minecraft rule *was* the protection in that
  dimension), what keeps it from becoming a mass-demotion, why the candidate is
  excluded, why it is asked before the version conflicts, and the known residue
  where a project's every build declares the wrong Minecraft.
- clientside: the testing section's two stale figures are gone. It said "257
  tests" against 523 and "Four need a resource" against twelve, having already
  been wrong once the same way — both are now stated as the commands that answer
  them, with that history recorded so the next writer does not restate a number.
- root: clientside 515 -> 523, grinder 512 -> 514.
- ANALYSIS-AUDIT / REFACTOR-AUDIT: a resolution section each, every finding
  closed or explicitly declined with its reason.

Finding A-6 is fixed in the analysis section itself: every commit hash is
replaced by its subject, because that file accumulates and a rebase killed
thirteen hashes in its sibling on 2026-09-01. The same pass caught a `file:line`
citation the same convention forbids.

Two things the resolution records rather than hides. The M-4 guard's first
implementation asserted a rule invented for it -- retention partitioned on
`grindRan` -- and went red against correct code, because ERROR keeps its logs
despite nothing having run. And M-4 under-reported: the same hand-written-list
defect sat in `VerdictPublicationTest`, whose name this batch had made false.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
docs: the analysis and audit, in the refactor log
All checks were successful
Documentation / Writerside webhelp (push) Successful in 3m13s
Qodana / scan (push) Successful in 11m23s
Docker Test / build image (push) Successful in 17m42s
Continuous / Build JAR (push) Successful in 20m8s
Qodana / notify (push) Successful in 15s
Documentation / Help image (push) Successful in 3m57s
Continuous / Build AppImage (x86_64) (push) Successful in 3m5s
Continuous / Build AppImage (aarch64) (push) Successful in 4m9s
Test / build (push) Successful in 17m34s
Continuous / Build Install4J Media (push) Successful in 10m1s
Continuous / Continuous Pre-Release (push) Successful in 5m53s
Docker Test / build image (pull_request) Successful in 18m9s
Test / build (pull_request) Successful in 25m58s
86d3d441b7
The per-commit verification is the part worth quoting: each commit in its own
fresh worktree -- never a reused build directory, which is what reported
"No tests found" for a present class the day before -- with the whole clientside
suite run so a filter cannot silently match nothing. 10 red, 33 green across
nine commits, zero collateral failures, so `git checkout <fix>^` really does
show the missing implementation at all four test/fix pairs.

The analysis's headline finding is a consequence of the batch rather than a
pre-existing defect: relaxing the exact-Minecraft rule removed the only gate in
that dimension, and `outsideThePacksMinecraft` closes it.

Two lessons recorded for reuse. A guard can assert a rule invented for it -- the
retention drift-guard's first implementation partitioned on "did a container
run?" and went red against correct code, because ERROR keeps its logs despite
nothing having run. And a guard that enumerates the values it knows about stops
covering the vocabulary the moment it grows: `everyVerdictButClearKeepsItsLogs`
stayed green while its own name became false.

The equivalence note is corrected from three adapted files to four: the audit
fix changed `DROPPED_BY_BACKTRACK`'s sentence, which is the batch's one genuine
expectation change.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Merge pull request 'This is why...' (#673) from develop into alpha
Some checks failed
Documentation / Writerside webhelp (push) Successful in 2m59s
Documentation / Help image (push) Has been cancelled
Qodana / notify (push) Has been cancelled
Test / build (push) Has been cancelled
Qodana / scan (push) Has been cancelled
Docker Test / build image (push) Has been cancelled
Generate Release / semantic-release (push) Successful in 3m57s
02437974fd
Reviewed-on: #673
RELEASE: 9.0.0-alpha.8
Some checks failed
Generate Release / semantic-release (push) Has been skipped
Build Release / Preparations (push) Successful in 12s
Docker Test / build image (push) Successful in 17m45s
Documentation / Help image (push) Successful in 3m4s
Documentation / Writerside webhelp (push) Successful in 2m30s
Qodana / notify (push) Successful in 9s
Qodana / scan (push) Successful in 11m16s
Build Release / Docker images (push) Successful in 19m8s
Build Release / JARs, media and checksums (push) Successful in 32m22s
Test / build (push) Successful in 43m21s
Build Release / Forgejo release (push) Successful in 3m31s
Build Release / Publish Maven (push) Failing after 4m54s
Build Release / News on Discord (push) Has been skipped
Build Release / VirusTotal scan (push) Successful in 1m39s
Build Release / Mirror release outward (push) Has been skipped
Docker Test / build image (pull_request) Successful in 21m56s
Test / build (pull_request) Successful in 16m11s
a353724c7d
The gate maps a descriptor path to a loader through a flat, version-blind
constant. Two eras make that wrong, and both are measured against the live
Modrinth API on 2026-09-10 by opening the actual jars:

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

api 412 tests, 0 failures (1 skipped).

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

clientside 542/0, grinder 514/0.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

api 413/0, clientside 554/0.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

api 421/0, clientside 566/0.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

clientside 568/0, grinder 516/0.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Nothing calls this yet.

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

Two compositions needed care, and both are pinned:

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

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

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

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

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

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

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

Three parts:

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

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

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

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

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

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

Suite: 594 tests, 0 failed, 0 skipped.

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

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

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

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

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

Three behaviour changes worth stating plainly:

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

Mutations, run and observed:

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

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

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

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

Observed:

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

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

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

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

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

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

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

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

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

Mutations, run and observed:

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

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

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

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

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

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

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

Mutations, run and observed:

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

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

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

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

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

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

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

Mutations, run and observed:

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Mutation, run and observed:

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

Plugin suite: 76 tests, 0 failed.

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

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

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

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

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

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

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

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

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

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

Conservative in three places, each pinned:

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

Mutations, run and observed:

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Suites green: clientside, grinder and app.

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

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

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

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

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

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

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

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

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

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

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

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

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

Mutations, run and observed:

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

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

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

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

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

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

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

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

Mutation, run and observed:

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

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

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

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

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

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

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

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

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

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

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

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

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

Four lessons that outlive the incident:

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

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

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

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

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

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

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

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

Observed:

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

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

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

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

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

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

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

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

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

Mutation, run and observed:

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Suites green: clientside, grinder, plugin-grinder.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Measured: 646 existing clientside guards green, unchanged.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Measured: clientside 654 green, grinder 532 green.

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

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

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

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

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

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

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

Measured: clientside 658 green.

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

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

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

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

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

Measured: clientside 659 green.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Measured: clientside 661 green.

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

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

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

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

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

Measured: clientside 661 green, grinder 532 green.

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

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

Measured: 661 existing clientside guards green, unchanged.

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

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

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

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

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

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

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

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

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

Measured: clientside 666 green, grinder 532 green.

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

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

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

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

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

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

Measured: clientside 666 green, grinder 532 green.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Merge pull request '...we can't have...' (#674) from develop into alpha
Some checks failed
Documentation / Writerside webhelp (push) Successful in 2m38s
Docker Test / build image (push) Has been cancelled
Documentation / Help image (push) Has been cancelled
Qodana / notify (push) Has been cancelled
Test / build (push) Has been cancelled
Qodana / scan (push) Has been cancelled
Docker Test / build image (pull_request) Has been cancelled
Test / build (pull_request) Has been cancelled
Generate Release / semantic-release (push) Successful in 2m24s
3d3c0f68e4
Reviewed-on: #674
RELEASE: 9.0.0-alpha.9
Some checks failed
Generate Release / semantic-release (push) Has been skipped
Build Release / Preparations (push) Successful in 22s
Docker Test / build image (push) Successful in 20m53s
Docker Test / build image (pull_request) Successful in 14m20s
Documentation / Help image (push) Successful in 2m2s
Documentation / Writerside webhelp (push) Successful in 2m10s
Test / build (push) Successful in 35m36s
Build Release / Docker images (push) Successful in 16m38s
Qodana / notify (push) Successful in 15s
Qodana / scan (push) Successful in 21m49s
Build Release / JARs, media and checksums (push) Successful in 23m34s
Build Release / Forgejo release (push) Successful in 2m40s
Build Release / VirusTotal scan (push) Successful in 1m38s
Build Release / Publish Maven (push) Successful in 6m9s
Build Release / News on Discord (push) Successful in 41s
Build Release / Mirror release outward (push) Failing after 5m56s
Test / build (pull_request) Successful in 48m11s
c68fa28988
Sign in to join this conversation.
No reviewers
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set

Reference
Griefed/ServerPackCreator!675
No description provided.