HUUUUGE HONGALABONGAMAHOOOOGS #670

Merged
Griefed merged 228 commits from develop into alpha 2026-08-22 16:31:38 +02:00
Owner
No description provided.
Griefed self-assigned this 2026-08-22 16:31:12 +02:00
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>
Griefed merged commit 39691ae7ca into alpha 2026-08-22 16:31:38 +02:00
Sign in to join this conversation.
No reviewers
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set

Reference
Griefed/ServerPackCreator!670
No description provided.