Tequila! #676

Merged
Griefed merged 93 commits from develop into beta 2026-09-22 20:44:24 +02:00
Owner
No description provided.
Three guards, all red against this commit's production code, each for the
defect it names rather than a fixture fault:

  configModeCapturesTheConfigFileItWasGiven
      -config <nonexistent> discards the path (isPresent false), which is
      what leaves ServerPackCreator.run() calling get() on an empty Optional.
  pathArgumentsWithoutAValueAreReportedNotThrown
      IndexOutOfBoundsException: Index: 1, Size: 1 at CommandlineParser.kt:218
  homeArgumentWithoutAValueIsReportedNotThrown
      IndexOutOfBoundsException: Index: 1, Size: 1 at CommandlineParser.kt:108

Run before committing, per the convention that a guard committed red is only
evidence when the red is the missing implementation: 16 tests, 3 failed, and
the three stacks above are the parser indexing past the end of its own
argument list.

The existing assertion that a non-existent -config path leaves serverPackConfig
empty is inverted here rather than kept. That is a deliberate contract change,
not a refactor, which is why the fix commit that follows is labelled fix: --
parsing should record what the user typed so the run can name it, and deciding
whether the path is usable belongs to the command that runs it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
-config with a path that is not a file left serverPackConfig empty, and the
CONFIG dispatch then called get() on it -- a bare NoSuchElementException, with
the path the user mistyped already discarded so nothing could name it. -config
with no path at all never got that far: reading argsList[indexOf(flag) + 1]
unguarded threw IndexOutOfBoundsException while still parsing.

Both halves are one defect -- the parser deciding whether a path is usable and
throwing away the evidence either way -- so the whole class is fixed rather than
the one spelling that was reported. pathAfter() records the path as given and
returns null only when the flag is absent or is the last word on the
commandline; -cgen, -feelinglucky, --setup, --destination and --home all read
through it, because all five indexed past the end of the argument list in
exactly the same way.

Validation moves to whoever runs the thing, which is where the path can be
named: runHeadless already warns "<path> not found...", feelingLucky already
logs a modpack-directory that does not exist, and --setup/--home now say which
path they rejected. Only "no path given at all" needs a guard at the dispatch,
because there is nothing to report.

Verified against the real application, not just the suite -- :serverpackcreator-app:bootRun:

  -config /definitely/not/here.conf
      WARN  (RunHeadlessCommand.kt:131) - /definitely/not/here.conf not found...
  -config
      ERROR (ServerPackCreator.kt:162) - -config requires the path to a server
      pack config, e.g. -config "/path/to/serverpackcreator.conf".

Both previously aborted with a stack trace. Suite: 151 tests, 0 failures.

Residual risk stated rather than assumed: the two dispatch guards in
ServerPackCreator.run() are not unit-tested, because run() boots the API and
there is no seam to drive it from a test. They are what the two bootRun
transcripts above exercise. The parser half is pinned by CommandlineParserTest.

Note for anyone scripting this: a mistyped path used to exit non-zero by virtue
of crashing, and now exits 0 like every other headless failure. That inconsistency
is pre-existing and deliberately not changed here -- the headless verbs signal
nothing through their exit code, and giving one of them an exit code is a
separate decision.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The KDoc on RunHeadlessCommand.run() claimed it generates from every
configuration in the config directory. It calls runHeadless() with no argument,
whose default is apiProperties.defaultConfig -- <home>/serverpackcreator.conf.
Generating from every configuration is what the withAllInConfigDir subcommand
does, which is presumably where the sentence came from.

Noticed while documenting the CLI for the README, where the shell's verbs had to
be described accurately.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
All four prompting commands wrap System.in in a Scanner and close it. A Scanner
closes its underlying source, System.in cannot be reopened, and JLine's POSIX
terminal holds that same descriptor as its pty slave -- OsXNativePty.current()
and LinuxNativePty.current() both pass fd 0 / FileDescriptor.in, and getSize()
does ioctl(slave, TIOCGWINSZ). So the prompt kills the shell that called it.

Run before committing. All four fail on the assertion itself, not on their
fixtures:

  cgenPromptLeavesSystemInOpen                   expected: <false> but was: <true>
  homeDirPromptLeavesSystemInOpen                expected: <false> but was: <true>
  langPromptLeavesSystemInOpen                   expected: <false> but was: <true>
  runWithSpecificConfigPromptLeavesSystemInOpen  expected: <false> but was: <true>

cgen's case asserts the returned directory *before* it asserts the stream, and
that first assertion passes -- so the fixture really does feed the prompt and
the red is the close alone.

Two fixture notes worth keeping. A ByteArrayInputStream cannot pin this: its
close() is a no-op, so the defect is invisible through one, which is why the
input is wrapped in a stream that records the call. And the assertion is on the
descriptor rather than on a terminal, because the damage is done when fd 0
closes; what JLine then fails to do needs a real tty to observe and is not
something a suite can watch.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A prompt killed the shell that called it. Scanner.close() closes its underlying
source, System.in cannot be reopened, and JLine's POSIX terminal holds that same
descriptor as its pty slave -- OsXNativePty.current() and LinuxNativePty.current()
both pass fd 0 / FileDescriptor.in, and getSize() calls ioctl(slave, TIOCGWINSZ).
So the next readLine failed with "Error calling ioctl(TIOCGWINSZ): return code is
-1", wrapped in a java.io.IOError. That is an Error, not an Exception, so the
shell's per-line catch (e: Exception) missed it and the outer catch (t: Throwable)
logged "Error initializing terminal" and returned -- ending the session. Reported
against 8.1.2; git diff 8.1.2..HEAD on these files is a copyright year and three
doc comments, so it was never fixed, only re-documented.

All four prompts did it, so all four are fixed: cgen, run withSpecificConfig
(when -c is omitted), lang and homeDir. Each keeps a LANDMINE comment at the
Scanner, because the close looks like tidy resource handling and the previous
pass through this code added a try/catch around it rather than removing it.

The deleted catch guarded the wrong thing: it caught close() *throwing*, while
the damage is close() *succeeding*.

Verified in a real pty, which is the only place this is observable -- `script`
against the built jar, input paced so JLine processes a line at a time:

  cgen      -> prompted, accepted the path, wrote configs/modpack-fixture
  printHelp -> "How to use ServerPackCreator:"   <- the command that used to be unreachable
  homeDir   -> prompted again, accepted, stored
  no TIOCGWINSZ anywhere; the process was still waiting for input when the
  harness timed out, which is the shell behaving correctly

Suite: 155 tests, 0 failures.

Separately noticed, not fixed here: on EOF the terminal's own close NPEs inside
JLine (Status.close, "Cannot read field rows because this.display is null") on
the way out of the use-block. It happens after the session has already ended, so
nothing a user does is affected, and it is JLine's bug rather than ours.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The shared prompt loop the four commands are about to be moved onto, pinned
before it exists.

The seam lands in this commit with TODO() bodies rather than in one of its own,
because a guard that cannot compile is not a red pin -- the convention's own
wording. The bodies are gone in the very next commit, so nothing unimplemented
outlives the boundary. Run before committing; all five reds are literally the
missing implementation:

  readExistingDirectoryReAsksUntilTheAnswerIsADirectory  NotImplementedError: ConsolePrompt.readExistingDirectory
  readExistingFileReAsksUntilTheAnswerIsAFile            NotImplementedError: ConsolePrompt.readExistingFile
  readChoiceAcceptsExactlyWhatItDisplayed                NotImplementedError: ConsolePrompt.readChoice
  readChoiceReAsksOnAnAnswerItNeverOffered               NotImplementedError: ConsolePrompt.readChoice
  theStreamIsNeverClosed                                 NotImplementedError: ConsolePrompt.readExistingDirectory

readChoiceAcceptsExactlyWhatItDisplayed is the guard that matters most. The lang
command currently prints en_GB / pt_BR / zn_GB and accepts only en / pt / zn --
probed against the shipped translations -- so it refuses every value it offers.
Making display and matching read one map means that state cannot be expressed,
rather than being fixed once and free to drift back.

theStreamIsNeverClosed wraps its input in a stream that records close(), because
ByteArrayInputStream.close() is a no-op: pinning this against a bare one would
assert nothing.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Turns the five guards from the previous commit green. readUntil is the only
loop; readExistingDirectory, readExistingFile and readChoice are thin over it,
so there is one place that re-asks and one place that decides what to say about
a refused answer.

Nothing calls it yet -- the four commands move onto it one at a time in the
commits that follow.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Moves cgen's prompt onto ConsolePrompt. Labelled fix rather than refactor
because one user-visible string changes: the loop validated isDirectory but
complained "File '<path>' does not exist.", and the shared prompt says
"Directory '<path>' does not exist." -- which is what HomeDirCommand's copy of
the same loop already said. That divergence is the smallest of the three the
duplication produced, and it goes away by construction rather than by being
corrected in place.

Behaviour otherwise identical: same question, same "Path: ", same re-ask until
the answer names a directory, same File returned. cgenPromptLeavesSystemInOpen
still passes unchanged.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Pure move. Same question, same Windows escaping hint in the same position (it is
appended to the question so it still lands between the question and the first
"Path: "), same "Directory '<path>' does not exist.", same restart notice.

The file's @Suppress("DuplicatedCode") goes with it -- there is no longer a
duplicate to suppress.

One thing a reviewer should check rather than take on trust: the stored value is
now the File's path instead of the raw typed line, which differs only by a
redundant trailing separator. It is not observable, because the preference is
only ever read back through File(...) (PathsConfig.homeDirectory, line 127), and
File("/a/b/") and File("/a/b") resolve to the same absoluteFile. No assertion
changed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`run withSpecificConfig` with no -c prompted "Enter the full path to the new
ServerPackCreator home-directory.", then validated isFile and returned it as the
server pack config. A user who answered the question as asked -- with a
directory -- was told "File '<dir>' does not exist.", which is both wrong and
unhelpful about what it actually wanted.

It is a copy of HomeDirCommand's loop with the validation swapped and the
question left behind. Both files carried @Suppress("DuplicatedCode"), so the
annotation was sitting on the copy that held the bug. The question now says
"Enter the full path to the server pack config." and the loop is
ConsolePrompt.readExistingFile.

This is the defect that justifies the extraction: it is not a bug anyone would
write on purpose, it is what duplication does over time, and it survived because
the two copies were marked as known duplicates rather than merged.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Written against the current constructor, so the guard compiles and the red is
the defect rather than a missing seam.

  langAcceptsAndStoresTheLocaleItDisplayed
      "lang must accept the locale it just displayed" expected: <false> but was: <true>
      -- "Unsupported locale en_GB." really is printed for the value the command
      itself just listed.
  langRefusesALocaleItNeverOffered
      "must go on to accept a listed locale" expected: <pt_BR> but was: <null>
      -- pt_BR is offered and refused too, so nothing is ever stored.

Two defects, one contract, so one pin. printAvailableLanguages prints the locale
(en_GB) while the loop compares locale.language (en), so every offered value is
refused; and having validated scanner.next(), the command passes
scanner.nextLine() -- the rest of that line, which is "" -- to changeLocale, so
even an accepted answer stores the wrong locale. The second is invisible until
the first is fixed, because nothing gets that far.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Turns the two guards from the previous commit green.

lang listed en_GB / pt_BR / zn_GB and matched against locale.language -- en / pt
/ zn -- so it refused every value it had just offered. There was no way to use
the command as displayed. And once past that, it validated scanner.next() but
handed scanner.nextLine() to changeLocale: the remainder of the same line, which
is "", so an accepted answer stored Locale("") rather than the locale chosen.

Both go away together, because readChoice is given one map: its keys are what
gets listed and what gets matched, and its values are what gets returned. A
locale that is offered can be chosen, by construction rather than by agreement
between two pieces of code. printAvailableLanguages is gone -- listing the
choices is part of asking for one.

One existing fixture changed, which is the signal this is a behaviour change and
not a refactor: InteractivePromptStdinTest.langPromptLeavesSystemInOpen fed "en"
and now feeds "en_GB". It failed with NoSuchElementException: No line found --
"en" is no longer accepted, so the command kept asking until the fed input ran
out. That test asserts stdin handling, not locale handling, so only the answer
changed; its assertion is untouched.

Suite: 162 tests, 0 failures.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
InteractivePromptStdinTest drove all four commands to assert none of them closed
System.in. There is now one Scanner in the module, and
ConsolePromptTest.theStreamIsNeverClosed pins it, so three of those four cases
were asserting the same object through three different sets of mocks -- and the
homeDir case wrote to the machine's real Preferences to do it.

What deleting it would have lost is the part no behavioural test can cover: a
*new* command written with its own Scanner(System.in). A test can only run
commands that exist, so that is pinned as a source check instead --
noCommandReachesSystemInOnItsOwn, which matches Scanner(System. across the main
source and expects nothing. Precedent for reading source from a test in this
module is ClientsideReadmeFlagsTest, and the path convention is taken from it.

Teeth verified rather than assumed: adding `java.util.Scanner(System.in)` to
HomeDirCommand.changeHomeDirectory turns it red, naming the file. Reverted.

Matching `Scanner(System.` and not `Scanner` is deliberate -- this module has
ModScanner, LarsonScanner and a `scanner` local in unrelated places, and a bare
match flags ten files. ConsolePrompt takes its stream as a constructor parameter,
so it does not match its own guard.

Suite: 159 tests, 0 failures. Down from 162 because four cases collapse into one
plus the source guard.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Three entries in the module's landmine section: never build a Scanner over
System.in and why (the JLine fd-0 chain, with the classes and call that make it
true), what the four duplicated prompt loops had drifted into, and the rule that
a path argument records what it was given so the run can name it.

Per the definition of done -- the module CLAUDE.md gets updated when an
architectural step lands, and a shared prompt plus a rule about stdin ownership
is one.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
runHeadless, withAllInConfigDir, feelingLucky and generateConfFromModpack
computed an outcome, printed it and threw it away. They now return it.

Behaviour-preserving: every println and every log call is unchanged, in the same
order, and nothing reads the returned value yet -- ServerPackCreator.run still
ignores it. This is the seam the exit-code change needs, landed on its own
because a guard asserting on a Unit cannot compile, and a guard that cannot
compile is not a red pin.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Eight guards on the seam from the previous commit. Seven are green on arrival
and one is red, so the state of each is worth being explicit about rather than
claiming a clean red pin.

RED -- withAllInConfigDirIsAFailureWhenTheDirectoryCannotBeListed
  java.lang.NullPointerException. File.listFiles() returns null for a directory
  that is not there, and the loop iterates it unguarded. A real defect, fixed in
  the next commit.

GREEN on arrival -- the other seven. They pin the outcomes the previous commit
started returning, so there is no defect for them to fail against. Their teeth
are in the fact that each forces a different branch through a mocked check and
generation: flipping checkPasses or generationSucceeds moves the expected value,
which is what the four runHeadless cases assert pairwise.

One fixture note worth keeping: checkConfiguration carries two further defaulted
parameters, and Kotlin compiles defaults in at the call site, so a two-argument
mockk matcher never matches and every case died with "no answer found". The stub
names all four.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
-withallinconfigdir threw a NullPointerException when its configs-directory was
missing or unreadable: File.listFiles() answers null for anything that is not a
readable directory, and the loop iterated that.

An empty directory is kept distinct from an unreadable one on purpose. Nothing
to generate is not a failure -- a cron job pointed at a directory a user has not
filled in yet should not start alerting -- but not being able to look is.

Turns withAllInConfigDirIsAFailureWhenTheDirectoryCannotBeListed green; the
other seven guards stay green.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The headless verbs signalled nothing through their exit code. A failed config
check, a failed generation and a path that does not exist all exited 0, so
`spc -config pack.conf && deploy` deployed whatever happened. run() now returns
EXIT_SUCCESS or EXIT_FAILURE and main exits with it.

LANDMINE, and the reason main does not simply exitProcess(run(...)): GUI and WEB
return from run() as soon as they have handed off -- the GUI to the Swing event
dispatch thread, the webservice to the embedded server -- and both keep the JVM
alive on their own non-daemon threads. Exiting on success would kill the window
or the server the instant it finished starting. So only a failure exits
explicitly; success falls off the end of main and lets the JVM end when nothing
is left running, which is exactly what every mode did before.

Verified against the built jar rather than reasoned about:

  -config /definitely/not/here.conf   exit=1
  -config                             exit=1
  -cgen   /definitely/not/a/modpack   exit=1
  -cgen   <a real modpack>            exit=0
  -scan                               exit=1
  -clientsidereport                   exit=1
  -help                               exit=0
  -gui                                still running after 25s (timeout killed it, 124)

That last line is the landmine check. The -scan and -clientsidereport rows are
also four crashes closed: SCAN, CLIENTSIDE_REPORT, VERIFY_CLIENTSIDE and
CLIENTSIDE_APPLY still called get() on an empty Optional and died with
NoSuchElementException. I claimed the whole class was fixed when -config was
done; it was not -- those four were missed, and are fixed here with the same
"say which argument is missing" guard.

Two warnings removed on the way through, both in the rewritten block: a
redundant elvis against a null right operand, and the inner when's else, which
the compiler now reports as unreachable. Losing that else is a small gain -- a
Mode added to the outer branch list is now a compile error until it is handled.

Suite: 167 tests, 0 failures.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The previous entry said the headless verbs signal nothing through their exit
code, which stopped being true one commit later. Replaced with what they now do,
the reason main must never exitProcess(0), and the jar-level evidence for both.

Also records that the .get() crash class was closed in two passes rather than
one, because the four clientside/scan branches were missed when -config was
fixed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Every request re-sorted the whole verdict store and re-gathered every filter
column across it, then paged. Pagination bounds only what is *rendered*, so the
cost grew with the store while the page stayed 250 rows. Measured on synthetic
stores (select / render the page):

  10,000 verdicts     63 ms /  8 ms
  38,258 verdicts    251 ms /  3 ms     <- roughly the deployed store
 100,000 verdicts    434 ms /  1 ms

At the deployed size 98% of the work was discarded. Both derivations are
properties of the store, not of the query -- the filter choices are gathered
across every verdict regardless of what is filtered, and the default order is
query-independent -- so VerdictSnapshot holds them lazily and
VerdictSnapshotCache rebuilds only when VerdictStore.version moves.

The counter is a counter and not a row count because record() replaces by
identity: a re-ground project changes what the report must show while leaving
the size identical.

select(list, ...) now delegates to select(snapshot, ...), so `/`, `/export.csv`
and `/verdicts.json` still run one implementation and cannot drift apart.

A note on the guard, because the first version of it asserted nothing: it
originally compared the two select overloads against each other, which -- now
that one delegates to the other -- runs the same code twice. Forcing
`selectsEverything` to true disabled filtering completely and the comparison
stayed green. It is rewritten against expectations derived in the test, and both
mechanisms were then mutation-checked: that mutation fails it, and making the
cache always rebuild fails theSnapshotIsReusedWhileTheStoreIsUnchanged.

Two fixture errors found the same way, both mine and neither a production bug:
filter params are prefixed (`f.loader`, not `loader`) and the slug column's sort
param is `name`, not `slug`. A wrong query string parses to no filter and no
sort, which looks exactly like a broken selection.

Suite: 537 tests, 0 failures, 29 skipped.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Two halves of the same failure. ReportServer now selects through
VerdictSnapshotCache rather than store.all(), and its thread count is
SPC_GRINDER_HTTP_THREADS, default 4 where it was a hardcoded 2.

Why the pool matters as much as the cost: the JDK's HTTP server hands every
request to this pool, so a request that blocks costs a whole thread and a pool
with nothing free stops answering *everything* -- including /status and
/dashboard, which do no store work. From outside that is indistinguishable from
a dead host. The socket still accepts, nothing ever replies, and the proxy
returns a 502; measured against the public instance at 131.3 s, the same for /,
/status, /dashboard and a path that does not exist, while port 80 answered a
redirect in 0.18 s.

Raising the pool alone would only have bought a bigger window, which is why the
derivation cost went first.

Documented in three places because a knob missing from any of them is invisible
to a guard: KNOBS (else neither documentation test sees it), the README table
plus a new "Report responsiveness" section carrying the measurements, and the
systemd unit. The reader call is deliberately on one line --
everyVariableReadIsDeclaredAsAKnob matches `reader("NAME"` and a wrapped call is
invisible to it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
drop(n) copies everything past the offset, so page one of a 38k-row store
allocated a 38k-element list in order to hand back 250 of them, on every
request. subList takes a view and only the page is materialised.

Small next to the derivation cache -- measured 6.9 ms to 5.4 ms per cached
request at 38,258 rows -- but it is the same mistake one level down: work
proportional to the store to produce a page that is not.

Paging semantics are unchanged; the clamps match drop/take's behaviour past the
end, and theSnapshotPathStillFiltersOrdersAndPages covers page size, the short
last page and non-overlap.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Two landmines: the thread pool's all-or-nothing failure mode, with the live
measurements that distinguished it from a dead host, and the per-request
re-derivation that filled the pool in the first place.

Also records that a guard comparing the two select overloads asserts nothing now
that one delegates to the other, since that is the trap the first version of the
test fell into.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
merge: a bad path names itself, a prompt stops killing the shell, and the report answers again
All checks were successful
Documentation / Writerside webhelp (push) Successful in 6m36s
Continuous / Build JAR (push) Successful in 12m0s
Docker Test / build image (push) Successful in 24m59s
Documentation / Help image (push) Successful in 3m10s
Qodana / scan (push) Successful in 23m27s
Continuous / Build AppImage (x86_64) (push) Successful in 2m22s
Continuous / Build AppImage (aarch64) (push) Successful in 4m1s
Continuous / Build Install4J Media (push) Successful in 11m46s
Docker Test / build image (pull_request) Successful in 20m35s
Qodana / notify (push) Successful in 12s
Continuous / Continuous Pre-Release (push) Successful in 5m45s
Test / build (push) Successful in 1h1m56s
Test / build (pull_request) Successful in 36m2s
1e4e781508
Six concerns, each pinned before it was changed.

-config with a path that was not a file discarded the path and then called get()
on the empty Optional -- a bare NoSuchElementException naming nothing -- and with
no path at all indexed past the end of the argument list. pathAfter() records what
it was given; deciding whether it is usable belongs to whoever runs it, which is
the only place that can name it. The same crash lived in -feelinglucky, -cgen,
--setup, --home, --destination and, found a pass later, in the four scan and
clientside branches.

All four interactive prompts closed the Scanner wrapping System.in. Scanner.close()
closes its source, System.in cannot be reopened, and JLine's POSIX terminal holds
that same descriptor as its pty slave, so the next readLine died with
ioctl(TIOCGWINSZ) = -1 inside a java.io.IOError -- an Error, which the shell's
per-line catch missed. One prompt per session, then the session ended. Reported
against 8.1.2 and never fixed, only re-documented.

The four prompt loops were copies and had drifted into three defects: `run
withSpecificConfig` asked for a home-directory while validating a config file,
cgen said "File" about a directory, and lang listed en_GB/pt_BR/zn_GB while
matching en/pt/zn -- refusing every value it offered -- then stored Locale("")
from the remainder of the accepted line. They are now one ConsolePrompt, whose
readChoice takes a single map so that listing what it will not accept is
unrepresentable rather than merely fixed.

The headless verbs signalled nothing through their exit code. run() returns one
and main exits with it -- but only on failure, because GUI and WEB return as soon
as they hand off to the Swing EDT and the embedded server, and exiting on success
would kill them. Verified against the jar, -gui included.

The grinder's report re-derived the whole verdict store per request: 251 ms to
select against 3 ms to render the 250 rows it sent, at the deployed row count.
Pagination bounds only the render. With a 2-thread pool that is how the public
instance stopped answering *everything*, including endpoints that do no store
work -- measured at a 502 after 131.3 s while port 80 answered in 0.18 s. The
derivations are now cached per store change (329 ms to 5.4 ms) and the pool is
SPC_GRINDER_HTTP_THREADS, default 4.

Full build green: 1871 tests, 0 failures, 30 skipped, across all six modules.

Two guards were caught asserting nothing and rewritten rather than trusted: the
report's equivalence test compared two overloads after one began delegating to
the other, and stayed green with filtering disabled outright. Both mechanisms
were mutation-checked afterwards.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Six characterization tests for the update mechanism, each building its own throwaway
modpack under a @TempDir rather than reusing the read-only forge_tests fixture -- the
whole point of an update is what happens when the modpack changes between two runs, and
a fixture cannot express a file being added, edited or dropped.

Pinned: the documented update combination removes a file the modpack no longer contains
and refreshes one whose content changed; files only the server itself created (a world,
ops.json) survive because they are absent from the manifest; overwrite empties the
destination, world included; with neither toggle the run is purely additive, so a mod
renamed by its version lands in the pack twice; and the manifest records relative paths
plus the Minecraft- and modloader versions.

The additive case is pinned as designed behaviour, not as a defect -- it is what the
setting promises -- but the consequence deserves to be visible rather than discovered.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Nine guards, all red, one per defect found analysing the update mechanism. Each was run
before committing and fails for the missing implementation, not for a fixture of its own:

- updatingWinsOverOverwriting: cleanupEnvironment empties the destination before the
  manifest the update reads is looked for, so with both toggles on the update is a silent
  no-op and the world goes with the cleanup. The GUI's changeUpdateSettingState greys out
  and un-ticks "Update" whenever "Overwrite" is on, so the combination is unreachable
  there -- at the price of "Update" being disabled by default, since overwrite defaults to
  true. Nothing else goes through that Swing listener: a hand-edited
  serverpackcreator.properties, an embedder or plugin setting
  ApiProperties.isUpdatingServerPacksEnabled, the CLI and the web service all reach it.
- theArchiveOfAnUpdateLeavesOutWhatTheServerWrote: the ZIP is built from the whole
  destination, so an updated pack ships the operator's world and ops.json to everyone who
  downloads it. Observed entries included world/, world/level.dat and ops.json.
- anUpdateKeepsTheOperatorsServerProperties / anUpdateKeepsTheOperatorsVariables:
  copyProperties and createServerRunFiles write unconditionally, reverting the MOTD,
  difficulty, level-name, Java path and memory settings of a running server.
- theManifestAlsoRecordsWhatWasProvisionedAlongsideTheModpack: server.properties,
  server-icon.png, variables.txt, HOW-TO-RUN.md and the start scripts are written after the
  file list is compiled and so are absent from it -- the record the prune works from does
  not describe the whole pack.
- anIconNoLongerWantedIsRemovedByTheNextUpdate: the consequence of that gap.
- aDirectoryEmptiedByAnUpdateIsRemoved: the prune skips directory entries, so a directory
  dropped from the modpack is left behind empty.
- aWorldShippedInTheModpackIsNotRevertedByAnUpdate: a world included from saves/ is in the
  manifest, so it is pruned and re-copied pristine, discarding everything played since.
- aRunThatProducedNothingPrunesNothing: the prune runs before the copy, so a run that
  fails to produce anything still deletes everything the previous one did.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Turns the nine red pins from "red pins for what an update must guarantee" green. A
server pack is routinely run in place, so its directory stops being ServerPackCreator's
the moment somebody starts a server out of it -- and regenerating over it was taking
their world.

New ServerPackUpdater owns the whole question: whether a run is an update, which paths
it must keep its hands off, and the removal of what the previous run produced and this
one did not. ServerPackHandler.run orchestrates, as before.

- Updating now takes precedence over overwriting. cleanupEnvironment emptied the
  destination before the manifest the update reads was looked for, so with both toggles on
  the update was a silent no-op. The GUI kept the combination out of reach by greying out
  "Update" whenever "Overwrite" is on, which is why this never surfaced there -- but a
  hand-edited serverpackcreator.properties, an embedder setting
  ApiProperties.isUpdatingServerPacksEnabled, the CLI and the web service all reach it, and
  overwrite defaults to true. With the precedence settled the combination is well-defined,
  so the GUI no longer needs to forbid it.
- New setting de.griefed.serverpackcreator.serverpack.update.protected: paths an update
  must never delete, overwrite or archive, defaulting to what a running Minecraft server
  writes into its own directory (world, world_nether, world_the_end, ops.json,
  whitelist.json, banned-*.json, usercache.json, eula.txt, logs, crash-reports) plus the
  two files an operator tunes by hand (server.properties, variables.txt). Deliberately
  additive in both accessors, so protection can be widened but never narrowed by accident;
  to regenerate a protected file, turn updating off or delete the file.
- The ZIP-archive now excludes protected paths on an update, unconditionally rather than
  behind the zip-exclusion preference. An updated pack was shipping the operator's world
  and ops.json to everyone who downloaded it.
- The manifest now also records what is provisioned beside the modpack-files: icon,
  server.properties, start scripts, variables.txt, HOW-TO-RUN.md. The prune deletes exactly
  what the manifest lists, so a record that stopped at the copied files could never clean
  up an icon the user stopped wanting. Names come from ServerPackProvisioner, which writes
  them, rather than being spelled a second time here.
- Pruning moved AFTER the copy. Deleting the previous run's output before producing its
  replacement means a generation that fails part-way leaves a server that cannot start; and
  a copy that yielded nothing at all is a broken run, not an empty modpack, so it prunes
  nothing.
- Directories the prune empties are removed, deepest first.
- Relative paths for the manifest now come from Path.relativize and are normalised to
  forward slashes. The old String.replace(absolutePath, "").substring(1) replaced every
  occurrence of the path and threw StringIndexOutOfBoundsException on a file copied to the
  pack root; the normalisation is what lets a pack generated on Windows be updated on Linux.
- cleanupEnvironment deleted the destination twice; once is enough.
- manifest.json's name now has one owner, ServerPackManifest.FILE_NAME.

Published signatures gained trailing defaulted parameters (copyFiles, createServerRunFiles,
zipBuilder on both ServerPackHandler and its collaborators), all @JvmOverloads so existing
Java call-sites keep compiling.

Guards: ServerPackUpdaterTest covers the class directly -- protection across separator
style and case, a path resolving outside the pack, an unreadable manifest, deepest-first
directory removal. Three mutations were applied to confirm they have teeth (dropping
ignoreCase, sorting shallowest-first, dropping the protection check); each failed exactly
one guard and only that one.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Found while adding updateProtectedPaths. Every collection-valued property in
GenerationConfig is declared `var x = fallbackX`, which makes the property and its
fallback the same TreeSet, and every setter does `field.clear(); field.addAll(value)`.
Configuring one therefore overwrites the constant it is supposed to be able to fall back
to.

Reproduced: fallbackZipExclusions reads
[libraries/net/minecraft/server/.../server-MINECRAFT_VERSION.jar,
minecraft_server.MINECRAFT_VERSION.jar, server.jar] on a fresh GenerationConfig, and
[only-this.jar] after `zipArchiveExclusions = TreeSet(listOf("only-this.jar"))`.

It is user-visible: GlobalSettings.kt's four reset-to-default buttons (zipReset,
inclusionsReset, preInstallFilesReset, postInstallFilesReset) each read a fallback, so
pressing reset after a save restores the user's own values rather than the shipped ones.

Two guards, both red: the fallback must survive configuring its setting, across all six
list-settings, and a setting must not be the same object as its fallback in the first
place. updateProtectedPaths is in both and already green -- it builds a fresh set per
read -- so the guards fail only on the five that alias.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Turns the previous commit's two guards green. Seven collection-valued properties in
GenerationConfig were declared `var x = fallbackX`, making each property and its fallback
the same TreeSet, so the setter's `field.clear()` emptied the constant. Each now starts
from a copy.

clientsideMods, modsWhitelist, directoriesToInclude, directoriesToExclude,
preInstallCleanupFiles, postInstallCleanupFiles, zipArchiveExclusions. The first two are
`private set`, so only the other five were reachable from outside -- and those five are
exactly the ones GlobalSettings.kt's reset-to-default buttons read.

api 450 tests, app 167, no failures.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The setting no longer needs a warning label or a guard rail, so it loses both.

- Dropped `!EXPERIMENTAL!` from the label and tooltip (en_GB and zn_GB). The tooltip now says
  what an update actually does -- removes what the modpack dropped, refreshes the rest, leaves
  a running server's own files alone -- and that it takes precedence over overwriting.
- Removed the interlock. changeUpdateSettingState used to disable and un-tick "Update Server
  Packs" whenever "Overwrite Server Pack" was on, which is the only reason the destructive
  combination never surfaced in the GUI. Since overwriting is on by default, it also meant the
  update setting was greyed out on a fresh install and a user had to untick overwrite before
  they could even reach it. ServerPackHandler settles the precedence itself now, so there is
  nothing left to forbid; the listener instead greys out the new protected-paths editor while
  updating is off, because nothing consults it then.
- New "Protected From Updates" row editing the update.protected setting, following the same
  ScrollTextArea + revert + reset idiom as the other path-lists, registered with the
  ComponentResizer like its siblings.
- ScrollTextArea.setEnabled now forwards to the text area it wraps. Swing does not cascade this
  from a JScrollPane to its view, so greying out one of these widgets greyed out nothing: the
  text stayed bright and stayed editable. Guarded by ScrollTextAreaEnabledTest, whose teeth were
  checked by dropping the forwarding line (fails).

GUI-verified by painting the real GlobalSettings panel to a PNG from inside the test JVM --
screencapture returns black on this machine for want of Screen Recording permission, and the
in-process render needs no permission at all. Confirmed: no EXPERIMENTAL text, "Update Server
Packs" enabled while "Overwrite Server Pack" is ticked, the new row rendering its defaults, and
the protected editor reading scrollPane=false textArea=false before the checkbox is clicked and
true/true after. The harness was deleted afterwards; it is a technique, not a test to keep.

api 450 tests, app 168, no failures.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The archive exclusion added with the update work used the protected-paths predicate, and
protected does not mean absent. server.properties and variables.txt are both protected --
an update must not revert the operator's copies -- and part of every server pack, so an
updated pack was archived without them and its start scripts had nothing to read.

Observed archive of an updated pack, before: [config/, config/settings.cfg, config/alpha/,
config/alpha/alpha.cfg, start.bat, start.ps1, install_java.sh, manifest.json, mods/,
mods/alpha.jar, install_java.fish, install_java.ps1, start.fish, start.sh, HOW-TO-RUN.md].
Exactly the two protected files SPC itself provisions were missing.

The right line is the manifest, not the protected list: what must never be archived is what
the operator's own server wrote, which is precisely what the manifest does not list. So the
ZIP now excludes a path that is protected AND absent from this run's manifest. A world or an
ops.json a running server created is still left out; a world shipped by the modpack, which is
in the manifest, is archived as it always was.

Found by reading serverpackcreator-help/Writerside/topics/HELP.md while documenting the
feature -- its "Multiple Java Installations" note about variables.txt reaching the ZIP is what
prompted checking. The existing archive guard asserted only that the world and ops.json were
absent, so it stayed green; it now also asserts variables.txt, server.properties and the start
scripts are present, and was watched failing on the first of those before this fix.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
- HELP.md: the "Keeping Data" section rewritten around the case it exists for -- somebody who
  ran a server out of their pack and does not want to lose the world. Updating no longer asks
  you to disable overwriting first, and the new text says what an update removes, refreshes and
  leaves alone, what the protected-paths setting covers and why it can only be widened, that
  protection guards a file that already exists rather than forbidding its creation, and what
  does and does not end up in the ZIP-archive. The additive combination (both toggles off) is
  kept, with its cost stated plainly: nothing is refreshed, so a version-renamed mod lands in
  the pack twice and the server will not start. The "Multiple Java Installations" note now names
  both ways a local Java path can reach an archived variables.txt, updating included.
- HELP.md property table: update.protected documented, and two descriptions corrected --
  serverpack.update no longer "Requires overwrites to be disabled", overwrite.enabled says it is
  ignored when an update is possible. Added to the example properties block too.
- claude-docs/API-BEHAVIOUR-CHANGES.md: five rows. The precedence and prune-ordering change, the
  new protected-paths setting, the manifest now describing the whole pack (and its entries being
  forward-slashed via Path.relativize), the defaulted @JvmOverloads lambdas, and the fallback
  aliasing fix.
- serverpackcreator-api/CLAUDE.md: a landmine for the three rules ServerPackUpdater exists to
  keep, plus the one this work got wrong first -- the ZIP exclusion is keyed on the manifest,
  not on the protected list, because protected does not mean absent.
- CLAUDE.md: snapshot refreshed (api 450, app 168, re-derived from the test-result XML), and two
  lessons that generalise -- a setting whose destructive sibling is on by default never runs, and
  a guard rail in one adapter is a reason the bug stays unreported rather than a fix.
- claude-docs/REFACTOR-LOG.md: the narrative, including the session's own git accident and how
  the four lost files were recovered.

Note serverpackcreator-help/Writerside/topics/HELP.md is GENERATED -- .gitignore:459, and
.forgejo/workflows/docs.yml:57 copies the repository-root HELP.md over it. The edits were made
there first and have been replayed onto the real source; the two files are byte-identical.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Both `serverpackcreator-api/src/main/resources/` and
`serverpackcreator-help/Writerside/topics/` hold generated copies of the seven root-level
documents (CHANGELOG, CODE_OF_CONDUCT, CONTRIBUTING, HELP, LICENSE, README, SECURITY), and
both are gitignored -- .gitignore:455-462 for the Writerside set, and the api-resources copies
likewise. Two Copy tasks in serverpackcreator-api/build.gradle.kts write them
(`shipRootDocuments`, `shipWritersideDocuments`), and .forgejo/workflows/docs.yml stages the
same set again before the help site builds.

So editing either copy is undone by the next build and `git status` says nothing about it,
which is exactly what happened to the update-mechanism documentation in this session: it was
written against Writerside/topics/HELP.md, because the staged copy was sitting there and looked
like the file, and had to be replayed onto the root. Sits beside the existing api-docs.yaml
note, which is the same class of trap.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The AppImage is far newer than the install4j installers, ships a JDK the project does not
otherwise distribute, and the aarch64 one is cross-packaged rather than built on the
architecture it targets. Somebody picking a download should be able to see that from the
filename, so `misc/build-appimage.sh` appends `_experimental` to it.

That name is decided in exactly one place. Both workflows previously rebuilt the filename from
the version and architecture, which would have gone stale the moment the script changed it, so
they now resolve it by glob instead:

- devbuild.yml: the two `Verify AppImage` steps glob for the produced file, fail when there is
  none rather than when a specific name is missing, and publish it as a step output that
  `Generate checksums` consumes. The upload paths glob too.
- release-build.yml: a new `Build AppImages` step produces both architectures and asserts there
  are exactly two, and `Collect files` copies them into the release directory before
  checksum.txt is generated, so they are covered by it like every other asset.

The glob is anchored on `ServerPackCreator-` rather than a bare `*.AppImage` on purpose: the
script downloads `appimagetool-<arch>.AppImage` into the same directory, and a bare glob would
match it. The two release AppImages are built on the one amd64 runner because nothing aarch64
has to execute -- the JDK is merely unpacked, and appimagetool runs as the host architecture
while ARCH decides which runtime is embedded -- which is also why devbuild.yml's aarch64 job
needs no arm runner label, there being no such registered runner.

Recovered work: this was written on 2026-09-18 after the release-build and devbuild workflows
failed on Forgejo, and the three files were destroyed by a `git reset --hard` in a later
session. The script came back from a Claude Code session transcript, the workflows from
IntelliJ Local History. Verified afterwards that the three agree: the script's APP_NAME is
`ServerPackCreator`, its appimagetool download is `appimagetool-<arch>.AppImage` in the same
directory, its JDK directories are per-architecture (`jdk-21-<arch>`) and it `rm -rf`s its
AppDir before each build -- every claim the workflow comments make. `bash -n` and a YAML parse
pass on all three.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The README told you ServerPackCreator has a CLI and then showed you the webservice. New
section 5.1 covers the part people actually start with, in nine subsections: the
thirty-second version, where files end up, `-feelinglucky`, the `-cgen`/`-config` two-step
with a worked example of the config it writes, `-withallinconfigdir`, the `-cli` interactive
shell, the global arguments, automating it, and a closing list of the things that bite.

Written from the questions that actually get asked, so the examples are concrete rather than
schematic -- CurseForge and Overwolf instance paths, bash, PowerShell and cmd.exe spellings,
forward slashes on Windows, mapped network drives, relative paths, and `--home` for a run that
must not touch the normal installation. Paths with spaces are quoted in every single example,
because unquoted paths are the most common way this goes wrong.

Two things stated that were not written down anywhere a user would find them:

- **The exit code is meaningful.** A one-shot run exits `0` only when it produced what was
  asked for and `1` when it did not -- a missing argument, a path that does not exist, a
  configuration check that failed, a generation that errored -- so `&&` means what you expect.
  `-withallinconfigdir` exits `1` if any configuration failed, and `0` for an empty
  configs-directory, nothing to do not being a failure. GUI and WEB return as soon as they hand
  off and never exit on their own, so there is nothing to check there.
- **Argument precedence**, and what happens when a value is missing rather than wrong.

Section 5.1 (webservice) becomes 5.2, with its subsections renumbered; no cross-reference to
the old numbering survives.

Recovered work: written on 2026-09-19 while investigating a CLI report and checking the unit
tests around it, and destroyed by a `git reset --hard` in a later session. Restored from
IntelliJ Local History and verified against the session transcript that produced it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
misc/build-appimage.sh appends `_experimental` to the filename, because the AppImage is far
newer than the install4j installers, ships a JDK the project does not otherwise distribute, and
its aarch64 build is cross-packaged rather than built on the architecture it targets. Somebody
choosing a download should see that without having to read release notes.

Both workflows used to rebuild that filename from the version and architecture, which is a copy
that goes stale the first time the script changes it. They resolve it by glob now: devbuild.yml
verifies whatever was produced, publishes it as a step output for the checksum step, and globs
in its upload paths; release-build.yml gains a `Build AppImages` step that produces both
architectures on the one amd64 runner, asserts there are exactly two, and copies them into the
release directory before checksum.txt is generated so they are covered by it.

The glob is anchored on `ServerPackCreator-`, not a bare `*.AppImage`: the script downloads
`appimagetool-<arch>.AppImage` into the same directory.

Written on 2026-09-18 after both workflows failed on Forgejo, and recovered after a later
session destroyed the uncommitted files.
Section 5.1 covers generating a server pack from a modpack via the CLI, in nine subsections:
the thirty-second version, where files end up, -feelinglucky, the -cgen/-config two-step with
a worked example of the config it writes, -withallinconfigdir, the -cli interactive shell, the
global arguments, automating it, and the things that bite. Before this the README mentioned
the CLI and then showed the webservice, which is now 5.2.

Examples are concrete rather than schematic -- CurseForge and Overwolf instance paths, bash,
PowerShell and cmd.exe, forward slashes on Windows, mapped drives, relative paths, --home for
a run that must not touch the normal installation -- and every path with a space is quoted,
that being the most common way this goes wrong.

Two things are stated that were nowhere a user would find them. The exit code is meaningful: a
one-shot run exits 0 only when it produced what was asked for, so `&&` means what you expect,
while -withallinconfigdir exits 1 if any configuration failed and 0 for an empty
configs-directory. And argument precedence, including what happens when a value is missing
rather than wrong.

Written on 2026-09-19 while investigating a CLI report and checking the tests around it, and
recovered after a later session destroyed the uncommitted file.
People run a server straight out of a generated server pack, so that directory stops being
ServerPackCreator's: it holds a world, an ops.json, a hand-tuned server.properties. Regenerating
over it was taking all of them, and the setting meant to prevent that could not run.

Updating now takes precedence over overwriting. isServerPacksOverwriteEnabled defaults to true
and its cleanup emptied the destination BEFORE manifest.json was looked for, so with both set the
update was a silent no-op and the world went with the cleanup. The GUI hid this by greying out
the update checkbox whenever overwrite was ticked -- which is why it survived as "experimental"
rather than being reported as broken -- while an embedder, a hand-edited properties file, the CLI
and the web service all reached it.

A new ServerPackUpdater owns the question: whether a run is an update, which paths it must not
touch, and the removal of what the previous run produced and this one did not. Pruning moved
AFTER the copy, so a generation that throws leaves a startable pack rather than a gutted one, and
a copy that produced nothing at all is treated as a broken run rather than an empty modpack.
Directories the prune empties are removed, deepest first.

serverpack.update.protected lists what an update must never delete, overwrite or archive,
defaulting to what a running Minecraft server writes into its own directory plus server.properties
and variables.txt. It is deliberately the union of the shipped defaults and whatever is configured,
in both accessors, so protection can be widened but never narrowed by accident. Protection guards
a file that exists rather than forbidding its creation, so a first generation still ships a world,
a server.properties and a variables.txt.

The ZIP of an updated pack was carrying the operator's world and ops.json to everyone who
downloaded it. It now excludes what is protected AND absent from the manifest -- the line between
"the operator's server wrote this" and "we produced this". Keying it on the protected list alone
was the first attempt, and it shipped an archive without server.properties or variables.txt, whose
start scripts had nothing to read; that was caught by reading the help docs while writing them.

The manifest now describes the whole pack, icon and scripts and variables.txt included, because
the prune deletes exactly what it lists and a record stopping at the copied files could never
clean up an icon the user stopped wanting. Its entries come from Path.relativize and are
forward-slashed; the old absolutePath.replace(packPath, "").substring(1) replaced every occurrence
of the pack path and threw on a file copied to the pack root.

Found in passing and fixed in their own commits: seven collection-valued properties in
GenerationConfig aliased their own fallback constant, so configuring zipArchiveExclusions emptied
fallbackZipExclusions -- which is what the GUI's four reset-to-default buttons read. And
ScrollTextArea.setEnabled did not reach the text area it wraps, so greying out one of those
widgets greyed out nothing.

Nine defects, each pinned red and run before being committed red. Three mutations confirmed the
ServerPackUpdater guards have teeth, one more the GUI component guard. The GUI was verified by
painting the real GlobalSettings panel to a PNG from inside the test JVM, screencapture returning
black on this machine for want of Screen Recording permission.
Audit of 1e4e78150..develop recorded in claude-docs/REFACTOR-AUDIT.md: no HIGH findings, five
MEDIUM, two LOW. Two of the MEDIUMs are closed here.

M4 -- feat(build): name the AppImage _experimental carried no measurement, which is the stated
standard for build logic. The measurement existed (a container run on 2026-09-18) but was left in
a session transcript rather than the artifact that outlives it. Re-run on 2026-09-20 rather than
quoted, and the numbers now sit in release-build.yml beside the assertion they justify: two
invocations in one debian:bookworm-slim workspace produce
ServerPackCreator-9.9.9-test-aarch64_experimental.AppImage (ELF ARM aarch64) and
ServerPackCreator-9.9.9-test-x86_64_experimental.AppImage (ELF x86-64), the anchored
ServerPackCreator-* glob matches 2 while a bare *.AppImage matches 3 because appimagetool sits in
the same directory, and the JDK directories are per architecture (jdk-21-aarch64, jdk-21-x86_64)
so the second run cannot inherit the first one's runtime. That is every claim the workflow
comments make.

M5 -- the archive guard asserted only that the world and ops.json were absent and that one mod was
present, so it stayed green when the first attempt at that exclusion also dropped server.properties
and variables.txt. A guard asserting something is excluded must assert in the same breath what is
still included; recorded as a landmine in the api module's CLAUDE.md.

M1-M3 are properties of commit structure in the merged series -- a feature commit carrying an
enabling refactor and an unrelated one-line fix, and two guards landing in the same commit as the
code they guard, so neither has a red state to check out. develop is unpushed, so they remain
rewritable; not undertaken without an instruction, and flagged in the audit file.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Found by the analysis pass on develop, recorded as H1 in claude-docs/ANALYSIS-AUDIT.md.

ServerPackHandler.run writes manifest.json, and ServerPackUpdater.isUpdateRun decides whether a
run is an update by asking whether that file exists -- so isUpdateRun flips from false to true in
the middle of the run, before either createServerRunFiles call. On a first generation with
updating enabled and zip creation desired:

  1. the zipped-variant call writes variables.txt, because it does not exist yet
  2. the local-variant call, which is the only one carrying the user's SPC_JAVA_SPC path, now
     sees a file that exists, is protected, and belongs to a run isUpdateRun calls an update --
     so it preserves this run's own output and never writes the local variant

The pack keeps the archive's copy, where SPC_JAVA_SPC was replaced with the literal "java".
Reachable in the feature's default configuration: overwrite on, zip on, user ticks Update Server
Packs.

Measured before writing the guard, same config generated twice into temporary directories and
differing only in the toggle, with SPC_JAVA_SPC=/opt/my-very-distinctive-jdk/bin/java --
updating enabled: variables.txt does NOT contain the path; updating disabled: it does.

No existing guard covers it: anUpdateKeepsTheOperatorsVariables exercises the second run only,
and nothing asserted anything about a first generation while updating was enabled.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Analysis finding M1. ServerPackFileGatherer.copyFiles returns before the copy loop for
lazy_mode, so the isProtected predicate the update mechanism installs is never consulted: the
whole modpack goes over the top with copyRecursively(..., overwrite = true). A modpack that
ships a world therefore overwrites the operator's played copy on an update, which is precisely
what the feature promises not to do.

The same early return hands back an empty copiedFiles, so a lazily generated pack's manifest
lists only the files provisioned beside the modpack -- observed: [start.bat, start.sh,
start.ps1, start.fish, ...] and no mods/alpha.jar. The emptiness predates this work; what is
new is that the manifest is now the record an update prunes against, so a lazy pack can never
converge on its modpack.

Both guards red, run before committing.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Turns the three red pins from the analysis pass green.

H1 -- ServerPackHandler.run now snapshots which provisioned files were already in the pack
before it writes anything, instead of asking ServerPackUpdater.preserves per call. isUpdateRun
answers by looking for manifest.json, and run writes one part-way through, so the answer flipped
mid-run: the zipped-variant start-scripts step created a variables.txt that the local-variant
step then preserved as though it were the operator's. A first generation with updating enabled
therefore shipped the archive's variables.txt, whose SPC_JAVA_SPC is the literal "java" rather
than the configured path. The icon and server.properties decisions read the same snapshot, so
the whole run now agrees with itself.

M1 -- lazy mode walks the modpack instead of copyRecursively-ing it. The early return meant the
isProtected predicate was never consulted, so a modpack shipping a world overwrote the
operator's played copy on an update; and it returned an empty list, so the manifest listed only
the provisioned run-files and an update of a lazily generated pack had nothing to prune against.
Both are fixed by the walk, which skips a protected path that already exists and records what it
copied.

M3 -- the four case-folding comparisons in prune and the archive filter pass Locale.ROOT. The
no-argument lowercase() uses the default locale, where tr/az fold I to a dotless i. The folding
itself stays, deliberately, and now says why: on a case-insensitive filesystem Mods/Alpha.jar
and mods/alpha.jar are one file, so NOT folding would prune what the copy had just written.

M2 in the analysis file proposed making that comparison case-sensitive. That recommendation was
wrong and is not implemented -- it would delete freshly copied files on Windows. The report has
been corrected.

BEHAVIOUR CHANGE, flagged rather than hidden: ServerPackFileGathererTest.lazyModeCopiesWholeModpack
asserted `copied.isEmpty()` with the message "Lazy mode returns no per-file accounting". That
assertion is now inverted, because the behaviour it pinned is the defect. Three rows added to
claude-docs/API-BEHAVIOUR-CHANGES.md.

api 453 tests, app 168, no failures.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Second pass over develop after 5bc4e2743. H1, M1 and M3 closed; full build green at 1,904 tests.

M2 is RETRACTED rather than fixed. It said the prune's case-insensitive keep-set comparison is
wrong on a case-sensitive filesystem, which is true and one-sided. On a case-insensitive one --
Windows, macOS by default -- Mods/Alpha.jar and mods/alpha.jar are one file, and the prune runs
after the copy: an unfolded comparison would fail to match what the copy had just written,
conclude it was stale, and delete a file this very run produced. Folding costs an occasional
stale file on Linux; not folding costs a freshly generated one on Windows. The behaviour stands
and now carries that reasoning in a comment.

Worth recording because the finding named a real asymmetry and then proposed the fix that
resolves it in the destructive direction. Anything that changes what gets deleted has to be
argued on both filesystems before it is written down.

Also verified in this pass: the H1 defect class -- a value that changes mid-run being re-derived
per call -- does not recur anywhere else in run(); isProtected's per-call exists() is safe by
construction because the copy checks a destination before writing it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The six items left open as M4 in claude-docs/ANALYSIS-AUDIT.md. None of them guards a defect
that is currently present -- every one passes against today's code -- so each was mutation-checked
instead, and each failed exactly the guard written for it and no other.

- aManifestWrittenOnTheOtherPlatformStillPrunes: a manifest whose entries are `mods\alpha.jar`,
  as a pack generated on Windows carries, pruned on this platform. protects() was already pinned
  against backslashes; prune() never was, and it is the half that deletes.
  Mutation: normalize() stops converting separators -> fails (with protectionIgnoresSeparatorStyleAndCase).
- anAbsolutePathInAnOldManifestCannotReachOutsideThePack: File(pack, "/abs/path") resolves under
  the pack on Unix, and the guard pins that the pack stays the only thing a prune can touch
  whatever a manifest claims.
  Mutation: prune resolves entries with File(relative) -> fails (with four others).
- protectionStopsAtTheSegmentBoundaryWhenPruning: `world` must protect world/ but not worlds/,
  world_backup/ or worldly.jar, or a modpack with a directory named after the save becomes
  unprunable.
  Mutation: startsWith("$protected/") relaxed to startsWith(protected) -> fails (with
  protectionCoversAnEntryAndItsContents).
- anUnreadableManifestPrunesNothingEither: the permission path, distinct from the unparseable one
  already covered. Guarded by Assumptions so a filesystem that ignores setReadable(false), or a
  run as root, skips rather than lies.
  Mutation: readManifest stops catching -> fails (with anUnreadableManifestPrunesNothing).
- aDerivedDestinationIsUpdatedInPlaceJustLikeAGivenOne: every other update guard redirects the
  pack with customDestination, so the path a normal GUI or CLI run takes -- the destination
  derived from the server-packs directory and the pack name -- was never exercised with updating
  on. Also asserts the second run updates the first pack rather than generating a second beside it.
- theCallersExclusionAppliesEvenWithTheZipExclusionPreferenceOff: zipBuilder now installs its
  ExcludeFileFilter unconditionally, because an update must keep a running server's data out of a
  shareable archive whatever the preference says -- while the configured list must still be ignored
  when the preference is off, that being what the preference means. Neither half was covered.
  Mutation: filter installed only when the preference is on -> fails.

api 459 tests (was 453), 1910 across the repository, no failures.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
docs: record M4 closed, with the mutation that proves each new guard
All checks were successful
Documentation / Writerside webhelp (push) Successful in 3m55s
Continuous / Build JAR (push) Successful in 15m30s
Docker Test / build image (push) Successful in 29m0s
Docker Test / build image (pull_request) Successful in 27m52s
Qodana / scan (push) Successful in 16m48s
Documentation / Help image (push) Successful in 5m2s
Continuous / Build AppImage (x86_64) (push) Successful in 2m2s
Continuous / Build AppImage (aarch64) (push) Successful in 1m46s
Continuous / Build Install4J Media (push) Successful in 8m38s
Qodana / notify (push) Successful in 16s
Continuous / Continuous Pre-Release (push) Successful in 5m36s
Test / build (pull_request) Successful in 46m29s
Test / build (push) Successful in 45m44s
52074ebc52
Third pass over develop at 2f3795465. All six remaining suggested tests are written and every
finding from the 2026-09-20 analysis is now closed or retracted.

Each new guard passes against today's code, so each was proved by mutating production code
instead; the table records which mutation was applied and which guards it broke. Two items
carry a caveat worth keeping: item 8 is Assumptions-guarded so a filesystem ignoring
setReadable(false) skips rather than passes for the wrong reason, and item 5 has no single-line
mutation because it pins a path through run() rather than a predicate.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Qodana run 817 reported seven KDocUnresolvedReference problems, all the same shape: a doc block
followed immediately by another doc block, so the first attaches to nothing. Dokka drops an
unattached block, and the declaration it was written for is left undocumented -- which is how a
rationale nobody meant to delete disappears.

- ClientsideVerifier: propagateClientOnlyProof had no doc at all; the sodium case and the
  "keeps its own bootResult" rule were stranded above contradictsTheProof.
- ForgeTomlScanner: getSide's doc had drifted above getVersionRange. Reattached, and its @param
  corrected -- it described the modId -- and the BOTH fallback stated, which it never did.
- MinecraftServerMeta: the class doc sat above `import java.util.Collections`, i.e. before the
  imports, documenting nothing.
- ModIdRegistry: two older copies of mappingFor's doc, stranded above refFor and mappingsFor.
  The first claimed "CurseForge gets no guess at all", which the code has contradicted since
  2026-09-06 -- deleted rather than corrected, per the standing rule about duplicated knowledge
  drifting toward the easier copy. The second's history paragraph is the one thing the live doc
  lacked, so that moved onto mappingFor and the copy went with it.

Documentation only; no declaration, signature or body was touched.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Qodana run 817 flagged three unused imports, two redundant qualifiers and one unused function in
this module. Read together they are one leftover: env(key, default) has had no caller since the
operator configuration moved into GrinderConfiguration -- the comment in main already says so,
"a real object rather than a run of env() calls inside this function" -- and the import of
BootResult went with the code that used it.

SystemdUnitConfigurationTest carried the same leftover: envWithLiteralDefault, envAnyName and
envWithoutDefault are regexes over GrinderApplication's source text, unreferenced since the test
started reading GrinderConfiguration.KNOBS instead. Removed with the function they scanned for.
Nothing this test asserts changed -- the three fields were read by no test.

VerdictReportRenderer imports URLEncoder and StandardCharsets and then writes both out fully
qualified on the one line that uses them; now it uses the imports.

Behaviour-preserving. :serverpackcreator-grinder:test 537 tests, 0 failures, 29 skipped.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Eight locals across the Fabric, LegacyFabric and Quilt metas were named next_installers,
next_loaders, next_releases, next_snapshots and next_allVersions -- underscores in camelCase
positions, which Qodana run 817 reports as LocalVariableName and which this project's own naming
convention rules out.

The next_ prefix was carrying real meaning and is kept: each is the snapshot being built to
replace the published one, swapped into its volatile field by the Collections.unmodifiableList
assignment at the end of update(). So nextInstallers, nextLoaders, nextReleases, nextSnapshots,
nextAllVersions.

Locals only -- no field, signature or published name moved. :serverpackcreator-api:test 459
tests, 0 failures, 1 skipped.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The four blocks fixed in "reattach four KDoc blocks that had come loose from their declarations"
were the ones Qodana could see: KDocUnresolvedReference fires only when an orphaned block happens
to contain a [link] that no longer resolves, so a block full of prose is invisible to it. Scanning
every .kt/.kts file for a doc block whose next non-blank line opens another doc block -- nothing
can attach to the first -- found fourteen more. That scan now reports zero.

Reattached to the declaration they were written for, each confirmed against the commit that
separated them where it was not obvious from the text:

- BootLogClassifier: the dependency-failure rationale (36 of 112 boots, the largest failure class)
  belongs to dependencyFailureMarkers; 2226261f8 inserted clientOnlyDependencyMarker between them.
- BootCandidateSelectorTest: "a file tagged for a *different* loader is still refused" belongs to
  stillRefusesAFileTaggedForAnotherLoader; fc7baf3f5 inserted a test between them.
- ReportServer.statusJson, ModrinthPlatform.filesOf, JarSelfDeclaration.platformIdsFor,
  FabricScanner.readDependencies, QuiltScanner.readDependencies, ScriptTemplateContentTest's
  extractShellFunction, ScriptTemplatesConfigTest's clearScratchPreferences and
  GrinderSpcEnvironmentTest's theSpcEnvironmentIsClaimedBeforeTheFirstLogStatement -- each was
  undocumented while its doc sat above a neighbour.

Merged, because both copies described the same declaration and each held something the other did
not: VersionMeta.refreshManifests (a27b63299 added the @Synchronized rationale above the existing
doc rather than into it) and FabricInstaller.installers.

Deleted, because the live doc below already says it and the copy had gone stale:
ApiProperties.apiVersion's one-liner, and JarDownloaderRoutingTest's class doc -- which still
described routing locked files to the browser-downloader that was removed on 2026-09-02.

Documentation only; no declaration, signature, body or declaration order changed. api 459 tests,
clientside 668, grinder 537 -- 0 failures.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Qodana run 817 reported 49 problems against develop at 52074ebc5. Twenty-one of them were real
and are fixed here; twelve were verified false positives or won't-fix and are recorded as such;
the rest is style noise.

The seven KDocUnresolvedReference problems turned out to be one defect: a doc block followed
immediately by another doc block, so the first attaches to nothing, dokka drops it, and the
declaration it was written for reads as undocumented. Four sites were reported, because that
rule fires only when a stranded block happens to contain a [link] that no longer resolves. A
structural scan -- next non-blank line after a doc block opens another doc block -- found
fourteen more that were invisible to it, including the rationale for BootLogClassifier's
largest single failure class and the whole case for ClientsideVerifier.propagateClientOnlyProof.
The scan now reports zero repository-wide.

The unused function, the three unused imports and the two redundant qualifiers were the residue
of the GrinderConfiguration extraction; the eight underscore-carrying locals in the Fabric,
LegacyFabric and Quilt metas now read as the snapshots they are.

Nothing here changes behaviour. develop's unmodified test tree, checked out over this branch's
production code in a detached worktree, compiles without a single adaptation and runs green:
api 459, clientside 668, grinder 537, zero failures.

Not confirmed by a re-scan: Docker Desktop's VM is capped at 2 GB here and Qodana's linter is
OOM-killed during the Gradle import, so the local run was abandoned deliberately. The evidence
in its place is the structural scan, the full compile and the 1,664 pre-existing guards.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Four commits and their merge, checked against the refactoring conventions. No HIGH: the two
refactor:-labelled commits contain eight local renames, one unreachable function, one unused
import, three unused test fields and one fully-qualified name replaced by the import that was
already there -- and nothing else, proven by stripping every doc block from all 26 touched files
at both ends of the range and diffing what remained.

Four MEDIUM, all of them about knowledge left outside the repository rather than about the code:
the orphaned-KDoc defect is fixed eighteen times over with no guard behind it and Qodana can see
only 22% of it; 28 triaged findings were deferred with their reasons living in a chat log; the
scan itself is known to analyse 85 Translations-referencing files with the symbol unresolved, and
has no baseline; and one commit bundled a second, unrelated cause under a subject naming the first.

Three LOW, one of which is a real loss: deleting a stale doc copy took two arguments with it that
survive nowhere.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Closes audit finding M1. Eighteen orphaned doc blocks were fixed on 2026-09-20 with nothing left
to stop the nineteenth: Qodana reports one only when it happens to contain a [link] that no longer
resolves, which was 4 of the 18, and the scan that found the rest was a throwaway script in a chat
log.

**This lands red, and the red is real.** It fails on two instances that script missed, because it
only looked at blocks spanning several lines and both of these are one-liners superseded by a
longer block written next to them:

    serverpackcreator-api/.../legacyfabric/LegacyFabricInstaller.kt:47
    serverpackcreator-grinder/.../GrindLoopTest.kt:185

Run before committing, per the rule that a guard committed red is only evidence when the red is the
missing fix rather than the guard's own fault: the failure message lists exactly those two paths,
and the scan reached 500+ Kotlin files, which the size assertion above it states independently so a
scan that finds nothing cannot pass by vacuity.

Lives in -api and reads every module because the convention is repository-wide; a test source set
adds no compile dependency, so the module graph is unaffected.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Turns the previous commit green. Both are one-line docs superseded by a longer block written beside
them rather than over them:

- LegacyFabricInstaller.allVersions: the one-liner says *what* the list is and the block below says
  *how* it is published, so they merge -- the same shape, and the same fix, as
  FabricInstaller.installers on 2026-09-20.
- GrindLoopTest.everyCompletedPassIsCounted: subsumed, since the block below states the same fact and
  then says how many passes and why. Deleted.

Documentation only. :serverpackcreator-api:test --tests '*KDocAttachmentTest*' now PASSES, having
failed on exactly these two paths one commit ago.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Closes audit finding L1. The stale ModIdRegistry copy deleted on 2026-09-20 was deleted for a good
reason -- its headline claim, "CurseForge gets no guess at all", had been false since 2026-09-06 --
but the whole block went with the headline, and two arguments in it survive nowhere else in the
module: that a slug guess is worth making because it costs one lookup that may simply miss, which is
cheaper than never resolving the dependency, and that an id mapping nowhere is reported rather than
fabricated.

Both checked against the code before being written back, rather than restored on trust: mappingFor
returns ModIdMapping.Guess for both platforms and ModIdMapping.None otherwise, and BootVerifier's
manifest planner turns a mapping that reaches nothing into ManifestDependencyPlan.Unmapped(modID) --
the id, by name, with no ref invented for it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Closes audit findings M2 and M3. Twenty-eight of run 817's 49 problems were triaged and then left
with their reasons in a chat log: twelve won't-fix decisions -- two Spring beans QDJVM Community
structurally cannot see, four Gradle @Incubating uses with no stable alternative, one that would
break source compatibility for embedders of a published module, and five commented early-return
guards -- plus sixteen style items. B38 carries the table and says the record is interim: the fix is
teaching qodana.yaml the verdicts and adding a baseline, so the tool stops re-reporting all 49 as
new on every run.

B39 is the scan's own defect. It runs against a raw checkout with no code generation, so 85 files
referencing the generated Translations object are analysed with it unresolved -- 79 of them in -app,
which is exactly the module whose three findings might otherwise be read as a clean bill. The same
warning now sits above the scan step in the workflow, because a reader of that job will not think to
open the backlog.

Neither is fixed here, and the reason is stated rather than implied: both need a Qodana run to
confirm, and Docker Desktop's VM is capped at 2 GB on this machine, where the linter is OOM-killed
during the Gradle import. B39 names the measurement -- sanity-failure count and problem count, before
and after -- that has to be in the commit message when it does land.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Closes audit finding L3. Every module's suite was run to get the numbers rather than incremented:
api 450 -> 460, clientside 643 -> 668, grinder 532 -> 537. app (168), plugin-example (3),
plugin-grinder (75) and web-frontend (32, Vitest, 14 files) were re-measured and were already right.
1,943 tests, zero failures, across all seven modules.

The lesson is the one worth keeping: a tool that detects a defect through a side effect sees only the
share of it that has that side effect. Qodana named 4 of 18 orphaned doc blocks, because it reports
them only when a stranded block happens to carry a [link] that no longer resolves -- and the block it
missed included the whole case for ClientsideVerifier.propagateClientOnlyProof. The same shape appeared
one level down: the throwaway script that found the other 14 missed two more, because it only
considered blocks spanning several lines, and KDocAttachmentTest caught those on its first run.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Six of seven closed the same day, one recorded as unfixable in place. M1 is the one that paid for
itself: the guard written to close it found two orphaned blocks nobody had found yet, so it landed red
on real instances rather than as a green formality.

M4 stays open by decision, not by neglect -- the commit it names is merged into develop, and
force-pushing a shared branch to relabel a behaviour-preserving 13-line cleanup is the wrong trade.
This entry is the remedy, as it was for 358675fbf on 2026-09-01.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
merge: close the audit of the Qodana remediation
Some checks failed
Qodana / scan (push) Has been cancelled
Documentation / Writerside webhelp (push) Successful in 2m35s
Continuous / Build JAR (push) Successful in 14m32s
Continuous / Build AppImage (x86_64) (push) Has been cancelled
Continuous / Build AppImage (aarch64) (push) Has been cancelled
Continuous / Build Install4J Media (push) Has been cancelled
Continuous / Continuous Pre-Release (push) Has been cancelled
Documentation / Help image (push) Has been cancelled
Test / build (pull_request) Has been cancelled
Qodana / notify (push) Has been cancelled
Docker Test / build image (push) Has been cancelled
Test / build (push) Has been cancelled
Docker Test / build image (pull_request) Has been cancelled
6b51f7006c
Six of the seven findings the audit raised, closed. The one that paid for itself is M1: the defect
fixed eighteen times over on 2026-09-20 had nothing guarding it, because Qodana reports a loose doc
block only when the block happens to carry a [link] that no longer resolves -- 4 of the 18. The guard
written to close that gap found two more instances on its first run, both single-line blocks the
throwaway script had skipped, so it landed red on real work rather than green on none.

M2 and M3 were knowledge sitting outside the repository: twenty-eight triaged-and-deferred findings
whose reasons lived in a chat log, and a scan that analyses 85 Translations-referencing files with the
symbol unresolved -- 79 of them in -app, the module whose apparent cleanliness is the thing that
misleads. Both are now B38 and B39, and the scan's blind spot is stated in the workflow itself.

L1 restores two arguments a deleted doc copy took with it, each re-checked against the code first,
since restoring a claim on trust is exactly what caused the finding.

M4 is not fixed and says so: its commit is already merged, and force-pushing a shared branch to
relabel a 13-line behaviour-preserving cleanup is the wrong trade.

All seven modules re-measured: 1,943 tests, zero failures.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`release` and `mirror` both assembled the whole release JSON -- changelog section
included -- into a single `-d "{...}"` shell word. Linux caps ONE argv entry at
MAX_ARG_STRLEN (32 * PAGE_SIZE = 131,072 bytes) regardless of ARG_MAX, so past
that size execve returns E2BIG and the shell reports

    /var/run/act/workflow/create.sh: line 21: /usr/bin/curl: Argument list too long

which is exactly how 9.0.0-beta.1 died (run 536, job `Forgejo release`). The
downstream JSONDecodeError in that log is the consequence, not the cause: curl
never ran, so python read an empty pipe.

Measured, since build logic is verified by measurement:

  9.0.0-alpha.8   changelog section  74,454 B   -d argument  ~75,9xx B   released
  9.0.0-beta.1    changelog section 200,185 B   -d argument  201,610 B   E2BIG

The jump is structural, not bad luck: a beta or final release aggregates every
prerelease section since the last stable tag, so 9.0.0 final will be at least as
large. Reproduced both shapes against the real notes in alpine:3.20 --
inlined gives exit 126 "Argument list too long", `-d @file` execs curl and
reaches the network with a 201,620-byte payload.

The PATCH branch four lines below had always written a file and used
`-d @patch.json`; only the POST branches inlined. Both now build the payload
with python (values via env, so nothing is spliced into shell or JSON) and pass
`-d @<file>`.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
With the body out of argv the `mirror` job's POST now execs, and GitHub answers
422 `body is too long (maximum is 125000 characters)` instead -- the same wall,
one layer further in. 9.0.0-beta.1's notes are 200,185 characters.

Forgejo has no equivalent cap: `Release.Note` is a TEXT column and Forgejo
truncates only `Title`, at 255. The instance is the proof -- 9.0.0-alpha.8's
stored body is 75,918 bytes, past MySQL TEXT's 65,535, so that database is not
the constraint either. So the canonical Forgejo release keeps every character
and only the mirrored copy is cut.

Truncation happens in `Fetch release notes from Forgejo`, the step that exists
solely to produce the GitHub-bound copy. It cuts on a line boundary so no
markdown link is severed, and appends a pointer to the Forgejo release that has
the rest.

Measured against the real 9.0.0-beta.1 notes by running the step's script as the
runner would see it: 200,185 -> 124,935 characters, ending on a complete
changelog entry followed by the notice.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The `release` step's own comment names re-running this workflow as "the ordinary
way to repair a release whose `maven` or `docker` job failed", and the reuse
branch above was written so a second POST would not die. The asset loop below it
was not: it uploaded all twelve unconditionally.

Forgejo does not save it. `CreateReleaseAttachment` in
routers/api/v1/repo/release_attachment.go passes the name straight to
`UploadAttachment` with no existence check -- and nothing else enforces
uniqueness either; `Release` truncates only `Title`, at 255 characters. So the
re-run does not fail on a duplicate name, which would at least be visible. It
attaches a second copy of every asset and reports success.

The `mirror` job has guarded its loop with a `have.txt` name list since it was
written, for exactly this reason on the GitHub side. The job that publishes the
canonical release now does the same, against
`GET /releases/{id}/assets` (verified against release 1734: 12 names, HTTP 200).

Exercised the loop against that real name list with a mixed asset directory:
three existing names skipped, one new name uploaded.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`.claude/rules/ci-workflows.md` already recorded this as outstanding: "the
`release`, `virustotal` and release-body-update steps still use `curl -sf` and
have the same blindness waiting for them". `-f` sets exit 22 and `-s` discards
the body, which is the only place these APIs say what is wrong -- and the body
is what distinguishes a rejected token from a rejected payload.

9.0.0-beta.1 is a fresh example of the cost one layer down: the job reported a
python JSONDecodeError traceback, which points at the parser, while the actual
line -- `/usr/bin/curl: Argument list too long` -- was a consequence of curl
never running at all.

Converted to the `-o file -w '%{http_code}'` shape the `mirror` job has used
since it was written: the `release` job's create, notes-refresh and asset
upload; the VirusTotal release-body read and PATCH; and the Discord post. Each
prints `::error::` naming the status and the operation, then the body.

Two `-sf` calls remain on purpose, both VirusTotal submissions: their failures
are explicitly tolerated (`|| true`, then a guard on the empty id) because a
scan that does not come back must not fail a published release. The rule file
now says that instead of listing work already done.

Every `run:` script in the workflow passes `bash -n` with the `${{ }}`
expressions stubbed (16 steps, 0 errors).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The step escaped the version's dots in the shell -- `sed 's/\./\\./g'` -- and
handed the result to `awk -v ver=...`. awk processes escape sequences in a `-v`
assignment, so `9\.0\.0-beta\.1` arrives inside the program as `9.0.0-beta.1`
and the dots have been wildcards the whole time. Every release log says so:

    awk: warning: escape sequence `\.' treated as plain `.'

The comment above the line claimed the dots were escaped, which is the failure
mode this repo's conventions single out -- a shell template fails silently,
producing a plausible value rather than an error. Nothing broke in practice
because the `\]` anchor does the real work, but the guard was not the guard it
was documented to be.

Escaped inside awk instead, as `[.]` rather than `\.`: POSIX leaves a backslash
in a gsub replacement undefined for anything but `&` and `\\`, and gawk bears
that out -- a program-text replacement of `"\\\\."` yields `9\\.0\\.0-beta\\.1`,
two backslashes, and the regex then matches nothing. A bracket expression has no
such ambiguity.

Verified under gawk in alpine:3.20 against six real CHANGELOG.md revisions
(8.1.0, 8.1.1, 8.1.2, 9.0.0-alpha.8, 9.0.0-alpha.9, 9.0.0-beta.1), running the
step's own script as the runner renders it:

  - byte-identical sections to the old shape in all six (14,396 / 2,844 / 8,111
    / 74,454 / 18,747 / 200,185 bytes)
  - stderr 56 B -> 0 B; the warning is gone
  - and the escaping now bites: given a heading `## [9X0Y0-betaZ1]`, the old
    shape collects its body, the new one collects nothing

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Both defects were latent from this workflow's first commit and survived eight
releases, because the input only crossed the threshold when the channel changed
from alpha to beta -- a beta or final changelog section aggregates every
prerelease since the last stable tag. The next one to cross it is 9.0.0 final,
so the numbers and the reasoning belong somewhere a future session reads before
touching the file, not only in the commits that fixed them.

Also records the Forgejo asset-uniqueness finding and the reading lesson from
the run log: the JSONDecodeError traceback is the consequence, and the line
above it naming a binary that could not be exec'd is the cause.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Nested backtick and apostrophe escaping made the quoted gawk warning harder to
read than the rule it was explaining. Comment text only; the extract step still
produces byte-identical sections for 8.1.0, 8.1.1, 8.1.2, 9.0.0-alpha.8,
9.0.0-alpha.9 and 9.0.0-beta.1 under gawk, with empty stderr.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`CHANGELOG.md` is semantic-release's and must not be hand-edited, so without
this file the only copy of these notes is the release body on two forges.

Provenance. Run 536 never created a release, so both were built by hand from
CI artifact 712 (897,155,382 B, 12 entries, all 11 SHA-256s verified against
its own checksum.txt before upload). This text is the body of Forgejo release
1735 and GitHub release 391643508, which are byte-identical at 18,245
characters. The file differs from them in one way: it omits the `## VirusTotal`
section, because those nine permalinks are generated per release by the
pipeline's own step and belong on the release page, not in the repository.

Written rather than generated. The raw section for this tag is 1,121 entries,
of which 297 are docs commits and 277 tests; the audience for a release body is
modpack creators, so the prose covers breaking changes, features and fixes and
links the full list.

Four claims were checked and left out because they are false or unverifiable:

  - an aarch64 AppImage in the release -- spc.install4j defines exactly three
    media (154 Windows, 159 Unix, 158 macOS) and the AppImage jobs are in
    devbuild.yml, confirmed against the artifact's 12 entries
  - a download-size reduction -- the 274.7 -> 77.8 MB jar shrink happened
    within the 9.0.0 alphas; against 8.1.2 every download grew (Linux installer
    164,164,895 -> 260,126,652 B)
  - a Java version bump -- JavaLanguageVersion.of(21) at both 8.1.2 and HEAD,
    and updates.xml bundles 21.0.4
  - a downloadable grinder or grinder plugin -- neither is a release asset

Numbers that did survive were measured here, not quoted: the shipped fallback
clientside list goes 496 -> 548 entries between 8.1.2 and this tag, and 8.1.2's
`configured-` line really was missing its trailing comma, welding it to
`connectedness-` so that neither mod could ever match.

One correction after publishing, caught by Griefed: the intro presented the
boot-verification tooling as something 9.0.0 gives you, without saying it is
command-line only. The engine does ship -- BOOT-INF/lib/serverpackcreator-
clientside-9.0.0-beta.1.jar, with ScanCommand, ClientsideReportCommand,
VerifyClientsideCommand and ClientsideApplyCommand -- but there is no GUI
surface at all; the only scanner in the gui package is LarsonScanner, the
progress widget. The notes now separate the two halves by how they reach a
user, and say plainly that the second has no GUI. Both release bodies were
patched; this file is the corrected text.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`serverpackcreator-api` has never been usable from Maven Central. The
`mavenJava` publication declared exactly one thing --
`artifact(tasks.named("javadocJar"))` -- and no `from(components["java"])`, so
it had no main artifact and no dependencies. Gradle writes
`<packaging>pom</packaging>` for such a publication, which is precisely what is
on Central:

  7.3.0 8.0.0 8.1.0 8.1.2 9.0.0-alpha.6 9.0.0-alpha.9 9.0.0-beta.1
  -> every one ships `-javadoc.jar` + `.pom`, and nothing else

An embedder writing `de.griefed.serverpackcreator:serverpackcreator-api`
resolved a POM and a javadoc jar and no classes, with no transitive
dependencies. The root CLAUDE.md calls this module's public surface "a
compatibility constraint -- plugins compile against it"; from Central they could
not.

The javadoc half compounded it. `withJavadocJar()` registers Gradle's stock
`javadocJar`, which zips the stock `javadoc` task -- and this module is pure
Kotlin, so `javadoc` documents nothing and that jar is a 261-byte manifest.
Worse, `dokka-conventions` registers `dokkaJavadocJar` with the SAME `javadoc`
classifier, so both wrote `build/libs/<name>-<version>-javadoc.jar` and
whichever ran last won. That is the whole explanation for a contradiction that
looked impossible: the release ASSET javadoc jar is 261 bytes (the `assets` job
runs `build`, which ran the stock task) while Central's is 2.4 MB (the `maven`
job runs `:serverpackcreator-api:dokkaJavadocJar` first). `-app` never applied
publishing-conventions, has no stock task, and so has always shipped a 5 MB
javadoc jar -- the contrast that located this.

So: `from(components["java"])` for the main jar, the sources jar and the
dependency list; `withJavadocJar()` removed so the empty task cannot exist or
collide; and the Dokka jar attached from `dokka-conventions`, which is the
plugin that owns it -- publishing-conventions is applied first, so a
`tasks.named("dokkaJavadocJar")` there would resolve a task that does not exist
yet.

Measured with `publishMavenJavaPublicationToMavenLocal` (signing temporarily
neutralised for the probe, reverted before this commit):

  before   -javadoc.jar 261 B / 2 entries, .pom 1,306 B, packaging=pom,
           0 <dependency>, no .module
  after    .jar 4,314,699 B / 197 classes (ApiWrapper, ApiProperties,
           ApiPlugins ...), -sources.jar 4,036,536 B / 848 entries,
           -javadoc.jar 2,449,329 B / 473 entries, .module 9,176 B,
           .pom 5,430 B, 20 <dependency>

No source API changed, so nothing moves for a plugin that already compiles
against a local build. What changes is that resolving the coordinate now yields
something that works. Recorded in claude-docs/API-BEHAVIOUR-CHANGES.md.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`tasks.build { finalizedBy(tasks.dokkaGeneratePublicationJavadoc) }` ran the
generator and stopped there: Dokka wrote 208 files into `build/dokka` and
nothing ever packaged them. The release's `assets` job takes its javadoc asset
from `build/libs` (`cp serverpackcreator-api/build/libs/*.jar`), so the asset
was whatever else had written that path -- the stock empty jar, before the
previous commit removed it.

`-app` has always finalised on `dokkaJavadocJar` rather than the generator, and
that is exactly why its javadoc asset is 5 MB while `-api`'s was 261 bytes.
This matches it.

Measured on `./gradlew -Pversion=9.9.9-repro :serverpackcreator-api:build`,
build/libs and build/dokka cleaned first:

  before   -javadoc.jar 261 B / 2 entries, 208 files stranded in build/dokka
  after    -javadoc.jar 2,449,323 B / 473 entries

The release `maven` job's explicit `:serverpackcreator-api:dokkaJavadocJar` is
now redundant rather than load-bearing, and is left alone: it is harmless, and
removing it would couple this fix to a workflow change.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`8c18001f1` deleted `Confidence` (HIGH/MEDIUM/LOW/INCONCLUSIVE) and the
`BootResult × Confidence` pairing it sat in, replacing both with `Verdict`
(CONFIRMED/CLEAR/ERROR/INCONCLUSIVE, later plus LOCKED/UNVERIFIABLE). The
user-facing docs were never updated, so a modpack creator reading them was
told about a scale the software no longer has.

Checked every claim against the source of record rather than against the
changelog:

  Verdict.kt                       the six states and what each means
  VerdictQuery.VERDICT_RANK        CONFIRMED 0, INCONCLUSIVE 1, ERROR 2,
                                   LOCKED 3, UNVERIFIABLE 4, CLEAR 5
  BootCandidateSelector            pickGrindTargets: one target per Minecraft
                                   version-line, under the first of
                                   LOADER_PRIORITY that line can boot
  ClientsideVerifier.report        the CLI path calls pickGrindTargets too
  ClientsideVerifier.supersededBy* keeps bootResult, falls back to the
                                   metadata-only verdict
  Grinder.kt                       the real "Done" log line carries the
                                   Minecraft line and the boot result

That fourth one is why this is more than a find-and-replace. `-clientsidereport`
and `-verifyclientside` were documented as reporting "a confidence per
modloader" and booting "once per modloader", and the axis changed under them:
both go through `ClientsideVerifier.report`, which selects per Minecraft
version-line. Renaming the scale and leaving the axis would have produced
wrong docs that merely used current words.

Changed:
  clientside/README  §3 per-modloader -> per Minecraft version-line; §4 the
                     CRASHED->HIGH / SURVIVED->MEDIUM table replaced by the six
                     verdicts, each with what it means and what it does NOT mean
  grinder/README     result-table ordering; "only HIGH is decisive" and "only
                     HIGH is ever published" -> CONFIRMED, with why it is one
                     condition now; the superseded-crash paragraph; the sample
                     worker log, which was stale in shape as well as scale; the
                     live-audit comment
  VerifyClientsideCommand KDoc  "HIGH confidence" -> CONFIRMED, per-loader ->
                     per-target, and it still promised the embedded headless
                     browser that 04e746aba removed -- a locked file now reports
                     LOCKED and is verified from Modrinth
  Grinder.kt         one stale "confidence" in a comment beside the log line

Deliberately NOT changed, having verified each is current:
  clientside/module.md:73   describes the replacement as history, correctly
  clientside/module.md:116  BootLogClassifier really does still return
                            BootResult (SURVIVED/CRASHED/INCONCLUSIVE) -- that
                            enum survived; only Confidence was deleted
  FallbackPropertiesRenderer  its `Confidence.HIGH` is in a comment explaining
                            why there is now one condition, not two
  grinder/README:163        "per modloader and per category" is the CurseForge
                            CRAWL partitioning, a genuinely different axis

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
8c18001f1 replaced Confidence (HIGH/MEDIUM/LOW/INCONCLUSIVE) with Verdict, and the user-facing docs
were never told: a modpack creator reading them was given a scale that no longer exists, and a
per-modloader axis the engine stopped using when grinding moved to one target per Minecraft
version-line.

Re-verified against the current tree before merging, not trusted from 2026-09-13:

  Verdict.kt                     six states -- CONFIRMED, CLEAR, ERROR, INCONCLUSIVE, LOCKED,
                                 UNVERIFIABLE
  VerdictQuery.VERDICT_RANK      CONFIRMED 0, INCONCLUSIVE 1, ERROR 2, LOCKED 3, UNVERIFIABLE 4,
                                 CLEAR 5 -- the order the grinder README's table now states
  BootCandidateSelector          LOADER_PRIORITY is still NeoForge, Forge, Fabric, Quilt,
                                 LegacyFabric, and pickGrindTargets still yields one target per line
  ClientsideVerifier:67          the CLI report path goes through pickGrindTargets, so
                                 -clientsidereport really is per version-line
  supersededByTarget             keeps bootResult and the crash excerpt, falls back to the
                                 metadata-only verdict -- exactly what the README paragraph claims

Docs, one KDoc and one comment. No behaviour.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
serverpackcreator-api has never been usable from Maven Central. The mavenJava publication declared
one artifact -- the javadoc jar -- and no from(components["java"]), so it had no main artifact and no
dependencies, and Gradle wrote <packaging>pom</packaging> for it.

Verified against Maven Central itself before merging, rather than taken from the branch's notes:
repo1.maven.org's directory for 9.0.0-beta.1 holds exactly serverpackcreator-api-9.0.0-beta.1.pom and
-javadoc.jar, and that POM carries <packaging>pom</packaging> and zero <dependency> entries. An
embedder resolving the coordinate the API compatibility policy exists to protect got a POM and a
javadoc jar and no classes.

The second commit fixes the other half: -api finalised build on the Dokka *generator*, which wrote 208
files into build/dokka and never packaged them, so the release assets job -- which copies from
build/libs -- shipped whatever else had written that path. That was Gradle's stock javadocJar, an empty
261-byte manifest colliding with Dokka's on the same classifier. withJavadocJar() is gone so the
collision cannot recur, and both landmines are written down where the next person will hit them.

The branch's measurements were re-run on this tree rather than trusted, since it was cut on 2026-09-13
and -api has grown since:

  POM            5,430 B, 20 <dependency>, no <packaging> -- identical to the branch's figure
  main jar       4,337,778 B / 201 classes (branch: 4,314,699 / 197), carrying ApiWrapper,
                 ApiProperties and ApiPlugins
  sources jar    4,051,432 B / 849 entries (branch: 4,036,536 / 848)
  javadoc jar    2,473,615 B / 477 entries (branch: 2,449,329 / 473) -- and not the 261-byte stub

Signing blocks publishMavenJavaPublicationToMavenLocal without a key and cannot simply be excluded, as
the .asc artifacts stay declared; generatePomFileForMavenJavaPublication plus the three jar tasks
answer the same question without one.

Conflict in API-BEHAVIOUR-CHANGES.md was both sides appending a row to the same table; both kept.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
merge: the release body travels in a file, and the release job says why it failed
All checks were successful
Continuous / Build JAR (push) Successful in 21m17s
Documentation / Writerside webhelp (push) Successful in 2m23s
Docker Test / build image (push) Successful in 24m32s
Docker Test / build image (pull_request) Successful in 23m48s
Qodana / scan (push) Successful in 14m52s
Continuous / Build AppImage (x86_64) (push) Successful in 1m44s
Continuous / Build AppImage (aarch64) (push) Successful in 1m37s
Continuous / Build Install4J Media (push) Successful in 8m20s
Test / build (pull_request) Successful in 34m3s
Qodana / notify (push) Successful in 32s
Documentation / Help image (push) Successful in 5m26s
Test / build (push) Successful in 33m31s
Continuous / Continuous Pre-Release (push) Successful in 5m50s
d97a6e188d
Eight commits fixing what killed 9.0.0-beta.1 (run 536, job `Forgejo release`): the release JSON,
changelog section included, was assembled into a single `-d "{...}"` shell word, and Linux caps one
argv entry at MAX_ARG_STRLEN = 131,072 bytes whatever ARG_MAX says. The section had grown from
74,454 B at 9.0.0-alpha.8 to 200,185 B, because a beta or final aggregates every prerelease since the
last stable tag -- so 9.0.0 final will be at least as large, and this was not bad luck.

With the body out of argv the mirror job hits GitHub's own 125,000-character cap, so the copy bound
for GitHub is truncated on a line boundary with a pointer to the Forgejo release that keeps every
character. Re-running the workflow to repair a failed release no longer attaches a second copy of all
twelve assets, because Forgejo's CreateReleaseAttachment enforces no uniqueness and reports success.
And `curl -sf` is gone from the steps that must explain themselves: the JSONDecodeError traceback in
run 536 pointed at a parser, while the cause was a line above it saying curl could not be exec'd.

Verified on the merged result rather than on the branch, since develop's AppImage work landed in this
same file after the branch was cut:

  merge          clean; `Build AppImages` and the ServerPackCreator-* globs both survive
  YAML           parses; 8 jobs
  bash -n        17 run: scripts with the ${{ }} expressions stubbed, 0 errors
  awk extraction re-run under gawk in alpine:3.20 against this repository's CHANGELOG. For
                 9.0.0-alpha.6 the old and new forms produce byte-identical output (40,347 chars),
                 but the old writes `gawk: warning: escape sequence \. treated as plain .` to stderr
                 and the new writes nothing -- which is the finding: the dots were wildcards all along
                 and only the ] anchor was doing the work.

Also keeps the hand-written 9.0.0-beta.1 notes in the repository, since CHANGELOG.md is
semantic-release's and must not be hand-edited, and records both limits in the CI rules file so the
next person to touch the release job reads them before 9.0.0 final crosses the same thresholds.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Closes B39. The scan ran against a raw checkout, so i18n4k's generated
`Translations` and `Example` objects did not exist during analysis. 85 source
files reference one of them, 79 of those in -app, and a file with an unresolved
core symbol is analysed with its inspections DEGRADED -- so the low finding
count for -app was not evidence that -app was clean, it was evidence that the
scanner could not read it.

Measured, per the build-logic rule in the root CLAUDE.md:

  sanity failures, run 817:  35  (30 Translations + 5 Example)
  sanity failures, run 832:  35  (30 Translations + 5 Example)
                                 -app 24 / -api 6 / -plugin-example 5
  sanity failures, after:    to be read off the next run; the target is 0

  codegen cost, clean checkout, --rerun-tasks --no-build-cache:  23.0 s
  codegen cost, clean checkout, build cache warm:                16.4 s

Run 832 scanned revision d97a6e188 -- this branch's base -- so the 35 is a
measurement of exactly the tree this step is being added to, not of an older
one. If the next run does not move it, revert this rather than keep a slower
job, which is what B39 asked for.

`generateI18n4kFiles` resolves in -api and -plugin-example and `kaptKotlin` in
both plugin modules: exactly the three modules the failures came from. Not
`build` -- the scan needs the generated sources, not the artifacts, and a full
build would cost the frontend, the license report and the suites for nothing.
kaptKotlin does drag in :serverpackcreator-api:compileKotlin, which is a feature
rather than a cost: -app is then analysed against a compiled -api.

The scan step's landmine said this could not be seen; it now says the opposite
and names what breaks if the step is removed or reordered. The count step
reports the sanity failures beside the problem count, because the problem count
alone cannot tell a clean scan from a broken one -- degradation LOWERS it, so a
broken scan reads as a clean report. An absent sanity.json is reported as
unknown rather than as zero, since those are opposite states.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
B38, first half. Twelve of run 832's 28 problems are decisions already taken and
re-taken: they were triaged from run 817, and every one of them came back
unchanged. Until now those verdicts lived only in a chat log and in a backlog
table, so the tool kept reporting them and every reader re-litigated them.

  UnusedSymbol          2  false positive -- @Component beans injected by
                           interface; QDJVM Community has no Spring plugin and
                           cannot see the wiring. WebServiceContextTest asserts
                           both resolve from the context.
  UnstableApiUsage      4  won't fix -- FAIL_ON_PROJECT_REPOS is @Incubating
                           with no stable alternative.
  ConvertLongToDuration 1  MUST NOT FIX -- changes an exported parameter type on
                           a Maven Central artifact, against the API
                           compatibility policy.
  RedundantIf           5  won't fix -- five explained early-return guards.

Each entry is scoped to the exact files it was decided for, never to a whole
rule. An over-broad exclusion silences a rule everywhere and is worse than the
noise it removes, so a new occurrence of any of these four rules in any other
file still reports.

Verified before committing, since a name or path that matches nothing fails
silently and looks identical to a working exclusion: all nine paths exist in the
tree, and replaying the nine (ruleId, uri) pairs against run 832's SARIF covers
exactly 12 of the 28 findings and leaves exactly the 16 style notes. The
confirmation on the next run is therefore the count going 28 -> 16 precisely,
not merely going down.

The baseline, B38's other half, deliberately does NOT land here. It would
suppress all 28 at once and so destroy the only evidence that these twelve
exclusions work; and a baseline taken now would be built from a scan that could
not fully read -app, which the preceding commit is what fixes. It is worth
taking against the first run that is both trustworthy and confirmed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Run 832 was the run both items were waiting for, so both are resolved as far as
this branch can resolve them, and what is genuinely left is re-stated rather
than left implied.

B39 closed. B38 closed by half -- the twelve verdicts are encoded; the baseline
is now B40, because a baseline landing in the same run as the exclusions
suppresses all 28 problems at once and so destroys the only evidence that the
exclusions match anything, and because a baseline built from run 832 would
encode the finding set of a scan that could not fully read -app. B40 names the
two job-summary lines that say when it is safe to take: `Qodana problems: 16`
and `Qodana sanity failures: 0`.

Per the backlog's own rule both landed items are deleted there rather than
marked done, and the narrative goes to REFACTOR-LOG.md. The next-ID pointer
moves to B41.

The log entry records the lesson rather than the diff: a file whose core symbol
does not resolve is analysed with its inspections degraded, so the defect LOWERS
the problem count and a broken scan reads exactly like a clean repository. Same
shape as the KDocUnresolvedReference finding already in the root CLAUDE.md --
a tool's count is a function of its reach, and reach has to be measured
separately from findings.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The public report had 502'd on every path since at least 18 September. It was
diagnosed twice, wrongly, and the second diagnosis shipped code -- so three
files now describe a cause that was never the cause, which is worse than
describing nothing.

WHAT THE EVIDENCE LOOKED LIKE. 2026-09-19: `/`, `/status`, `/dashboard` and a
nonexistent path all 502'd at 131.3 s while port 80 answered in 0.18 s.
`/status` does no store work, so this was read as thread starvation, and
`148ccb385` shipped VerdictSnapshotCache plus a pool of 4. Re-measured
2026-09-21: 130.18 / 131.07 / 131.05 s, all 502. Unchanged by a fix aimed
straight at it. A cleared store changed nothing either.

THE FLAW IN THE REASONING, which is what these edits preserve. A cheap endpoint
also failing rules out *a slow page*. It does not distinguish "no thread is
free" from "the request never arrived" -- different layers, identical signature
from outside. Both readings fit every observation; the one about code we own won.

WHAT SETTLED IT. `curl http://127.0.0.1:9090/dashboard` on the host: 200 in
0.368 s. `/dashboard` is a compile-time constant, so that one line exonerates
the daemon. Then a thread dump, where the evidence was an ABSENCE:
newFixedThreadPool creates workers lazily and never retires core threads, so
zero `pool-*` threads means zero requests ever reached a handler -- in 2.9 h of
uptime. HTTP-Dispatcher idle in EPoll.wait on 477 ms of CPU, heap 330 MiB of
1.1 GiB, no BLOCKED thread.

THE CAUSE. A containerised nginx dialled the Docker bridge gateway
172.19.0.1:9090; the grinder binds 0.0.0.0 so the socket was listening, but the
host firewall dropped the SYN. The proxy log had said so throughout:
`connect() failed (110: Operation timed out)`. 110 is a dropped packet, 111
would have been a wrong bind -- and the 131 s that read as an application hang
is Linux's tcp_syn_retries=6 budget, ~127 s.

A TRAP FOUND ON THE WAY OUT, and the reason the README now shouts about it:
port 9090 was unreachable from the internet too, by the same rule. With
SPC_GRINDER_HOST=0.0.0.0 and no authentication on the report, that firewall was
the only thing keeping the verdict table and full CSV export private. `ufw allow
9090` would have ended the outage and published the report in one command.

The README gains the one-line host test as the FIRST thing in "Report
responsiveness", plus an errno table that routes 111 to the bind and 110 to the
firewall; "Exposing the report" gains the half it was missing, since it
documented only the refused case. The unit file stops pointing at
SPC_GRINDER_HTTP_THREADS as the thing to reach for. The module CLAUDE.md
landmine that asserted thread starvation was proven now says why that inference
was invalid.

Docs only; no production code touched. Grinder suite re-run: 537 tests, 0
failures, 29 skipped, with SystemdUnitConfigurationTest and
ReadmeConfigurationTest green -- both read these files directly.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Behaviour-preserving seam, landed on its own so the guard that follows is a real
red pin rather than one that cannot compile.

`JdkHttpFetcher.client` goes from `private` to `internal`. Nothing outside this
module can see it either way -- Kotlin `internal` is module-scoped, and the test
source set is a friend module -- so no call site changes and no behaviour does.

The property worth pinning is that default-constructed fetchers SHARE a client,
and identity is the only honest way to observe that. Counting selector threads
would be flaky and asserting on a mock would assert nothing.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Committed RED, and run before committing so the red is the missing
implementation rather than the guard's own fault:

  manyFetchersCollapseToOneClient  FAILED
    25 default-constructed fetchers produced 25 distinct HttpClients
      ==> expected: <1> but was: <25>
  defaultFetchersShareOneClient    FAILED
      ==> expected: <HttpClientImpl@4c29f8aa(27)>
           but was: <HttpClientImpl@122b07f1(28)>
  anExplicitlySuppliedClientIsStillHonoured  PASSED

Note the `(27)`/`(28)` in that failure: it is the JDK's own per-client counter,
the same one that names the `HttpClient-N-SelectorManager` threads. The third
test passes already and must keep passing -- the fix must not swallow a
caller-supplied client, which every test faking the transport relies on.

WHY THIS IS A DEFECT AND NOT A STYLE NOTE. The JDK documents the alternative as
the anti-pattern in as many words: "Creating a new client for each operation,
though possible, will usually prevent reusing such connections." Each client
carries its own connection pool and its own SelectorManager thread, so a fetcher
per candidate re-does the TLS handshake to Modrinth/CurseForge that the previous
candidate had already paid for.

MEASURED, on the live daemon 2026-09-21: 384 clients created in 2.9 hours, 28
still alive in the thread dump. The path is
ContainerCandidateVerifier.verifyStaged -> supportedPlatforms() per candidate ->
a ModrinthPlatform and a CurseForgePlatform -> a default JdkHttpFetcher each.

Sharing is safe, checked rather than assumed: the CurseForge key travels as an
`x-api-key` REQUEST header through HttpFetcher.get(url, headers), never as
client state, and HttpClient is documented immutable and thread-safe.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Turns the previous commit's pin green. `JdkHttpFetcher`'s default client is now
a single `SHARED` instance rather than a fresh build per construction.

  before: 25 default-constructed fetchers -> 25 distinct HttpClients
  after:  25 default-constructed fetchers -> 1

WHAT IT COST IN PRODUCTION. `ContainerCandidateVerifier.verifyStaged` calls
`supportedPlatforms()` per candidate; each call builds a ModrinthPlatform and a
CurseForgePlatform, each default-constructing a fetcher. On the live daemon on
2026-09-21 that was 384 clients in 2.9 hours, 28 still alive in the thread dump.
Each one is a private connection pool -- so every candidate re-paid the TLS
handshake to Modrinth and CurseForge that the previous candidate had already
done -- plus a SelectorManager thread that lives until the client is collected.

The JDK names this exact shape as the anti-pattern: "Creating a new client for
each operation, though possible, will usually prevent reusing such connections."

WHY SHARING IS SAFE, verified rather than assumed:
  - HttpClient is documented immutable and thread-safe.
  - Neither platform puts credentials on the client. The CurseForge key travels
    as an `x-api-key` REQUEST header through `HttpFetcher.get(url, headers)`, so
    two platforms sharing a client cannot leak one's key into the other's calls.
  - A caller-supplied client still wins -- `anExplicitlySuppliedClientIsStillHonoured`
    was green before this change and stays green, which is what keeps every test
    that fakes the transport working.

`SHARED` is deliberately never closed: it is process-wide with no lifecycle
shorter than the JVM's, closing it would race whatever is mid-request, and there
is now exactly one of it rather than hundreds.

Suites: clientside 671 (668 + this commit's 3), grinder 537 (29 skipped),
app 168 -- 0 failures across all three, no new compiler warnings.

NOT claimed as the cause of the report outage. That is still unproven; this is a
defect found while looking, fixed on its own evidence.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The defect read as harmless -- a `HttpClient.newBuilder()…build()` default
parameter -- which is exactly why it survived: nothing about the call site looks
like a resource being allocated. The landmine records what makes it expensive
(a connection pool and a SelectorManager thread per instance), what multiplies
it (supportedPlatforms() per candidate, so two per candidate rather than two per
process), and the measurement that made it concrete.

It also records the two things a future reader needs and cannot re-derive
cheaply: why sharing is SAFE -- the CurseForge key is a request header, not
client state -- and what is still NOT shared, since supportedPlatforms() remains
a factory that builds fresh platform objects on every call.

Root CLAUDE.md clientside count 668 -> 671, re-derived from the test-results XML
rather than by adding 3 to the old figure.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Behaviour-preserving seam, landed alone so the guards that follow are real red
pins rather than ones that cannot compile.

`VerdictSnapshotCache` gains `maxAge` and an injected `clock`. `maxAge` defaults
to `Duration.ZERO` and the coalescing branch is guarded by `maxAgeNanos > 0`, so
today's behaviour -- rebuild whenever `VerdictStore.version` moves -- is exactly
what a default-constructed cache still does. Every existing guard is untouched
and green (VerdictSnapshotTest, ReportServerTest, VerdictsJsonEndpointTest).

The clock is injected because the alternative is a test that sleeps through a
window, and a sleeping test is a flaky test.

`maxAgeNanos` is floored at zero rather than trusted: a negative Duration would
otherwise make `clock() - builtAt < maxAgeNanos` false forever in one direction
and mean "cache for ever" in the other, which is the one failure mode a report
cache must not have.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Run before committing. THREE fail, four pass, and which is which matters:

  RED  statusReportsTheCountWithoutCopyingTheStore
         /status copied the entire store to read a count ==> expected <0> was <1>
  RED  asPropertiesReadsThroughTheSnapshotCache
         3 scans for 3 requests against an unchanged store ==> expected <true> was <false>
  RED  theDefaultWindowCoalescesRatherThanRebuildingPerVerdict
         a default-constructed cache rebuilt on the very next verdict

  GREEN aVerdictRecordedInsideTheWindowDoesNotForceARebuild
  GREEN theWindowExpiringLetsTheNextRequestSeeTheNewData
  GREEN anUnchangedStoreIsNeverRebuiltHoweverLongPasses
  GREEN aZeroWindowRebuildsOnEveryChangeAsBefore

THE FOUR GREEN ONES ARE NOT RED PINS AND ARE NOT CLAIMED AS SUCH. The preceding
seam commit carried the window mechanism, inert behind a zero default, so they
characterize what that commit added rather than demanding anything new. The only
behaviour left to change is the DEFAULT, which is what the third red pin is for.
Saying so here because a green test in a `test(...)` commit otherwise reads as a
pin that was never checked.

WHAT THE THREE RED ONES ARE ABOUT. All three are costs that grow with a
long-running grind while the page stays 250 rows, and the store has passed the
high six figures:

  - `/status` read `store.all().size` -- a full list copy to look at one integer,
    on the endpoint the dashboard polls on a timer.
  - `/as-properties` called `store.all()` directly, the one endpoint the snapshot
    cache never covered, and the one polled unattended by every SPC instance in
    the wild.
  - the cache rebuilt whenever `VerdictStore.version` moved, and `record()` moves
    it on every verdict -- so with several workers recording continuously it was
    very nearly a no-op exactly when the daemon is busiest.

They assert a COUNT OF SCANS, never a duration: a timing assertion is flaky on CI
and says nothing about why it got slow, whereas "this endpoint copied every
verdict" is the defect itself and is exactly reproducible. `CountingStore`
delegates to a real `InMemoryVerdictStore` so what is observed is the production
store's traffic rather than a reimplementation of it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Turns the three red pins green. Three separate costs, all of which grow with the
store while the page stays 250 rows, and the store has passed the high six
figures:

  1. /status read `store.all().size` -- a full list copy to look at one integer,
     on the endpoint the dashboard polls on a timer. `VerdictStore` gains `count`,
     defaulted to `all().size` so no implementation is forced to care, overridden
     by both real stores to read the backing map directly.

  2. /as-properties called `store.all()` directly. It was the ONLY endpoint the
     snapshot cache never covered, and it is the one polled unattended by every
     SPC instance in the wild -- a whole-store copy per poll by the endpoint least
     able to afford one. It now reads `snapshots.current().verdicts`, the same
     rows the table serves.

  3. The cache rebuilt whenever `VerdictStore.version` moved, and `record()` moves
     it on EVERY verdict. With several workers recording continuously there is a
     new version between almost any two requests, so during a grind the cache was
     very nearly a no-op -- it stopped working exactly when the daemon is busiest.
     `VerdictSnapshotCache` now coalesces for `SPC_GRINDER_REPORT_CACHE_SECONDS`
     (default 5).

BEHAVIOUR CHANGE, hence `perf(` and not `refactor(`. The report may now lag by up
to one window. That is the same bargain SPC_GRINDER_STORE_FLUSH_SECONDS already
makes for writes and for the same reason: whole-store work belongs on a clock,
not on an event that fires thousands of times an hour. `0` restores the old
strictly-live behaviour exactly, and is pinned as such.

TWO TEST EDITS, both disclosed rather than buried:

  - `theSnapshotIsRebuiltWhenAVerdictIsRecorded` now passes `Duration.ZERO`. Every
    assertion is byte-identical; it pins what it always pinned -- that a changed
    store invalidates the derivation -- and now names the window it assumes. The
    default-window behaviour is pinned separately.
  - `CountingStore` had to override `count` to delegate. `VerdictStore.count`
    DEFAULTS to `all().size`, so a double that inherited it counted a scan per
    count and reported the defect regardless of what production did. The double
    was incomplete; the assertion did not move.

The knob is declared in all three places a knob has to be or the documentation
guards cannot see it: `GrinderConfiguration.KNOBS`, the README table, and the
systemd unit. The reader call is on one line, like httpThreads, because
`everyVariableReadIsDeclaredAsAKnob` matches `reader("NAME"` and a wrapped call
is invisible to it.

Suite: 544 tests (537 + 7), 0 failures, 29 skipped. No new compiler warnings.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The README said the derivations are "computed once per change to the store …
so a report whose grinder is idle costs effectively nothing". Every word of that
was true and the qualifier was carrying the whole sentence: a busy grinder is the
normal case, and that is precisely when the cache stopped working, because
`record()` invalidates it on every verdict.

Corrected there, and the two endpoints that sat outside the caching entirely are
now named rather than left for the next reader to discover -- `/status`, which
copied every verdict to read one integer, and `/as-properties`, which is polled
unattended by every SPC instance in the wild.

The module landmine records the trap that actually cost a red run:
`VerdictStore.count` DEFAULTS to `all().size`, so a store double that inherits it
still copies and reports the defect whatever production does. Anyone writing the
next double needs that before they write it, not after.

Root CLAUDE.md grinder count 537 -> 544, re-derived from the test-results XML.

SystemdUnitConfigurationTest and ReadmeConfigurationTest green -- both read these
files directly, which is what keeps the new knob documented in all three places.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Run 832 reported 28 problems, and they decomposed exactly as the run-817 triage
predicted the residue would: the 12 standing won't-fix verdicts at precisely
their triaged counts, plus 16 style notes. Nothing new.

The number that mattered was the other one. Run 832 carried the SAME 35 sanity
failures as run 817 -- 30 x "Unresolved reference Translations", 5 x "Unresolved
reference Example", split -app 24 / -api 6 / -plugin-example 5 -- because the
scan ran against a raw checkout with no Gradle invocation, so i18n4k's generated
objects did not exist during analysis. 85 source files reference one of them, 79
of those in -app, and a file whose core symbol will not resolve is analysed with
its inspections DEGRADED.

That is worse than an ordinary gap in coverage, and it is why B39 existed: the
defect LOWERS the problem count, so a broken scan and a clean repository produce
the same reassuring number. The count step now prints the sanity count beside the
problem count, and an absent sanity.json reports as `unknown` rather than as
zero -- "the scanner wrote no sanity report" and "the scanner resolved
everything" are opposite states.

B39 closed with a codegen step: generateI18n4kFiles in -api and -plugin-example,
kaptKotlin in both plugin modules, exactly the three modules the failures came
from. Measured on a clean checkout, per the build-logic rule -- 23.0 s with
--rerun-tasks --no-build-cache, 16.4 s with the build cache warm, against a scan
that takes minutes.

B38 closed by half. The 12 verdicts are in qodana.yaml, each scoped to the exact
files it was decided for rather than to a whole rule, so a new occurrence
anywhere else still reports. Verified before committing, because a name/paths
pair that matches nothing fails SILENTLY and looks identical to one that works:
all nine paths exist, and replaying the nine (ruleId, uri) pairs against run
832's own SARIF covers exactly 12 of 28 and leaves exactly the 16 style notes.

The baseline did NOT land and became B40, for a reason that generalises: a
suppression that hides everything destroys the evidence that a narrower
suppression works. B38's acceptance test is the count going 28 -> 16 exactly, and
a baseline in the same run zeroes it either way. It is also the wrong order --
a baseline built from run 832 would encode the finding set of a scan that could
not read a fifth of the repository, which is what this branch fixes.

Confirmation is one run away and is two lines in the job summary:
`Qodana problems: 16` and `Qodana sanity failures: 0`.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The public report had 502'd on every path since at least 18 September. It was
diagnosed twice, wrongly, and the second diagnosis shipped code -- so three files
described a cause that was never the cause, which is worse than describing none.

WHAT THE EVIDENCE LOOKED LIKE. 2026-09-19: `/`, `/status`, `/dashboard` and a
nonexistent path all 502'd at 131.3 s while port 80 answered in 0.18 s.
`/status` does no store work, so this was read as thread starvation, and
`148ccb385` shipped VerdictSnapshotCache plus a pool of 4. Re-measured
2026-09-21: 130.18 / 131.07 / 131.05 s, all 502. Unchanged by a fix aimed
straight at it. A cleared store changed nothing either.

THE FLAW IN THE REASONING, which is what this branch preserves. A cheap endpoint
also failing rules out *a slow page*. It does not distinguish "no thread is free"
from "the request never arrived" -- different layers, identical signature from
outside. Both readings fit every observation; the one about code we own won.

WHAT SETTLED IT. `curl http://127.0.0.1:9090/dashboard` on the host: 200 in
0.368 s. `/dashboard` is a compile-time constant, so that one line exonerates the
daemon. Then a thread dump, where the evidence was an ABSENCE:
newFixedThreadPool creates workers lazily and never retires core threads, so zero
`pool-*` threads means zero requests ever reached a handler -- in 2.9 h of
uptime. HTTP-Dispatcher idle in EPoll.wait on 477 ms of CPU, heap 330 MiB of
1.1 GiB, no BLOCKED thread, no deadlock.

THE CAUSE. A containerised nginx dialled the Docker bridge gateway
172.19.0.1:9090; the grinder binds 0.0.0.0 so the socket was listening, but the
host firewall dropped the SYN. The proxy log had said so throughout:
`connect() failed (110: Operation timed out)`. 110 is a dropped packet, 111 would
have been a wrong bind -- and the 131 s that read as an application hang is
Linux's tcp_syn_retries=6 budget, ~127 s.

A TRAP FOUND ON THE WAY OUT, and why the README now shouts about it: port 9090
was unreachable from the internet too, by the same rule. With
SPC_GRINDER_HOST=0.0.0.0 and no authentication on the report, that firewall was
the only thing keeping the verdict table and full CSV export private. `ufw allow
9090` would have ended the outage and published the report in one command.

The README gains the one-line host test as the FIRST thing in "Report
responsiveness", plus an errno table routing 111 to the bind and 110 to the
firewall; "Exposing the report" gains the half it was missing, having documented
only the refused case. The unit file stops pointing at SPC_GRINDER_HTTP_THREADS
as the thing to reach for. The module CLAUDE.md landmine that asserted thread
starvation was proven now says why that inference was invalid.

Docs only; no production code. Verified on the merged result: grinder suite 537
tests, 0 failures, 29 skipped, with SystemdUnitConfigurationTest and
ReadmeConfigurationTest green -- both read these files directly.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

# Conflicts:
#	claude-docs/REFACTOR-LOG.md
merge: one HTTP client, and a report that stops walking the whole store
All checks were successful
Documentation / Writerside webhelp (push) Successful in 2m6s
Continuous / Build JAR (push) Successful in 12m47s
Docker Test / build image (push) Successful in 17m15s
Docker Test / build image (pull_request) Successful in 17m20s
Qodana / scan (push) Successful in 13m47s
Documentation / Help image (push) Successful in 2m33s
Continuous / Build AppImage (x86_64) (push) Successful in 2m8s
Test / build (pull_request) Successful in 15m39s
Test / build (push) Successful in 15m16s
Continuous / Build AppImage (aarch64) (push) Successful in 3m24s
Qodana / notify (push) Successful in 52s
Continuous / Build Install4J Media (push) Successful in 8m33s
Continuous / Continuous Pre-Release (push) Successful in 3m46s
ec99696028
Two defects found while chasing the outage, fixed on their own evidence. Neither
is claimed as the outage's cause -- that was the host firewall, merged just
before this.

ONE HTTP CLIENT. `JdkHttpFetcher`'s default parameter built a fresh `HttpClient`
per instance, and `ContainerCandidateVerifier.verifyStaged` calls
`supportedPlatforms()` once PER CANDIDATE, which constructs a ModrinthPlatform
and a CurseForgePlatform that each default-construct a fetcher. Two clients per
candidate, each a private connection pool and a SelectorManager thread, none ever
closed. Measured on the live daemon 2026-09-21: 384 clients in 2.9 hours, 28
still alive in the thread dump. The JDK names the shape as the anti-pattern in as
many words -- "Creating a new client for each operation, though possible, will
usually prevent reusing such connections" -- so every candidate re-paid the TLS
handshake the previous one had already done. Red pin: 25 fetchers -> 25 distinct
clients; after: 1. Sharing is safe for a checkable reason rather than by
assumption -- the CurseForge key is an `x-api-key` REQUEST header through
`HttpFetcher.get(url, headers)`, never client state, and a caller-supplied client
still wins, which is what every test faking the transport depends on.

A REPORT THAT STOPS WALKING THE WHOLE STORE. Three separate costs, all growing
with the store while the page stays 250 rows, and the store has passed the high
six figures:

  - `/status` read `store.all().size` -- a full list copy to read one integer, on
    the document the dashboard polls on a timer. `VerdictStore.count` now answers
    from the backing map.
  - `/as-properties` called `store.all()` directly. It was the ONLY endpoint the
    snapshot cache never covered, and it is the one polled unattended by every
    SPC instance in the wild.
  - The cache rebuilt whenever `VerdictStore.version` moved, and `record()` moves
    it on EVERY verdict -- so with several workers there is a new version between
    almost any two requests and the cache degraded to nearly nothing exactly when
    the daemon is busiest. That is the part that answers "it worked a week ago":
    the cache never scaled with an ACTIVE grind, which did not matter at 38k rows.

BEHAVIOUR CHANGE, hence `perf(` rather than `refactor(`: the report may now lag
by up to `SPC_GRINDER_REPORT_CACHE_SECONDS` (default 5). Same bargain
SPC_GRINDER_STORE_FLUSH_SECONDS already makes for writes. `0` restores the old
strictly-live behaviour exactly and is pinned as such.

Two test edits are disclosed in their commits rather than buried:
`theSnapshotIsRebuiltWhenAVerdictIsRecorded` now passes `Duration.ZERO` with every
assertion byte-identical, and `CountingStore` had to delegate `count`, because
`VerdictStore.count` DEFAULTS to `all().size` and an inheriting double reports the
defect whatever production does. Four of the seven new tests were green when
committed and the commit says so -- the seam carried the window mechanism inert
behind a zero default, so only three were real red pins.

Verified on the merged result, not on the branch, since the outage docs landed in
the same README section after this branch was cut:

  merge           one conflict, in the README's "Report responsiveness" prose;
                  both edits kept, the host-first triage box ahead of the cost table
  clientside      671 tests, 0 failures, 0 skipped
  grinder         544 tests, 0 failures, 29 skipped
  app             168 tests, 0 failures, 0 skipped
  plugin-grinder  green
  documentation   SystemdUnitConfigurationTest + ReadmeConfigurationTest green,
                  which is what keeps the new knob declared in all three places

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

# Conflicts:
#	serverpackcreator-grinder/README.md
The outage is confirmed, and the confirmation changes what the docs should say.
They described "a host firewall" dropping the proxy's packets, which was right
and was not enough: it implied a rule someone could go and look for.

  Chain INPUT (policy DROP 1072 packets, 387K bytes)

THE POLICY WAS THE WHOLE RULE. ufw was active, nothing mentioned port 9090, and
the chain policy did the dropping -- so `ufw status` showed nothing relevant,
"ufw is not the issue" read as a checked fact, and the firewall was dismissed
twice. Two further hypotheses were chased in the gap: conntrack, which was 569
entries of 262,144 and had never been close to full, and a renumbered Docker
network, whose gateway turned out to be correct and owned by the host.

That is the transferable part and it is now in all three places: "no rule for
this" and "not filtering this" are opposite statements, and only the chain policy
separates them. Never conclude a firewall is innocent from `ufw status`.

WHAT SETTLED IT, now written down as a reusable bisect rather than as a story:
three requests chosen so each traverses a different chain.

  host  -> bridge   200 in 0.077 s
  proxy -> bridge   timeout
  proxy -> peer     200 in 0.003 s

Container-to-container goes through FORWARD and was always fine, which is exactly
why nothing else on a host running thirty-odd containers had noticed -- the
grinder is the only HOST service behind a CONTAINERISED proxy, so it was the only
thing on the INPUT path. A closed port from the proxy also timed out rather than
being refused, which is what proves the drop was blanket rather than aimed at the
report. The README carries the three commands and a table mapping the three
possible outcomes to the layer at fault.

The fix command changes shape too. It was `ufw allow in on br-XXXXXXXX`, which
makes the reader look up a Docker-generated interface id that changes when the
network is recreated; it is now scoped by subnet and destination
(`from 172.19.0.0/16 to 172.19.0.1 port 9090`), which is the thing the proxy is
actually configured against. The warning against a bare `ufw allow 9090` is
sharpened with the measurement behind it: that policy is the only thing keeping
an unauthenticated verdict table and full CSV export off the public internet,
verified by port 9090 timing out from outside.

Docs only. ReadmeConfigurationTest and SystemdUnitConfigurationTest green -- both
read these files directly.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Closes the grinder report outage. The cause is confirmed rather than inferred:

  Chain INPUT (policy DROP 1072 packets, 387K bytes)

and the shape of that is why it took three attempts to find. There was no rule
about port 9090 -- the chain POLICY was doing the dropping -- so `ufw status`
showed nothing relevant, the firewall was dismissed twice as already checked, and
conntrack (569 of 262,144) and a renumbered Docker network (gateway correct, host
owns it) were both chased first.

"No rule for this" and "not filtering this" are opposite statements, and only the
chain policy separates them. That now sits in the README, the module landmine and
the root CLAUDE.md lesson, because it generalises past firewalls: when a negative
rules a layer out, ask what was actually read to rule it out.

The three-request bisect that localised it is written down as a procedure with a
table mapping each outcome to the layer at fault -- host->bridge 200 in 0.077 s,
proxy->bridge timeout, proxy->peer 200 in 0.003 s. Container-to-container goes
through FORWARD and was always fine, which is why nothing else on a host running
thirty-odd containers noticed: the grinder is the only HOST service behind a
CONTAINERISED proxy, so it was alone on the INPUT path.

The fix command is now scoped by subnet and destination rather than by a
Docker-generated bridge interface id that changes when the network is recreated,
and the warning against a bare `ufw allow 9090` carries its measurement: that
policy is the only thing keeping an unauthenticated verdict table and full CSV
export off the internet, verified by 9090 timing out from outside.

Docs only, no production code. Verified on the merged result: grinder 544 tests,
0 failures, 29 skipped, with ReadmeConfigurationTest and SystemdUnitConfigurationTest
green -- both read these files directly.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Of the sixteen notes the scan reported, these five are cases where the tool was
right and the change costs nothing:

  ModListCompiler          TreeSet<File>(...) -> TreeSet(...)        x2
  GenerationConfig         "${updateProtectedPaths}" -> "$updateProtectedPaths"
  DockerJavaContainerEngine "${OWNER_LABEL}" -> "$OWNER_LABEL"
  SelectionAttribution     when { entry in X -> } -> when (entry) { in X -> }

Behaviour-preserving, hence `refactor:`. No test assertion, argument or expected
value changed anywhere; the string templates render identical bytes, the TreeSet
element type is inferred to the same File, and the `when` subject form is the
same five branches in the same order with the same comments between them.

The other eleven were read at their call sites and refused rather than applied --
they are excluded in the following commit, each with the reason. Not applying a
lint is a decision, so it is recorded where the tool will look rather than in a
commit message nobody re-reads.

Suites, all re-run: api 460 (1 skip), clientside 671, grinder 544 (29 skip),
app 168, plugin-grinder 75. 0 failures, no new compiler warnings.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Closes B40, and not by the means B40 proposed. Its goal was "a run reports what
is NEW rather than everything"; reaching **zero** achieves that better than a
baseline, because then anything reported is new by definition and there is no
file to maintain.

WHY NOT THE BASELINE. Measured on run 785's SARIF: the file is 3.9 MB, of which
2.78 MB is JetBrains' inspection catalog and 22 KB the actual findings. That is
99.4% vendor metadata committed to the repository to suppress sixteen style
notes, re-churned on every re-baseline. A hand-trimmed SARIF might work -- the
results do carry partialFingerprints -- but it could not be verified here (Docker
Desktop is still capped at 1.9 GiB and the linter is OOM-killed), and an
unverifiable config change is exactly the trap B38 existed to avoid.

Each of these was read at its call site. The five the tool was right about are
fixed in the preceding commit; these eleven are refused, scoped to the exact file
each was decided for:

  UsePropertyAccessSyntax   1  FALSE POSITIVE, and the code already said so --
                               g2d.renderingHints is read-only in Kotlin, so the
                               suggestion DOES NOT COMPILE. Someone tried before.
  RemoveExplicitTypeArguments 2 both pin a type inference would narrow. The two
                               that pinned nothing were fixed instead.
  RedundantInterpolationPrefix 1 escaping-heavy regex, deduplicated because two
                               copies had drifted and lost a migration.
  UnnecessaryVariable       1  `annotating` is read 11x and means what `fired` no
                               longer does -- the rule stopped deciding.
  ConvertToStringTemplate   1  key construction; its sibling verdictKey spells the
                               marker the same way. Change both or neither.
  ConvertCallChainIntoSequence 2 an unmeasured perf change over tens of elements,
                               where Sequence is a pessimisation. B30 is the receipt.
  DestructuringDeclaration  2  binds a domain object's fields POSITIONALLY, so
                               re-ordering the data class would silently rebind
                               rather than fail to compile.
  JoinDeclarationAndAssignment 1 one of three metas assigned in an ordered init, in
                               -api's central published type.

Verified before committing, the same way B38 was: all 19 inspection-scoped paths
exist, and replaying every (ruleId, uri) against run 785's SARIF accounts for all
16 findings -- 11 excluded, 5 fixed in code, 0 unaccounted. Projected next run:
0 problems, 0 sanity failures.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
B40 is deleted from the backlog per its own rule -- landed items go to
REFACTOR-LOG.md rather than being marked done -- and the header now records B38,
B39 and B40 as issued-and-gone, so a future citation of any of those three
resolves to something rather than looking like a numbering gap. The next-ID
pointer already read B41 and is unchanged.

The log entry carries what a future reader cannot re-derive: that the baseline
was measured and rejected (3.9 MB, 99.4% vendor catalog, unverifiable locally),
that B40's GOAL was met more completely by reaching zero, and the per-note split
that made it possible -- five right, eleven wrong.

The refusal worth remembering is LarsonScanner: the code already carried a
comment saying the suggested fix does not compile, and the tool reported it
anyway, because a linter cannot read the comment explaining why it is wrong.
That is the argument for encoding a verdict in qodana.yaml rather than in prose.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
merge: the Qodana report reaches zero, without a baseline
All checks were successful
Continuous / Build JAR (push) Successful in 12m34s
Documentation / Writerside webhelp (push) Successful in 44s
Docker Test / build image (push) Successful in 18m24s
Docker Test / build image (pull_request) Successful in 18m23s
Test / build (pull_request) Successful in 16m8s
Qodana / scan (push) Successful in 13m47s
Continuous / Build AppImage (x86_64) (push) Successful in 2m46s
Test / build (push) Successful in 17m51s
Continuous / Build AppImage (aarch64) (push) Successful in 4m5s
Qodana / notify (push) Successful in 19s
Documentation / Help image (push) Successful in 4m13s
Continuous / Build Install4J Media (push) Successful in 10m30s
Continuous / Continuous Pre-Release (push) Successful in 4m5s
4be89ab678
Closes B40, and deliberately not by the means B40 proposed.

Run 785 confirmed both of B38/B39's gates against revision ec9969602 -- 16
problems, 0 sanity failures, exactly as predicted -- which unblocked B40's
baseline. The baseline then did not survive contact with its own evidence: that
run's SARIF is 3.9 MB of which 2.78 MB is JetBrains' inspection catalog and 22 KB
the actual findings. That is 99.4% vendor metadata committed to the repository to
suppress sixteen style notes, re-churned on every re-baseline. A trimmed SARIF
might have worked -- the results carry partialFingerprints -- but it could not be
verified locally, and an unverifiable config change is the trap B38 existed to
avoid.

B40's GOAL was "a run reports what is new rather than everything". Reaching zero
achieves that more completely: anything reported afterwards is new by definition,
with no file to maintain.

So all sixteen were read at their call sites, and the split was not the one a
count suggests. FIVE were genuine improvements and are fixed -- two redundant
TreeSet<File> type arguments, two redundant brace pairs, one `when` that reads
better with a subject, all behaviour-preserving with no assertion touched.
ELEVEN are refused and excluded, each scoped to the file it was decided for.

The most instructive refusal: UsePropertyAccessSyntax on LarsonScanner, where the
code ALREADY carried a comment saying the suggestion does not compile --
g2d.renderingHints is read-only in Kotlin because the getter returns
RenderingHints and the setter takes a Map. Somebody tried, found out, wrote it
down, and the tool reported it anyway, because a linter cannot read the comment
explaining why it is wrong. That is the case for encoding a verdict where the
tool looks rather than in prose beside the code.

The rest refuse on grounds this repository already has receipts for: unmeasured
optimisation (B30 bought ~0 ms), a dedup key that would end up spelled
differently from its sibling, positional binding of a domain object's fields, and
deleting a speaking name read eleven times.

Verified on the merged result:

  exclusions    19 inspection-scoped paths, all exist
  replay        every (ruleId, uri) against run 785's SARIF: 11 excluded,
                5 fixed in code, 0 unaccounted -> projected 0 problems
  api           460 tests, 0 failures, 1 skipped
  clientside    671 tests, 0 failures
  grinder       544 tests, 0 failures, 29 skipped
  app           168 tests, 0 failures
  plugin-grinder 75 tests, 0 failures
  warnings      none new

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
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!676
No description provided.