This is why... #673

Merged
Griefed merged 465 commits from develop into alpha 2026-09-09 20:42:18 +02:00
Owner
No description provided.
Griefed self-assigned this 2026-09-09 20:10:42 +02:00
Red on purpose. `ModrinthPlatform.filesOf` maps every entry of a version's
`files[]`, and a Modrinth version commonly carries more than one: authors attach
source jars, flagged `"primary": false`.

Measured against the live API on 2026-08-23, `creativecore` publishes 300
versions; its Fabric group holds 143 files, of which one is the stray
`CreativeCore-sources.jar` (fabric, 1.21.1, non-primary, 2024-09-04). That one
name shares no delimited prefix with the `CreativeCore_FABRIC_v*.jar` builds, so
`FilenameStemDeriver` falls back to stripping the version off the *shortest*
name and derives `CreativeCore-sources` — an entry that matches no published
file. Deriving from the primaries alone yields `CreativeCore_FABRIC_`.

Three of the 300 versions flag no primary at all, so the filter has to be
per-version: primaries when the version has any, every file when it has none.
The second pin covers that, and the existing canned JSON — which carries no
`primary` field — pins the same fallback.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`filesOf` mapped every entry of a version's `files[]` onto a `ModFile`, so an
attached source jar became a candidate for everything the engine does with a
file: derive the published list-entry from it, pick it as the jar-scan sample,
and — order permitting — boot it.

`modFilesOf` keeps only the entries Modrinth flags `"primary": true`, falling
back to every file of a version that flags none. The fallback is load-bearing,
not padding: 3 of `creativecore`'s 300 versions genuinely carry no primary flag,
and dropping them would lose real builds.

What it cost, measured against the live API on 2026-08-23: `creativecore`'s
Fabric group holds 143 files, one of them the stray `CreativeCore-sources.jar`.
That name shares no delimited prefix with the `CreativeCore_FABRIC_v*.jar`
builds, so `FilenameStemDeriver` fell back to stripping the version off the
shortest name and published `CreativeCore-sources` — an entry matching no file
the project has ever shipped. It also cost the mod its cross-loader disproof:
`loaderDisprovingTheCrash` compares entries, and `CreativeCore-sources` matches
neither of the other loaders' `CreativeCore_`, so a Fabric crash stood as HIGH
while NeoForge had booted a server in the same run.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Red on purpose — it does not compile, because the sample the crash re-check
boots has to name a loader per candidate and gate on the (loader, Minecraft)
pair rather than on the Minecraft version alone.

What the pins ask for, and why. Reported 2026-08-23 on `creativecore`: the
Fabric crash on Minecraft 26.2 spent both its re-checks on Fabric 26.1.2 and
Fabric 26.1 — the same loader, the same loader version (0.19.3) and the two
Minecraft versions adjacent to the crashing one. Near-identical code in a
near-identical environment; both came back INCONCLUSIVE, so the crash stood as
HIGH, while NeoForge had booted a server for the same project in the same run.

So each pick now has to introduce a Minecraft version-line and a loader that no
earlier pick used, newest Minecraft first: the booted line is skipped before its
neighbours are considered, and the budget is not spent twice on one loader. The
budget itself is unchanged.

`aSingleMinecraftLineStillSpendsTheWholeBudget` pins the other direction —
diversity is a preference, not a filter, so a project publishing one loader and
one Minecraft line samples exactly as deeply as it did before.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`pickRecheckCandidates` took the newest file of each *other Minecraft version*
of the crashing loader. With a budget of two that is the two versions either
side of the crashing one, on the same loader, at the same loader version — near
identical code, re-tested in a near-identical environment.

Reported 2026-08-23 on `creativecore`, a mod whose own description advertises
server features. Fabric / Minecraft 26.2 crashed; both re-checks went to Fabric
26.1.2 and Fabric 26.1, both under Fabric 0.19.3, and both came back
INCONCLUSIVE, so the crash was published HIGH. In the *same* run, NeoForge
26.1.2.97 booted a server cleanly for the same project, and the CurseForge run
minutes earlier booted the very file the 26.1 re-check gave up on.

Each pick now introduces a Minecraft version-line and a loader that no earlier
pick used, considered newest-Minecraft-first, with the crashing combination's
own line marked used from the start. A line is two components (`26.1.2` and
`26.1` are one, `26.2` another), because that is the granularity at which mod
source actually differs. On the shape above the same two boots become Fabric
26.1.2 and NeoForge 1.21.11 — measured by running the real `ModrinthPlatform`
and `pickRecheckCandidates` over the project's live 300-version response, not
read off the miniature in the unit test.

Diversity is a preference, not a filter: it relaxes to a new line, then a new
loader, then anything left, so a project publishing one loader and one Minecraft
line samples exactly as deeply as before. The budget is unchanged.

Crossing the loader is a wider claim than `loaderDisprovingTheCrash` allows, and
it is gated to match: this sample is spent only where the crash already
contradicts a declared server support, i.e. where one of the two signals is
known to be wrong. Every attempt's label now names its loader, because the
returned outcome can be a boot run under a different loader than the verdict is
about.

Attempts still stage into the *crashing* loader's directory — all attempts for
one candidate share one `boot.log`, which `restoreDecisiveConsole` repairs, and
staging under the candidate's own loader would wipe the pack and console that
loader's own verdict is built from.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Two defects that had to line up to publish `creativecore` HIGH: a Modrinth
source jar becoming the derived list-entry (which also disabled the cross-loader
disproof installed hours earlier, since that guard compares entries), and a
crash re-check whose whole budget went to the two Minecraft versions either side
of the crashing one on the same loader.

Both get a landmine in the module file, because both look like settled ground:
the platform layer reads like a straight JSON mapping, and the re-check reads
like it already samples other versions.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Red on purpose — it does not compile, because there is no shared helper naming
the per-attempt staging directory, and the reaper cannot yet be told which
platform's candidate finished.

The grinder already treats the same slug on Modrinth and on CurseForge as two
candidates ("Freshness is per (platform, slug)") and runs them on parallel
workers. Staging is keyed on `(slug, loader)` alone, and it *wipes* the
directory before using it; `BootWorkspaceReaper.reap(slug)` then deletes it
again once either candidate's verdicts are in. So two runs of one slug share a
server pack, and each is free to delete it out from under a container the other
is still booting.

Observed 2026-08-23 on `creativecore`, whose two platform runs finished 71
seconds apart:

  - NeoForge 26.2.0.66 / Minecraft 26.2 → SURVIVED (exit 137) on CurseForge and
    CRASHED (exit 1) on Modrinth. Same loader build, same Minecraft, same mod.
  - Fabric / Minecraft 26.2 → CRASHED (exit 127) on CurseForge. 127 is a shell
    that could not find the command it was told to run.
  - Both Modrinth Fabric re-checks INCONCLUSIVE (exit 1, exit 0) — one of them
    on `CreativeCore_FABRIC_v2.14.13_mc26.1.jar`, the very file the CurseForge
    run had booted to a ready-line two minutes earlier.

`BootWorkspaceReaperTest`'s fixture now builds its layout through the same
helper production will use, so the reaper's parse and the verifiers' naming
cannot drift into disagreeing — today they agree only by two separate string
literals happening to match.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Per-attempt scratch space was keyed on `(slug, loader)`. Staging *wipes* that
directory before using it, and `BootWorkspaceReaper.reap(slug)` deletes it again
once a candidate's verdicts are in. The grinder, meanwhile, treats the same slug
on Modrinth and on CurseForge as two candidates — verdict freshness is keyed
`(platform, slug)` for exactly that reason — and grinds them on parallel
workers. So both runs of one slug shared a server pack, and each was free to
delete it out from under a container the other was still booting.

`AttemptDirectory` now names the directory `<platform>-<slug>-<loader>` and
reads it back to its owner. Both halves live in one place because three callers
depend on them agreeing: `ClientsideVerifier` (jar-scan downloads),
`BootVerifier` (staged packs) and the reaper, which decides what to delete from
the name alone. Until now they agreed only by separate string literals happening
to match. The loader suffix is still cut rather than the slug prefix-matched, so
`creativecore` does not claim `creativecore-extras`.

What it was costing, from the `creativecore` report of 2026-08-23, whose two
platform runs finished 71 seconds apart:

  - NeoForge 26.2.0.66 / Minecraft 26.2 → SURVIVED (exit 137) on CurseForge,
    CRASHED (exit 1) on Modrinth. Same loader build, same Minecraft, same mod.
  - Fabric / Minecraft 26.2 → CRASHED (exit 127) on CurseForge. Exit 127 is a
    shell that could not find the command it was told to run — the pack had gone.
  - Both Modrinth Fabric re-checks INCONCLUSIVE (exit 1, exit 0), one of them on
    `CreativeCore_FABRIC_v2.14.13_mc26.1.jar`, the very file the CurseForge run
    had booted to a ready-line two minutes earlier.

Every one of those is a boot scored as evidence about a mod when it was really
evidence about a deleted directory, and a crash is the one outcome that reaches
HIGH.

Teeth checked: reaping on the bare slug fails
`reapingOnePlatformLeavesTheSameSlugOnAnotherPlatformAlone`; restoring either
producer's `"${project.slug}-$loader"` fails
`theJarScanOfTwoPlatformsSharingASlugDownloadsIntoSeparateDirectories` and
`theSameSlugOnTwoPlatformsStagesIntoSeparateDirectories`.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The loose end from the `creativecore` report: its Minecraft 26.2 boots did not
merely disagree, they disagreed about the same build, 71 seconds apart, with one
of them exiting 127. Landmined in both module files because the reaper already
carried a note reasoning about *different* slugs sharing a prefix — the case
where two candidates share a slug outright was the hole, and it is exactly what
the platform column exists to name.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Red on purpose (both fail to compile — `LoaderVerdict` has no `bootedLoader`,
`ContainerCandidateVerifier` has no `reapTarget`). Two audit findings from this
branch's own changes, pinned before either is touched.

H1 — only a loader's *own* clean boot may disprove another loader's crash. Since
the other-version re-check began spanning loaders, a verdict's `bootResult` can
be the result of a boot run under a different loader:
`reconcileOtherVersionRecheck` returns the surviving attempt's own outcome, and
that attempt may now be a cross-loader one. Reproduced by calling both functions:

  reconcileOtherVersionRecheck(NeoForge CRASHED,
      ["sodium-fabric-0.5.jar (Fabric, Minecraft 1.21.11)" SURVIVED]) → SURVIVED
  loaderDisprovingTheCrash(Forge CRASHED "embeddium-", [.., NeoForge SURVIVED])
      → NeoForge
  note: "Crashed, but NeoForge booted a server with the same entry 'embeddium-'"

NeoForge never booted a server. The build that did is `sodium-fabric-0.5.jar`,
which `embeddium-` cannot strip — so the invariant the entry comparison exists to
protect is not satisfied, and the note states something untrue. The stem split is
the one `FilenameStemDeriver.deriveStems` documents, not a contrived shape.

M1 — reclamation must ask for the identity the staging was *named* from. Staging
uses `ProjectFiles.platform`/`slug`; the reaper was being handed the candidate's
copy of both. `Grinder` already logs "Platform mismatch for …: candidate says
'X', resolved report says 'Y'", so the codebase knows they can disagree, and a
slug is a mutable name a rename can move. On disagreement the reap matches
nothing and leaks a server pack per attempt.

`reapTarget` is pinned as a pure decision rather than through `verify`, which
needs an ApiWrapper, a loader cache and a container engine — the same split the
boot verifier's own decisions already follow.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The other-version crash re-check samples across loaders, and
`reconcileOtherVersionRecheck` returns the surviving attempt's *own* outcome. So
a verdict for one loader could end up carrying `bootResult = SURVIVED` produced
by a boot of another, and nothing recorded the difference. Two things read that
field and both were misled.

`ClientsideVerifier.loaderDisprovingTheCrash` compares two verdicts' entries so
that a published stem can never strip a build proven to boot a server. With a
borrowed survival the build that actually booted belongs to a third loader whose
stem may differ, so the comparison guards nothing:

  Forge     CRASHED  entry `embeddium-`
  NeoForge  SURVIVED entry `embeddium-`  ← actually a Fabric boot of
                                            sodium-fabric-0.5.jar

`embeddium-` cannot strip `sodium-fabric-0.5.jar`, yet the crash was cleared and
the report printed "NeoForge booted a server with the same entry 'embeddium-'".
NeoForge booted nothing. That stem split is the one `FilenameStemDeriver.
deriveStems` documents, not a contrived shape.

`BootOutcome.bootedLoader` is stamped by `runPrepared` — the one place that knows
what was booted — and carried to `LoaderVerdict.bootedLoader`. The disproof now
also requires `other.bootedLoader == other.loader`, restoring the invariant
exactly as documented, and the Markdown report's Boot cell reads
`SURVIVED (via NeoForge)` when the two differ rather than letting a row claim a
boot it never had.

`outcomeFor` deliberately does not learn the loader: it classifies a console, and
that is all it should need to know.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Staging directories are named from the *resolved report's* `ProjectFiles.platform`
and `slug`; the reaper was handed the queued candidate's copy of both. `Grinder`
already logs "Platform mismatch for …: candidate says 'X', resolved report says
'Y'" when a source labels a project differently from the platform that resolves
it, and a slug is a mutable display name a rename can move out from under a
queued candidate — so the codebase knows the two can disagree.

On disagreement the reap matched no directory at all and left a full server pack
per attempt behind, which is the disk-growth class `BootWorkspaceReaper` exists
for: 98 GB across 1750 attempt directories, ~23 GB/h, measured 2026-07-30.

`reapTarget` prefers the report and falls back to the candidate when the
verification threw — precisely the case where staging is most likely to be left
behind, and the case where no report exists to ask. It is a pure decision so it
can be pinned without an ApiWrapper, a loader cache or a container engine, the
same split `BootVerifier`'s own decisions follow.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
All three describe what the code does and stopped being true when the crash
re-check began spanning loaders.

L1 — `BootVerifier` logged "re-checking N other version(s)" while the sample now
also spans loaders, and the message named none of them. It now says
"combination(s)" and lists each `<loader> / Minecraft <version>`, which is the
line an operator reads when a verdict has to be explained after the fact. Its
empty-sample sibling gets the same word.

L2 — `BootVerifierCrashRecheckTest`'s synthetic labels kept the pre-change
`"ironchest-1.20.1.jar (Minecraft 1.20.1)"` form. They are opaque strings to the
pure functions under test and every assertion is byte-identical, but the file is
where a reader goes to learn what a label looks like, so it should show the real
one.

M2 — `serverpackcreator-clientside/CLAUDE.md` claimed `MetadataScannerTest` is
the only test needing a resource. Measured: three already did on `develop`
(`MetadataScannerTest`, `LoaderVersionResolverTest`, `BootVerifierSelectionTest`)
and this branch adds a fourth. The replacement names all four and states the
`grep` that re-derives them, rather than leaving another number to trust —
"cite names, not snapshots" applies to the context files themselves.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Red on purpose — `CrashLogStore` does not exist, `ReportServer` takes no crash
logs, and `VerdictReportRenderer.toHtml` takes only a verdict list.

**Why a crash console needs a home outside staging.** `BootWorkspaceReaper`
keeps one `boot.log` per attempt directory, but staging *wipes and re-creates*
that directory, so the next re-grind of the same `(platform, slug, loader)`
destroys the console for the verdict that is still published. A crash is the
only outcome that reaches HIGH, and its usual cause — a server reaching for a
client-only class — is legible from nothing else. The pins therefore cover the
round trip a report link makes, that the same slug on two platforms is two logs,
and that a re-grind *replaces* rather than accumulates, so the store is bounded
by distinct crashing tuples instead of by uptime.

`aNameThatEscapesTheStoreReadsNothing` and the server's traversal case are the
security half: the store is addressed **by name off a query string**, so a name
is untrusted input. The report binds loopback by default but is documented as
something an operator may reverse-proxy, so a traversal must resolve to nothing
rather than to any file the daemon's user can open.

`anOversizedConsoleIsKeptFromItsTailAndSaysSo` — a mod can spew megabytes before
it dies; the crash is at the end, and a silently shortened log is one nobody can
trust.

The renderer asks a per-row lookup rather than reading a field off the verdict,
so a link is offered only where a file is actually kept and the table can never
point at a 404.

The endpoint buttons close a smaller gap: `/export.csv`, `/status` and
`/as-properties` were reachable only from a line printed at startup, and the
overview is the only page an operator ever opens.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Red on purpose — `keepCrashConsoles` does not exist. Completes the previous
commit's pins with the producer half: the store now has a reader and an index,
but nothing yet puts a console into it.

The attempt directory is wiped and re-created by the next re-grind of the same
`(platform, slug, loader)`, so a crash console survives only until the project
comes round again — while the verdict it justifies stays published. Only the
boot that CRASHED is kept: a clean boot proves nothing about sideness and
explains nothing either.

The second pin is the one that keeps this from ever costing a verdict — a crash
whose console never reached disk keeps nothing and throws nothing. Collecting
evidence after the fact must not fail a grind that already has its answer.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Two operator-facing gaps, one commit because they are the same page.

**Crash consoles now survive the sweep that produced them.** `CrashLogStore`
copies the console of every CRASHED boot out of staging into `<home>/crash-logs`
the moment a candidate's verdicts are in. `BootWorkspaceReaper` already kept one
`boot.log` per attempt directory, but staging wipes and re-creates that directory,
so the next re-grind of the same `(platform, slug, loader)` destroyed the evidence
for a verdict that stays published. A crash is the only outcome reaching HIGH and
its usual cause — a server loading a mod that reaches for a client-only class,
`NoClassDefFoundError: net/minecraft/client/…` — is legible from the console and
from nothing else.

Only CRASHED is kept: a clean boot proves nothing about sideness and explains
nothing either. Logs live under the daemon's home rather than under `work/`,
because everything below `work/` is scratch the reaper may reclaim. Growth is
bounded by the number of distinct crashing tuples, not by uptime, because a log
is named after its tuple and a re-grind replaces it — the opposite of the naming
that once grew the work tree to 98 GB. An oversized console is kept from its tail
with the truncation stated in the file; the stack trace is at the end.

The store is addressed **by name off a query string**, so a name is untrusted:
`read` requires a plain file name resolving directly inside the store, checked on
the string before the filesystem and confirmed canonically afterwards so a
symlink cannot lead out. A refusal is indistinguishable from an absent log, so
probing an unauthenticated report tells a caller nothing.

**Every endpoint is now a button beside "Download CSV"** — `/export.csv`,
`/status`, `/as-properties` and the new `/crash-logs` index were reachable only
from a line printed at startup, and the overview is the only page an operator
ever opens. Each crashing row also links its own console.

The renderer takes a per-row *lookup* rather than reading a field off
`GrindVerdict`: the log lives on disk, so asking at render time means a link
appears exactly when a file is there, and a log removed by hand cannot strand the
table pointing at a 404. It defaults to "no logs", keeping the page renderable —
and openable straight from disk — with no store wired.

Teeth checked: dropping the traversal guard fails
`aNameThatEscapesTheStoreReadsNothing` and
`servesAKeptCrashLogAndRefusesToEscapeItsStore`; dropping the CRASHED filter
fails `aCrashedBootsConsoleIsKeptOutsideStaging`.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The audit report (iteration 24) gains its resolution section, including why H1's
remedy was chosen over the two cheaper ones — a patched result would have kept
each row honest while losing which build actually cleared the crash, and dropping
cross-loader survivors would have undone the fix the branch exists for.

Two new landmines. `bootResult` alone no longer identifies whose boot it was, and
the guard that compares list-entries depends on knowing. And a crashed boot's
console now lives outside staging, addressed by an untrusted name over HTTP —
both of which read as settled ground otherwise.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`creativecore` — a library mod whose own description advertises server features
— was published HIGH clientside under the entry `CreativeCore-sources`. Three
independent defects had to line up, and the third was only visible once the
first two were understood.

1. A Modrinth version's `files[]` is not a list of mods. Source jars are flagged
   `"primary": false`; mapping them all made one stray `CreativeCore-sources.jar`
   the shortest name in the Fabric group, so the derived list-entry matched
   nothing the project ships — and, because `loaderDisprovingTheCrash` compares
   entries, disabled the cross-loader disproof installed hours earlier.

2. The crash re-check never left the crashing combination's neighbourhood: both
   boots went to the same loader, the same loader version and the two adjacent
   Minecraft versions. Each pick now introduces a Minecraft version-line and a
   loader no earlier pick used, on the same budget.

3. Per-attempt scratch space was keyed on `(slug, loader)` while the grinder
   grinds the same slug on both platforms in parallel — so one run deleted the
   server pack out from under the other's container. That is what produced the
   exit-127 boot and NeoForge 26.2 reading SURVIVED on one platform and CRASHED
   on the other for the identical build.

Audited before merge (`claude-docs/REFACTOR-AUDIT.md`, iteration 24). Five
findings, all fixed and re-pinned; the HIGH was one this branch introduced — a
cross-loader survivor was being published as the crashing loader's own boot and
then disproving a third loader's crash. `develop`'s unmodified test tree run
against the branch's code: 415 pre-existing guards, 0 failures, exactly two files
uncompilable, both intended contract changes.

Also lands two operator-facing changes: crashed boots' consoles are copied out of
staging before the next re-grind destroys them, and every endpoint is a button on
the report overview.

api 359 · clientside 136 · app 149 · grinder 325 — 0 failures.
Red on purpose — there is no `RequeueStore`, no `RequeueSelection`, and neither
`Grinder.grind` nor `GrindPool.grindAll` takes a force.

The catalog crawl plus the re-verify TTL answer "when does a project come round
again?" with *eventually, at TTL*, and that is the wrong answer when the defect
is in the engine rather than in the mod. Three landed on 2026-08-23 alone — a
source jar becoming a list-entry, a crash re-check that never left the crashing
combination's neighbourhood, and two platform runs of one slug sharing a staging
directory — and each invalidated verdicts that were already published.

`aForcedGrindReVerifiesEvenAFreshVerdict` is the pin that makes the queue worth
having. A verdict is queued precisely *because* it is wrong, and it is almost
always recent: engine defects get found by reading verdicts that were just
produced. Without the force, a drained queue would turn straight into
`SKIPPED_FRESH` and do nothing, which is the failure mode that looks like it
worked.

Persistence is pinned because the queueing tool and the daemon are different
processes — an operator queues work against a service that is already running —
so an in-memory queue would be empty in the process that matters.

`RequeueSelection.verifiedBefore` expresses the recurring shape once: a defect is
found, and every verdict produced before the fix is suspect. One candidate per
*project* rather than per verdict row, since re-grinding re-verifies every loader
anyway.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The crawl plus the re-verify TTL answer "when does a project come round again?"
with *eventually, at the TTL*. That is right when a mod changes and wrong when
the defect is in this engine — and then the bad verdicts are already published,
so waiting out 30 days means serving a known-wrong clientside entry for a month.
Three such defects landed on 2026-08-23 alone.

`RequeueStore` is a persisted queue drained at the **start of every pass**, ahead
of the catalog slice, and ground with `force = true`. The force is the load-
bearing half: a project is queued precisely because its verdict is wrong, and a
wrong verdict is usually a recent one — engine defects get found by reading
verdicts that were just produced — so an unforced drain would turn straight into
SKIPPED_FRESH and look like it worked.

Two selectors, because two things actually happen:

  --requeue <url>…              a named handful someone disputed
  --requeue-before <instant>    everything verified before a fix landed

The second is the recurring shape and the reason this exists at all: a defect
invalidates a *population*, not a list assembled by hand. One candidate per
project rather than per verdict row, identified by platform + the platform's own
project id where known, so a renamed project is still one re-grind and the same
slug on two platforms is still two.

**Queue-and-exit, over the CLI rather than HTTP, and deliberately before the SPC
claims.** The report server is unauthenticated by design, so a write endpoint
there would let anyone who can reach the page schedule unbounded container work.
And the command runs against a daemon that is already up, so it must not claim
the preferences node or re-pin SPC's home — which is also why it prints to stdout
and never to `log`: the first log statement in a process constructs the very
`ApiProperties` those claims exist to control.
`theRequeuePathRunsBeforeTheClaimsAndNeverLogs` guards both halves, because
`main`'s own body cannot see a log call made from inside the helper.

Verified against the real entry point and a store sliced from live data (875
verdicts): 14 rows → 7 distinct projects queued with both platforms of `chipped`
and `ambientsounds` kept apart; re-running added 0 of 7; a named URL added 1; a
bad instant printed guidance rather than a stack trace; and the run left only
`requeue.json` behind — no log directory, confirming no `ApiProperties` was built.

`/status` reports `requeued` so a backlog is visible rather than inferred, and a
queued grind logs `(re-grind requested)` so the log distinguishes "the crawl
reached this" from "somebody decided the old verdict was wrong".

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The force on the drain, the CLI-not-HTTP choice, and the runs-before-the-claims /
never-logs pair each look like style until you know what they cost. The last is
the existing preferences landmine one level removed — a log call inside the
helper is invisible to a guard that scans main's body.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Three defects fixed on 2026-08-23 each invalidated verdicts that were already
being published, and none of them had a remedy: the catalog crawl plus the 30-day
re-verify TTL bring a project round *eventually*, which means serving a
known-wrong clientside entry until then.

`RequeueStore` is the lane that jumps both. Persisted, drained at the start of
every pass ahead of the catalog slice, and ground forced — the force being the
half that matters, since a verdict is queued because it is wrong and a wrong
verdict is usually a recent one. `--requeue <url>…` for a named handful,
`--requeue-before <instant>` for the recurring shape: a defect invalidates a
population, not a hand-assembled list.

Queue-and-exit over the CLI rather than HTTP (the report is unauthenticated by
design), running before SPC's home/preferences claims and printing to stdout
rather than logging, because the command is run against a daemon that already
owns those. Verified against the real entry point over a store sliced from the
live 875-verdict file.

grinder 325 → 336, 0 failures.
Three findings of substance. H1 is this session's: the drain added a second
GrindPool per pass while the shutdown hook holds one handle, so a stop landing in
a drain is signalled, awaited, reported clean — and then the catalog pass starts
anyway, creating containers after the hook has finished.

M3 is pre-existing and was found by reading the production log rather than the
code: 'Pass #$pass complete' interpolates a shadowed GrindPass, so the line an
operator greps for progress is a multi-kilobyte dump of every candidate.
Surfaced rather than deferred, per the conventions.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Red on purpose — two source guards fail, and the pacing pin does not compile
(`GrindPacing.pollInterval` does not exist). All three are audit iteration 25's
findings against this session's own re-grind queue.

H1 — the drain gave the pass loop a *second* `GrindPool`, while the shutdown hook
holds one handle and reads it once, deliberately ("reading it twice could signal
one pool and wait on another"). With no `running` check between the two pools a
stop landing in a drain is signalled, awaited and reported clean, and then `main`
takes a catalog batch and starts new containers **after** the hook has finished,
with systemd's TimeoutStopSec counting down. Containers live in the docker
daemon's cgroup, not the unit's, so the hook is the only thing that can stop
them. Asserted against `main`'s own source, like the other entry-point guards:
the loop needs an ApiWrapper, Docker and a report port to run, and none of that
is needed to know the check is there.

M1 — `status.beginPass` ran after the drain and counted only the catalog slice,
so for the whole of a 300-candidate drain `/status` showed the *previous* pass's
number and size while `active` showed workers grinding candidates belonging to
neither. That is the one operation an operator is most likely to be watching.

M2 — the inter-pass wait is one uninterruptible sleep, and `pauseAfterPass`
returns `betweenSweeps` (default 21 600 s) after a completed sweep that verified
nothing. Queue a re-grind into a just-dozed daemon and nothing happens for six
hours; the queue exists precisely so a known-wrong verdict is not served while a
timer runs down. Pinned as the pure decision — how long to wait before looking
again — so no test has to sleep.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Audit iteration 25, findings H1, M1, M2 and L1.

**H1** — the re-grind drain gave the pass loop a second `GrindPool`, and the
shutdown hook holds one handle which it reads once (deliberately: reading twice
could signal one pool and wait on another). A stop landing in a drain was
therefore signalled, awaited and reported complete, after which `main` fell
through and built the catalog pool — starting boots and creating containers
*behind* a hook that had already finished, with systemd's TimeoutStopSec counting
down. Containers live in the docker daemon's cgroup rather than the unit's, so
the hook is the only thing that can ever stop them. A `running` re-check between
the two pools closes it, landmined in place.

**M1** — `/status` is announced before either pool now, and counts both. It used
to be announced after the drain with only the catalog slice's size, so for the
whole of a 300-candidate drain the endpoint that answers "what is it doing right
now?" showed the *previous* pass's number and size while `active` showed workers
grinding candidates belonging to neither.

**M2** — the inter-pass wait is served in 15-second slices and ends early when
work is queued. It was one uninterruptible sleep, and `pauseAfterPass` returns
`betweenSweeps` (default 21 600 s) after a completed sweep that verified nothing,
so queueing a re-grind into a just-dozed daemon bought a six-hour wait. The whole
point of the queue is not serving a known-wrong verdict while a timer runs down;
trading a 30-day TTL for a 6-hour one was better and still not what was built.
`GrindPacing.pollInterval` keeps the decision pure, so no test has to sleep.

**L1** — `pending()` is `@Synchronized` like its neighbours. It only reads and
degrades to empty, so the exposure was a stale count rather than corruption, but
an annotation that is on two of three methods reads as decorative.

The two source guards were re-targeted before they passed, and the reason is
worth stating: they originally keyed on `crawler.nextBatch()` and on
`requeue.drain()`, and the fix moved both. The *intent* — no new pool after a
signalled stop, and a pass size that includes the drain — is unchanged; the
landmarks were badly chosen. They now key on the two `GrindPool` constructions
and on `beginPass`'s argument, which are the things that carry the meaning.
Teeth re-checked after re-targeting: removing either fix fails its guard.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Red on purpose, and the finding is pre-existing rather than this session's —
surfaced because the conventions require a bug found while working to be raised
and fixed in its own commit rather than deferred.

`val pass = pool.grindAll(batch.candidates)` shadows the `var pass` counter, so
`log.info("Pass #$pass complete: …")` interpolates the `GrindPass` data class.
It compiles, it runs, and the line still begins "Pass #", which is why it
survived: it is only visible in the output.

Found in the production log, not the code. `~/.spc-grinder/grinder.log` carries
14 of them, each a multi-kilobyte dump of every reached candidate's URL and
popularity in the one line an operator greps for pass progress:

  Pass #GrindPass(reached=[GrindCandidate(projectUrl=https://modrinth.com/mod/
  lambdynamiclights, slug=lambdynamiclights, popularity=49644693, platform=…

Asserted against `main`'s source because that is where the shadowing is: a log
line's text is not reachable from a unit test without an appender, and the defect
is the declaration rather than the formatting.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`val pass = pool.grindAll(batch.candidates)` shadowed the `var pass` counter, so
`"Pass #$pass complete: …"` interpolated the `GrindPass` data class — a
multi-kilobyte dump of every reached candidate's URL and popularity, in the one
line an operator greps to see how a pass went. 14 of them in the production log.

Renamed to `catalogPass`, which is also what it is: the *catalog* slice's result,
now that a pass can also grind a re-grind drain. Pre-existing on `origin/develop`
and fixed in its own commit rather than folded into the audit work that found it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
H1 is iteration 25's own fix biting back: the sliced wait computes its remainder
after a file read, so the final slice can go negative, and Thread.sleep throws an
IllegalArgumentException that no catch on that path handles — ending main and
leaving a fire-and-forget daemon quietly doing nothing.

M1 is the crash-log cap bounding what is written rather than what is read, with
runCatching(Throwable) standing ready to swallow the OOM.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Red on purpose — `RequeueSelection.fromLinks` does not exist, and the two other
pins fail against the current code. Audit iteration 26.

H1 — `pollInterval` must never hand back a negative. The caller computes its
remainder *after* the loop condition, with a synchronized queue read in between,
so the final slice — bounded by construction to (0, 15s] — goes negative when
that read stalls longer than the remainder. A negative Duration compares below
the slice and passes straight through, and `Thread.sleep(-5)` throws
IllegalArgumentException, which is not InterruptedException: it escapes the
wait's catch, escapes `while (running.get())`, and ends `main`. The daemon then
stops grinding with no crash anyone is watching for. Measured:

  PROBE Thread.sleep(-5) -> java.lang.IllegalArgumentException: timeout value is negative
  PROBE Duration.ofMillis(-5) < Duration.ofSeconds(15) = true

M1 — the crash-log cap must bound what is *read*, not only what is written.
`keep` does `console.readText()` before the MAX_BYTES check; boot consoles are
streamed uncapped and bounded only by the 15-minute timeout, so a chatty mod can
leave hundreds of megabytes, doubled again as UTF-16. And `keep`'s `runCatching`
catches Throwable, so the OutOfMemoryError would be swallowed and the daemon
would carry on in an unknown heap state. Pinned **by measurement** — a 64 MiB
console, with the heap growth across the call required to stay under the file
size — because that is the only formulation that distinguishes a bounded read
from a lucky one; the existing truncation test plants a file just over the cap
and would pass either way.

M2 — a link no platform resolves must be refused by the command that read it.
`ModPlatforms.ofUrl` answers `Unknown` for a typo'd host, and queueing it reports
"Queued 1 of 1" before failing hours later inside the daemon, in a log nobody is
reading.

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

**H1** — `pollInterval` clamps a negative remainder to zero. The caller computes
`wakeAt - now` after testing `now < wakeAt`, with a synchronized queue read in
between, so the final slice — bounded by construction to (0, 15s] — goes negative
when that read stalls longer than the remainder. `Thread.sleep` throws
IllegalArgumentException on a negative timeout; that is not InterruptedException,
so it escaped the wait's catch, escaped `while (running.get())` and ended `main`,
leaving a fire-and-forget daemon quietly not grinding with no crash anyone was
watching for. Landmined in place.

**M1** — `CrashLogStore` seeks to the tail instead of reading the console whole.
The cap bounded what was written, not what was read: boot consoles are streamed
uncapped and bounded only by the 15-minute timeout, so a chatty mod can leave
hundreds of megabytes, doubled again as UTF-16 — and `keep`'s `runCatching`
catches Throwable, so the OutOfMemoryError would have been swallowed and the
daemon left running on an unknown heap. Decoding can clip a multi-byte character
at the seek point, which is why the truncation notice sits in front of it: the
first line is already declared incomplete.

**M2** — `RequeueSelection.fromLinks` refuses a link no platform resolves and
names it back. `ModPlatforms.ofUrl` answers `Unknown` for a typo'd host, and
queueing that reported "Queued 1 of 1" before failing hours later inside the
daemon, in a log nobody is reading.

M2's fix consolidates rather than patches, and that is deliberate: the **one-shot
path had the identical hole** — `args.map { GrindCandidate(it, slugFromUrl(it),
0, ModPlatforms.ofUrl(it)) }`, the same expression — so fixing only the queue
would have left the same defect one call site away. Both now go through
`fromLinks`, and `slugFromUrl` moved with it.

Teeth checked: restoring `readText()` fails `anOversizedConsoleIsNeverReadWhole-
IntoMemory` (measured on a 64 MiB console), and restoring the two-branch
`pollInterval` fails `aRemainderThatHasAlreadyElapsedSlicesToZero`.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Both iterations' findings fixed, with the two judgement calls stated rather than
left to look like drift: the iteration-25 guards were re-targeted (intent
unchanged, landmarks moved by the fix), and iteration-26's M2 consolidated the
one-shot path's identical hole instead of patching only the queue.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
merge: two audit iterations over the unpushed work
Some checks failed
Continuous / Build AppImage (x86_64) (push) Has been cancelled
Continuous / Build AppImage (aarch64) (push) Has been cancelled
Continuous / Build Install4J Media (push) Has been cancelled
Continuous / Continuous Pre-Release (push) Has been cancelled
Continuous / Build JAR (push) Has been cancelled
Docker Test / build image (push) Has been cancelled
Documentation / Help image (push) Has been cancelled
Qodana / scan (push) Has been cancelled
Qodana / notify (push) Has been cancelled
Test / build (push) Has been cancelled
Documentation / Writerside webhelp (push) Has been cancelled
5a4b64c41f
Iteration 25 — H1: the re-grind drain gave the pass loop a second GrindPool while
the shutdown hook holds one handle and reads it once, so a stop landing in a
drain was signalled, awaited and reported clean, and then the catalog pass
started anyway — creating containers behind a finished hook with TimeoutStopSec
counting down. Also: /status stale and under-reporting for the whole of a drain;
a queued re-grind waiting out a six-hour inter-sweep pause; and a pre-existing
shadowed pass counter that had been printing an entire GrindPass where the pass
number belongs (14 such lines in the production log).

Iteration 26 — H1: iteration 25's own sliced wait could compute a negative
remainder and die on Thread.sleep's IllegalArgumentException, which no catch on
that path handles, ending main and leaving a fire-and-forget daemon quietly not
grinding. Also: the crash-log cap bounded what was written rather than what was
read, with runCatching(Throwable) ready to swallow the resulting OOM; and
--requeue accepted links no platform resolves, reporting success before failing
hours later in a log nobody reads.

Nine findings, all fixed, each pinned red first. Two judgement calls are recorded
rather than buried: iteration 25's source guards were re-targeted when the fix
moved their landmarks (intent unchanged, teeth re-checked afterwards), and
iteration 26's M2 consolidated the one-shot path's identical hole instead of
patching only the queue.

api 359 · clientside 136 · app 149 · grinder 344 — 0 failures.
Tag repair after migrating from GitLab to Forgejo
All checks were successful
Documentation / Writerside webhelp (push) Successful in 1m53s
Continuous / Build JAR (push) Successful in 8m42s
Qodana / scan (push) Successful in 9m58s
Docker Test / build image (push) Successful in 15m53s
Documentation / Help image (push) Successful in 5m41s
Continuous / Build AppImage (x86_64) (push) Successful in 3m36s
Continuous / Build AppImage (aarch64) (push) Successful in 5m0s
Test / build (push) Successful in 15m24s
Qodana / notify (push) Successful in 29s
Continuous / Build Install4J Media (push) Successful in 13m19s
Continuous / Continuous Pre-Release (push) Successful in 6m9s
53ffe7f749
Signed-off-by: Griefed <griefed@griefed.de>
`9.0.0-alpha.7` died in `Mirror release outward` on a GitHub 422 naming three fields at once --
`tag_name is not a valid tag`, `Published releases must have a valid tag`, and an invalid
`target_commitish`. All three are one cause with three symptoms: GitHub did not have the release
commit, so there was nothing to mint the tag from. `GET /commits/50fd50f37` answered `422 No commit
found for SHA`, GitHub's `alpha` still sat on `RELEASE: 9.0.0-alpha.6` 69 commits back, and
`pushed_at` was some five hours older than the tag. The Forgejo release was complete and correct
throughout -- id 1730, 12 assets, the VirusTotal section, the tag on the right commit -- so nothing
in the workflow was wrong. Its precondition was false.

The cost was in the reading: a 422 about `tag_name` sends you to the tag, the changelog and the
release payload, three places that were all fine. `Require GitHub to have the release commit` now
probes `GET /commits/${{ github.sha }}` before the POST and fails naming the mirror, with the repair
steps in the message. It polls 20x15s rather than failing at once, because a push-mirror's `Sync when
new commits are pushed` is an opt-in checkbox and the periodic interval defaults to 8h -- a mirror
merely queued behind a large push is worth a few minutes. `401`/`403` short-circuits as a credential
problem rather than waiting five minutes to blame the wrong subsystem.

`Mirror to GitHub` also gains the reuse-and-PATCH path the `release` job already has, and skips
assets already attached. Re-running this job alone is the only safe repair -- a whole-workflow re-run
would re-publish Maven and Docker for an already-released version -- and without reuse a re-run after
a half-finished asset loop dies on GitHub's `already_exists`. Run 222 is the case in point: its
mirror job uploaded all twelve assets and then failed in the step after.

`target_commitish` is not the fix for this, and its comment now says so: it covers an absent *tag*
only, and the commit it names still has to be there. `9.0.0-alpha.6`'s GitHub release carries
`target_commitish` `f7ebba4e` where `.1` through `.5` carry `main`, which is proof the mechanism works
when the mirror is current.

Verified: both new shell bodies pass `bash -n`, all thirteen workflows still parse, and `mirror` keeps
job index 5 so the run-URL references elsewhere stay valid.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Recreates the `news` job `main` still carries in `github_release.yml` and
`github-prerelease.yml`. Those two differ only in the words "release" and "Pre-Release", so
`prepare.outputs.prerelease` picks between them and this is one job rather than two near-identical
workflows. The message keeps 0xC0FFEE, the four i.griefed.de images, the author block and both the
`content` line and the embed `description`; the `Platform` field becomes Forgejo, which is where the
release now lives and what the embed links.

Two findings from WORKFLOW-AUDIT.md are fixed by construction rather than carried across. M6: the
original downloaded ChaoticWeg/discord.sh's *master* and executed it, which is
remote-code-execution-by-trust for what is only a JSON builder -- the payload is now built the way
qodana.yml already builds its own. M4: the deprecated `::set-output` disappears with the separate date
step that fed it. Every value reaches python through `env:` rather than being interpolated into the
shell, per L2.

`needs: [prepare, release, maven, docker]` -- deliberately not `mirror`. This announces the Forgejo
release, which is the canonical one, and `mirror` is the job that failed on both releases of
2026-08-23; gating the announcement on it would have silenced two perfectly good releases. It does
wait for `maven` and `docker`, on the same reasoning `mirror` gives for its own `needs`: nobody should
be pointed at a release whose artifacts and images do not exist yet. `virustotal` is left out because
its failures are explicitly tolerated and this does not reproduce the release notes.

Verified by executing the step, not by reading it: with a `curl` shim it produces valid JSON for both
the release and prerelease shapes, with the right wording, URLs and `color == 0xC0FFEE`, and the
heredoc dedents correctly inside the YAML block scalar. The guard was exercised both ways -- unset and
empty `WEBHOOK_URL` print the skip line, exit 0 and build no payload -- with `set -e` after it, so a
webhook that is configured and then fails is still a hard failure. `${{ github.server_url }}/${{
github.repository }}` is known to expand on this instance because qodana.yml's notify job posts with
it, which is also how `WEBHOOK_URL` is known to be configured. Both embed URLs answer 200, and
Discord's own docs confirm `username`/`avatar_url`/`content`/`embeds` and a 10-embed cap against the
one embed sent.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`9.0.0-alpha.6` and `9.0.0-alpha.7` both show exactly one red job -- `Mirror release outward`,
attempt 1 -- and they failed for completely unrelated reasons. The run list cannot tell them apart,
which is the whole trap, so the rules file now separates them and says how to.

Run 222 (alpha.6) mirrored all twelve assets to GitHub successfully and only then died on
`exitcode '22'`, curl's `--fail`. The step was `Mirror to GitLab.com`, present at that tag and deleted
two and a half hours later by *fix(ci): drop the gitlab.com release mirror, and stop curl hiding why*.
So alpha.6 needs no repair and that failure cannot recur. Verified against both APIs that its GitHub
release is in fact complete -- 12/12 assets with matching sizes, an identical 41,275-char body
including the VirusTotal section, `target_commitish` `f7ebba4e`, `prerelease: true`. **A red `mirror`
job does not mean an incomplete GitHub release**; check before repairing one.

Run 272 (alpha.7) is the stalled-mirror 422, and is recorded as the second instance of the class the
gitlab.com section already described -- the class being that a mirror step's precondition is the
mirror, not the API call. Distinguish the two by exit code: `22` is the old GitLab step, `1` is the
`::error::` the GitHub steps now emit. Reading the log is the fastest way in and works anonymously on
a public repo, via `GET /api/v1/repos/{owner}/{repo}/actions/jobs/{job_id}/logs` -- noting that the
API's run id is not the number in the run's URL, which is `index_in_repo` (222 -> 252, 272 -> 311), so
`/actions/runs/222` returns 404.

Also records that nothing else in the release should be gated on the mirror, which is why `news` needs
`[prepare, release, maven, docker]` and not `mirror`, and adds `news` to CI-SECRETS.md: `WEBHOOK_URL`
is now read by two workflows and is optional in both.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
merge: the release mirror's precondition, and the Discord announcement
All checks were successful
Documentation / Writerside webhelp (push) Successful in 2m14s
Continuous / Build JAR (push) Successful in 12m43s
Qodana / scan (push) Successful in 14m43s
Docker Test / build image (push) Successful in 18m27s
Continuous / Build AppImage (x86_64) (push) Successful in 2m39s
Continuous / Build AppImage (aarch64) (push) Successful in 2m36s
Documentation / Help image (push) Successful in 6m46s
Qodana / notify (push) Successful in 15s
Test / build (push) Successful in 15m25s
Continuous / Build Install4J Media (push) Successful in 12m56s
Continuous / Continuous Pre-Release (push) Successful in 8m4s
39080000f7
Two release-build.yml concerns and their record. `mirror` now probes that GitHub actually has the
release commit before POSTing the release, and reuses an existing release so re-running that job alone
-- the only safe repair -- works. `news` is recreated from `main`, without the remote-script download
and the deprecated `::set-output` the original carried.
Red on purpose. Under `--network none` the daemon has no address to map, so it
writes no `<ip> <hostname>` line into `/etc/hosts` and `getaddrinfo` on the
container's own name fails. Observed against a live daemon (docker 29.7.2):

    wget: bad address '11419499a196:1'

which is the same lookup failure a boot reports as

    UnknownHostException: 928f022c75b5: Temporary failure in name resolution

three times, before any mod is loaded, because log4j calls
`InetAddress.getLocalHost()` while it configures itself.

Asserted through `wget` rather than by reading `/etc/hosts`: it calls the same
`getaddrinfo` the JVM does, so the guard covers resolution and not merely that
a line was written.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Adopt the name resolution the docker daemon gives a *networked* container: it
writes an `<ip> <hostname>` line into /etc/hosts, which is the only thing that
makes a container's own name resolvable. A grinder boot runs `--network none`
and therefore has no address, so no such line was written and `getaddrinfo`
failed on the container's own name.

A Minecraft server asks for it immediately — log4j calls
`InetAddress.getLocalHost()` while configuring itself — so every boot opened
with three

    UnknownHostException: 928f022c75b5: Temporary failure in name resolution

stacktraces before a single mod was loaded (Modrinth-chloride-NeoForge.log).

The hostname is now fixed (`spc-grinder`) rather than the daemon's default,
because the mapping must be part of the create call and the container id only
exists after it. It is pointed at loopback: the mod must stay unable to reach
anything, but it must be able to look *itself* up.

Measured against docker 29.7.2, `--network none`:

  before   wget: bad address '11419499a196:1'
  after    wget: can't connect to remote host (127.0.0.1): Connection refused

i.e. resolution now gets as far as the connect. `--add-host` is honoured with
no network at all, which is what makes this possible without giving the boot
one. `theContainersOwnHostnameResolvesWithoutANetwork` was red on the commit
before this one and is green here.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Red on purpose, and it shows why the endpoint needs a context of its own rather
than only a bundled file: without one the catch-all `/` answers a browser's
unprompted icon request with the whole verdict table —

    /favicon.ico was served as Optional[text/html; charset=utf-8]

Asserted on the PNG signature, because a 404 page and an HTML fall-through are
also non-empty 200 bodies.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
ServerPackCreator's own configuration glyph (`img/config.png`, copied
byte-identical) ships inside the grinder jar and is served from there, so the
page still fetches nothing from outside itself.

Registered under both `/favicon.ico` and `/favicon.png` — the pages link the
latter, browsers ask for the former unprompted — because either one otherwise
falls through to the catch-all `/` context and receives the verdict table with
`Content-Type: text/html` in place of an icon. The `<link>` goes on both HTML
pages; the plain-text crash consoles are covered by the `.ico` route.

`respond` now delegates to a byte-oriented `respondBytes`, which is what the
icon needs and what every text endpoint was already doing via UTF-8.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Red on purpose, and the red is the live defect: the console of
`CurseForge-ars-nouveau-Forge.log` (2026-08-23), pasted verbatim, classifies as
CRASHED today —

    expected: <INCONCLUSIVE> but was: <CRASHED>

so a mod whose code never ran was on its way to a clientside HIGH. The server
died in `BootstrapLauncher.main` before FML existed; nothing about the mod was
exercised.

Also pins the ServerStarterJar's own pre-launch give-ups (no run-script to read
arguments out of), and adds the new rung to the whole-ladder guard order test,
between launch-failure and killed/OOM.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`Could not find parent layer for module …` is the modloader falling over on its
own module wiring, before FML exists and before any mod is loaded. It exited
non-zero, so it reached the classifier's floor and was scored CRASHED — a
clientside HIGH for a mod whose code never ran (`ars-nouveau`, Forge 48.1.0 on
Minecraft 1.20.2, 2026-08-23).

It is upstream and deterministic rather than a flaky boot: the ServerStarterJar
synthesises a boot layer for the module path in Forge's `unix_args.txt`, and
Forge's `SecureModuleClassLoader` resolves a read module's configuration against
its *direct* parents only, so `java.base` — one level up, in the real boot
configuration — is not found and it throws. cpw's original, which NeoForge runs,
falls back to the platform classloader there; that asymmetry is why the same jar
launches NeoForge and not Forge.

The ServerStarterJar's own pre-launch give-ups join the same guard: with no
run-script it has no launch arguments to read and exits before any loader code
runs.

New rung between launch-failure and killed/OOM. The three guards added in the
commit before this one were red there and are green here.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Red on purpose. `USE_SSJ` defaults to `true` and HELP.md already records the
incompatibility the knob exists for — "people ran into trouble when using Forge
and Minecraft 1.20.2 and 1.20.3". A human reads that and flips it; an unattended
grinder cannot, so every Forge 1.20.2/1.20.3 candidate booted a server that died
in `BootstrapLauncher` before FML and learned nothing.

Pinned on both boots: installing one way and launching the other would cache an
install layer the offline boot cannot use.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The starter jar cannot launch a Forge install whose module path it has to
synthesise a boot layer for. `USE_SSJ=false` is the escape hatch HELP.md already
documents for it ("people ran into trouble when using Forge and Minecraft 1.20.2
and 1.20.3") — a pack author flips it by hand, an unattended grinder never can,
so the knob is now taken by default on every pack it generates. Only `setupForge`
reads it; NeoForge is untouched.

Measured on Forge 1.20.2-48.1.0 installed by its own `--installServer`, booted
under `--network none` with a 3g cap on Temurin 17 (the grinder's Java for that
Minecraft):

  starter jar   IllegalStateException: Could not find parent layer for module
                `java.management.rmi` read by `JarJarMetadata`
                at ...SecureModuleClassLoader.<init>(SecureModuleClassLoader.java:137)
                at ...BootstrapLauncher.main(BootstrapLauncher.java:117)

  argfile       [Server thread/INFO]: Done (5.183s)! For help, type "help"

Same install, same JVM, same flags otherwise. Note the module named in the
failure is *not* the one from the production log (`java.base` read by
`net.minecraftforge.eventbus`) — the iteration order differs per run, which is
why the classifier's guard matches the message and not the module.

Set on the install boot as well: caching a layer installed one way and launching
it the other would break the offline boot.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Red on purpose. `GrindVerdict.verifiedAt` has always been recorded — it is what
the re-verify TTL compares against — but neither output ever showed it, so a
reader could not tell a verdict reached minutes ago from one reached weeks ago
on a loader build long since superseded.

`YEAR/MM/DD` in UTC, zero-padded: the same store then reads the same on any
host, and the column sorts correctly under the table's text sort.

The two exact header expectations move with it, which is a new column and not a
changed behaviour of an existing one.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`GrindVerdict.verifiedAt` was recorded from the start and shown nowhere, so the
overview could not distinguish a fresh verdict from one reached weeks ago on a
loader build long since superseded — which matters most for the rows an operator
acts on, since a HIGH is published to the fallback list.

A `Scanned (UTC)` column on the table and a `Scanned` column closing each CSV
row, both through one `ScanDate` so the page and the file it hands out cannot
disagree. `YEAR/MM/DD`, UTC and zero-padded: the same store reads the same on
any host, and the table's text sort orders the column correctly.

The four guards added in the commit before this one were red there.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Red on purpose, and a gap the Forge argfile fix opens rather than an existing
one: Forge now launches with `@libraries/.../unix_args.txt`, so an install layer
cached without that file fails with the JVM's own

    Error: could not open `libraries/.../unix_args.txt'

verbatim from Temurin 17 — non-zero, no ready-line, currently CRASHED. It is the
same incomplete-cached-install case the jarfile messages beside it already cover;
only the file the boot depends on changed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Closes the gap the Forge argfile switch opened: a cached install layer missing
`unix_args.txt` now fails with the launcher's `Error: could not open …` instead
of the `Unable to access jarfile` this guard already excused, and the boot was
being scored CRASHED for it.

Matched with the launcher's own `Error: ` prefix, so a mod logging "could not
open" about one of its own files still gets judged on its merits.

Red in the commit before this one.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Root CLAUDE.md refactor state (clientside 136 → 138, grinder 344 → 351, counts
read back from the test-result XML), plus the durable facts where the code that
needs them lives:

- container/: the resolvable-hostname requirement and the measurement behind it,
  and a LANDMINE for `/tmp` being `noexec` — JNA cannot load a native library
  there, which is the oshi noise in every crashed console and a latent
  false-positive for any mod needing JNA at load time. Left as a decision, not
  a cleanup: adding `exec` weakens the untrusted-mod posture.
- loader/: why `USE_SSJ=false` is load-bearing for Forge, with the before/after
  boots, and the note to match the message rather than the module.
- report/: the bundled favicon's two routes, and the one shared `ScanDate`.
- clientside/: the loader-bootstrap rung, the eight-rung ladder, and why cpw's
  fallback is the reason the same starter jar launches NeoForge and not Forge.

REFACTOR-LOG carries the blow-by-blow for all five items.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Red, and the red is audit iteration 27's H1: `Error: could not open` is
unanchored and case-insensitive at rung four, while the client-only-class marker
is at rung seven, so a console holding both

    [19:41:26] [main/ERROR] [polytone/]: Error: could not open assets/…json
    java.lang.NoClassDefFoundError: net/minecraft/client/multiplayer/ClientLevel

scores INCONCLUSIVE — a true clientside HIGH dropped:

    expected: <CRASHED> but was: <INCONCLUSIVE>

The launcher writes its message as the whole line; every mod line carries a
timestamp and level prefix. That is the difference the guard has to key on.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Audit iteration 27, H1. `Error: could not open` sat at rung four, unanchored, so
any line containing the phrase was excused — and because the client-only-class
marker is at rung seven, that excuse cost a true HIGH rather than merely adding
noise. `could not open` is a generic verb phrase a mod may log about its own
resources, unlike the three distinctive sentences beside it.

`^` is exact here: the launcher writes the message as the entire line, and lines
are matched one at a time, so it means "the launcher said it" and nothing else.
The KDoc claimed the `Error: ` prefix already achieved this; it did not, and now
says what the anchor is for instead.

Both directions green: the launcher's own line stays INCONCLUSIVE
(`aJvmThatNeverLaunchedIsInconclusive`), a mod's line no longer suppresses the
crash (`aModLoggingCouldNotOpenDoesNotEscapeAClientOnlyClassCrash`, red in the
commit before this one).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Audit iteration 27, M1. `rowHtml` passed every cell through `esc` except the one
added for the scan date, while that function's KDoc — edited in the same commit
that added the cell — asserts that every cell is escaped.

Behaviour-preserving, provably: `esc` rewrites only `& < > " '`, and a
`yyyy/MM/dd` string from a fixed formatter over an `Instant` contains none of
them, so the rendered byte sequence is identical. Hence `refactor:` and hence no
assertion moved — the existing renderer and report-server guards stay green
untouched. What changes is the invariant: uniform escaping is what makes the next
cell safe to add, and a lone exception is a trap for whoever adds it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Audit iteration 28. `columns` and `rowHtml`'s cell list are two hand-maintained
lists that must stay the same length, and nothing checked it: add a header
without its cell and the table still renders, every column past the gap shows its
neighbour's data, and `sortBy(index)` — wired from the header's position — sorts
the wrong one. No existing guard notices, since each looks for one value
somewhere in the page. This branch incremented both lists, which is when they
drift.

A characterization test, so it passes as written; teeth checked in both
directions by breaking the code:

  header with no cell   expected: <9> but was: <8>
  cell with no header   expected: <8> but was: <9>

Counted off the rendered page, so it pins the consequence rather than the two
source lists.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Red on purpose — the guard names a template function that does not exist yet.

Established against real installs on 2026-08-23, because the first hypothesis
("every Forge from 1.20.2 on") was wrong and only measurement showed it:

| Minecraft | argfile the installer writes | ServerStarterJar |
|---|---|---|
| 1.17 – 1.20.1 | `-p <module path>`, cpw securejarhandler | works — cpw's loader falls back |
| 1.20.2 | `-p <module path>`, Forge securemodules | dies before the server starts |
| 1.20.3 onward | `-jar forge-<ver>-shim.jar` | works — the starter jar's own jar mode |

Boots: `1.20.2-48.1.0` on Temurin 17 dies at `SecureModuleClassLoader.<init>`
through the starter jar and reaches `Done (5.183s)! For help` from its argfile;
`1.21.1-52.1.0` reaches `Done (6.593s)!` *through* the starter jar, logging
`Launching in jar mode, using jar: forge-1.21.1-52.1.0-shim.jar`. 1.20.2's
install carries no shim jar and its argfile opens `-p … --add-modules
ALL-MODULE-PATH`; 1.20.3's and 1.21.1's do carry one.

1.20.3 is bypassed even though its shim jar says it would work: HELP.md records
it as affected, and over-including a Minecraft version with two Forge builds in
total costs only hosting compatibility, while under-including it costs a server
that cannot start.

The version matrix includes `26.2` and `26.20.2` — the latter matches 1.20.2
component for component below the major, so a rule that skips the major test
bypasses the starter jar for every modern pack.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The templates already bypassed it automatically for one reason (Java 24+, which
cannot grant the Security Manager SSJ needs to trap the installer's exit). This
adds the second: Minecraft 1.20.2/1.20.3 Forge, which SSJ cannot launch at all.
Until now the only remedy was the operator finding the note in HELP.md and
setting USE_SSJ=false by hand — and an unattended consumer never can.

The affected range was *measured*, and the first hypothesis was wrong. Forge's
installer writes one of two argfiles:

| Minecraft | argfile | ServerStarterJar |
|---|---|---|
| 1.17 – 1.20.1 | `-p <module path>`, cpw securejarhandler | works — cpw's loader falls back to the platform classloader |
| 1.20.2 | `-p <module path>`, Forge securemodules | dies: `Could not find parent layer for module` |
| 1.20.3 onward | `-jar forge-<ver>-shim.jar` | works — SSJ's own jar mode, no synthesised layer |

Boots on Temurin, `--network none`, 3 GiB, through SSJ:

  1.20.1-47.4.0   Done (ready-line reached)
  1.20.2-48.1.0   IllegalStateException at SecureModuleClassLoader.<init>
                  (its argfile from Forge itself: Done (5.183s)! For help)
  1.21.1-52.1.0   Done (6.593s)!, logging
                  "Launching in jar mode, using jar: forge-1.21.1-52.1.0-shim.jar"

1.20.2's install carries no shim jar and its argfile opens `-p … --add-modules
ALL-MODULE-PATH`; 1.20.3's and 1.21.1's do carry one. So "every Forge from
1.20.2 on" — which reading the securemodules source alone suggested, since the
throw is still in 2.2.21 — would have cost every modern pack the hosting
compatibility SSJ exists to provide.

1.20.3 is bypassed on HELP.md's word rather than a boot: it ships the shim, so it
likely works, but it has two Forge builds in total and over-including costs only
that compatibility while under-including costs a dead server.

All three shells, verified by execution rather than by reading, across
1.17.1/1.19.2/1.20/1.20.1/1.20.2/1.20.3/1.20.4/1.21.1/26.2/26.20.2 — bash via
the new test, fish and PowerShell by driving the extracted function in
containers. All ten agree in all three. `26.20.2` is the trap the major test
exists for: it matches 1.20.2 component for component below the major. Whole
templates also pass `fish -n` and PowerShell's own parser.

The duplicated argfile block is folded into one, which is the enabling change
rather than cleanup: a second refusal reason would otherwise be a third copy.
The Java guard's literal text is untouched, so its own pin still holds.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Reverts this branch's `USE_SSJ=false`, now that the templates bypass the
ServerStarterJar themselves for exactly the Minecraft versions it cannot launch.

The knob was the right fix while the templates could not tell those versions
apart, but it is blanket: it also disabled the starter jar for 1.17–1.20.1 and
1.20.4+, where it demonstrably works. The grinder would then boot every Forge
pack by a route almost no user's pack takes — and would never again notice the
starter-jar path breaking. It noticed once, which is the whole reason the
templates now decide, so trading that away to keep a redundant workaround is the
wrong direction.

The guard moves with it: `leavesTheStarterJarChoiceToTheTemplates` now pins that
a pack's own `USE_SSJ` survives untouched, and it fails if the line comes back.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The api landmine gets the table and the trap: `securemodules` 2.2.21 still
contains the throw, so reading the source condemns every modern Forge — and
1.21.1 boots through the starter jar anyway, because from Minecraft 1.20.3 the
installer writes a `-jar <shim>` argfile and the module-path route is never
taken. Widening the rule on source evidence alone would have cost every modern
pack the compatibility the starter jar exists for.

`variables.txt` and the root `HELP.md` (the source the api resource copy is
generated from — the copy is gitignored) now tell operators they should not need
`USE_SSJ` at all, rather than naming two Minecraft versions and leaving them to
act on it.

The grinder's loader notes record why its own knob came back out, and the api
row of the root table records that the two shells which cannot be executed
everywhere are now covered by driving the extracted function in a container.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Read back from the test-result XML: 351, not the 350 the section claimed. The
count moved twice in one branch (an alignment guard added, two PackVariables
guards replaced by one), which is exactly how a number quoted in prose goes
stale.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Red, and it is my own doc comment that is wrong rather than merely unpinned: the
helper's KDoc claims "anything unreadable falls through to a bypass, which is the
safe direction", and it does not —

    Minecraft 26w05a was launched via the ServerStarterJar; expected Forge's argfile

Comparing an unreadable component is not harmless either. bash shouts at the
operator:

    bash: 26w05a: value too great for base (error token is "26w05a")

and PowerShell's `[int]` cast throws outright (`THREW: RuntimeException`), which
the ps1 template would propagate out of the function.

The bypass is the correct polarity for the same reason the Java guard's is: the
argfile path works for every Forge from 1.17 on, while the starter jar has a
known failure, so a version nobody can parse must not be handed to the latter.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Two call sites in `setupForge`, one of them mine and one pre-existing, both
comparing a `$SEMANTICS` component without checking it is a number first. The
new launch-path guard exposed the old one.

What it costs, per shell:

  bash   `bash: 26w05a: value too great for base` printed at the operator
  fish   the comparison is an error
  ps1    `[int]` THROWS — a snapshot-shaped version takes the whole start
         script down, which is a live defect in the launcher-era check

And the polarity was wrong in the new guard: the doc claimed an unreadable
version falls through to the bypass, while it actually fell through to the
ServerStarterJar — the one route with a known failure. The argfile path works
for every Forge from 1.17 on, so unreadable now means bypass. The launcher-era
check keeps falling to the modern era, which is where anything not plainly
1.x-and-old belongs anyway, so its pinned behaviour is unchanged.

Both call sites fixed together because it is one concern: screen a component
before comparing it. Verified by execution in all three shells across
1.17.1/1.19.2/1.20/1.20.1/1.20.2/1.20.3/1.20.4/1.21.1/26.2/26.20.2/26w05a —
all eleven agree, no shell complains, and both templates still pass `fish -n`
and PowerShell's own parser.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Five reports off the live grinder, and two of them were bad clientside evidence
rather than cosmetics.

* Container name resolution. `--network none` gives the daemon no address to
  write into `/etc/hosts`, so a boot could not resolve its own hostname and every
  one opened with three `UnknownHostException` stacktraces from log4j's
  `getLocalHost()`. Boot containers now carry a fixed hostname mapped to
  loopback, which is the entry a networked container gets for free.

* A Forge server that never bootstrapped was scored as a mod crash. Two fixes:
  the classifier no longer reads a loader that died in `BootstrapLauncher` as
  CRASHED, and the *templates* now bypass the ServerStarterJar for the Minecraft
  versions it cannot launch — so the pack boots instead of needing an operator to
  find the note in HELP.md.

  The affected range was measured, and the obvious hypothesis was wrong. Forge's
  installer writes either a `-p <module path>` argfile or a `-jar <shim>` one,
  and only the first defeats the starter jar; `securemodules` still *contains*
  the throw at 2.2.21, but from Minecraft 1.20.3 nothing reaches it. So the range
  is 1.20.2/1.20.3, not "everything from 1.20.2 on" — which would have cost every
  modern pack the hosting compatibility the starter jar exists for. Boots: 1.20.1
  and 1.21.1 reach the ready-line *through* it, 1.20.2 dies at
  `SecureModuleClassLoader.<init>` and reaches `Done (5.183s)!` from its argfile.

* A favicon, served out of the jar under both `/favicon.ico` and `/favicon.png`,
  and a `Scanned` date on the overview and in the CSV through one shared
  formatter.

* polytone's verdict was already correct; what its console also showed is JNA
  failing against a `noexec` tmpfs. Landmined rather than fixed — the only remedy
  weakens the untrusted-mod sandbox, which is an owner's decision.

Three audit iterations (27-29) found five further defects in this branch's own
work, all fixed here: an over-broad launcher excuse that could suppress a true
clientside HIGH, an unescaped table cell, uncoupled header/cell lists, an
inverted fail-safe polarity whose doc claimed the opposite, and an unscreened
version comparison — the last of which was partly pre-existing and made the
PowerShell template throw on a snapshot-shaped version.

Every code commit is preceded by its own red `test(...)` commit. Templates are
verified by execution in all three shells, fish and PowerShell in containers,
across eleven Minecraft versions. `develop`'s own guards run green against this
branch's production code — api 359, clientside 136, grinder 344 — with the only
two failures being the CSV header assertions the `Scanned` column deliberately
changed.

Suites: api 356 -> 361, clientside 136 -> 139, grinder 344 -> 351, zero failures.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Red against a live daemon: `sh: line 0: /tmp/echo: Permission denied`.

Docker mounts a `--tmpfs` `nosuid,nodev,noexec`, and the rootfs is read-only, so
nothing can write a `.so` and map it executable. Measured with the production
posture otherwise unchanged (no network, read-only rootfs, all caps dropped,
no-new-privileges), JNA loading its own native library:

  /tmp:rw        UnsatisfiedLinkError: /tmp/jna….tmp: failed to map segment
                 from shared object
  /tmp:rw,exec   JNA-OK pointerSize=8

That failure reaches a boot console as `NoClassDefFoundError: Could not
initialize class com.sun.jna.Native` — seen in `Modrinth-polytone-NeoForge.log`,
harmless there, but a mod needing JNA *at load time* would die for the
environment and arrive at the classifier looking like a crash.

Asserted by executing a binary out of `/tmp`, because the mount flag is the
mechanism and running the file is the promise. The flags are then checked for
what must NOT be given away with it: `nosuid` and `nodev` stay.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Owner-approved weakening of the sandbox, and the cost is smaller than the
benefit turned out to be.

Docker mounts a `--tmpfs` `nosuid,nodev,noexec` and the rootfs is read-only, so
nothing inside a boot could write a shared object and map it executable. The
report that started this was cosmetic — `Could not initialize class
com.sun.jna.Native` in polytone's crash report — but booting a real server under
the actual posture showed it is not:

  noexec   [io.netty…NativeLibraryLoader]: /tmp/libnetty_transport_native_epoll_
           aarch_64….so exists but cannot be executed even when execute
           permissions set; check volume for "noexec" flag
           [minecraft/ServerConnectionListener]: Using default channel type

  exec     [minecraft/ServerConnectionListener]: Using epoll channel type

So every boot the grinder has ever run fell back from Netty's native epoll
transport to NIO. And JNA itself, with the production posture otherwise
unchanged (no network, read-only rootfs, all caps dropped, no-new-privileges):

  rw        UnsatisfiedLinkError: /tmp/jna….tmp: failed to map segment from
            shared object
  rw,exec   JNA-OK pointerSize=8

What is given away: a mod can run a native binary it wrote into `/tmp`. Against a
workload that is already an untrusted JVM — an arbitrary-code execution engine —
inside a container with no network, no capabilities, no privilege escalation, a
read-only rootfs and a non-root user, none of which changes. `nosuid` and `nodev`
stay: docker applies both even when only `exec` is asked for, verified rather
than assumed (`rw,exec` and `rw,nosuid,nodev,exec` both yield
`rw,nosuid,nodev,relatime`).

`aBootCanExecuteFromItsTmpfsWhileKeepingTheRestOfItsHardening` was red on the
commit before this one (`/tmp/echo: Permission denied`) and is green here; it
executes a binary out of `/tmp` rather than reading the mount flag, then checks
the flags for what must not have gone with it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
docs: record the tmpfs exec decision and what noexec was costing
Some checks failed
Test / build (push) Failing after 13m9s
Continuous / Build Install4J Media (push) Successful in 11m50s
Continuous / Continuous Pre-Release (push) Successful in 6m14s
Documentation / Writerside webhelp (push) Successful in 1m44s
Continuous / Build JAR (push) Successful in 13m32s
Docker Test / build image (push) Successful in 17m50s
Qodana / scan (push) Successful in 16m21s
Continuous / Build AppImage (x86_64) (push) Successful in 2m48s
Continuous / Build AppImage (aarch64) (push) Successful in 3m19s
Documentation / Help image (push) Successful in 6m34s
Qodana / notify (push) Successful in 20s
4ffe309f97
The container landmine flips from "do not add exec without deciding" to the
decision itself, with the measurement that changed its justification: noexec was
not merely producing JNA noise in crash reports, it was silently costing every
boot Netty's native epoll transport.

Also records the half that must not drift: nosuid and nodev are still applied,
verified rather than assumed, so nobody "restores" noexec believing it was free
or widens the grant further.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`:serverpackcreator-api:dokkaGeneratePublicationJavadoc` is now clean (8 → 0).
The reported list was seven; `NeoForgeInstance` was the eighth in the same run.

None of them is a signature restatement — where there was nothing to add beyond
the name, the doc says what the *type* is for instead:

- `ForgeTomlScanner.client` — that it is the TOML's own upper-case spelling, and
  that it is a `side=CLIENT` on the *platform* dependency that marks the mod.
- `ScannedMod.toString` / `ModDependency.toString` — that the dependency list is
  expanded rather than left as object identities, which is why it is hand-written.
- `NetworkConfig.Companion` — that the keys are public so a host names the
  constant instead of retyping the property string.
- `WebUtilities.Companion` — that HasteBin's ceilings are private because they
  are that service's limits, not this class's promise.
- `SPCGenericListener` — that it carries no payload, and to reach for a typed
  listener when the details matter.
- `Comparison` — why three values exist: an era boundary wants EQUAL_OR_NEW, an
  update check wants NEW.
- `NeoForgeInstance` — why it is an interface at all: NeoForge's 1.20/1.20.1
  builds live under the legacy `net/neoforged/forge/` group and address their
  installer by both versions, everything later by the bare NeoForge version, and
  `NeoForgeLoader` hides that split behind this type. Verified against
  `OldNeoForgeInstance`/`NewNeoForgeInstance` rather than assumed.

Also fixes the one such warning this session introduced: `ReportServer.Companion`
(grinder 4 → 3), whose member was documented while the companion holding it was
not.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
clientside 20 → 0, grinder 3 → 0. api was already 0.

Two of these were not missing prose at all, which is why they were worth reading
rather than filling in:

- `GrindPool.awaitStop` had a fourteen-line doc explaining why the interrupt is
  load-bearing — orphaned, because `trackedWorkerCount` had been inserted between
  the block and the function it described. Reordered so the doc lands on
  `awaitStop` again; nothing was written.
- `LoaderVerdict` documents its properties in a class-level `@param` block, and
  `@param bootCrashExcerpt` was simply absent from the list. Added, saying the
  part that is easy to get wrong: the excerpt survives a later pass stripping the
  crash of its standing, because the server did crash and that is still worth
  diagnosing.

Three single-line data-class constructors were reshaped to one parameter per line
so each could carry its own doc — `RunResult.Completed`, `RunResult.NotStarted`
and `ClientsideListEditor.Entry`. Names, types, order and defaults unchanged;
this is the enabling reshape the conventions already sanction, not a redesign.
`BootVerifier.Prepared.Ready`/`Failed` were already one per line and just needed
the docs.

The seven `X.Companion` blocks get a sentence on what the block is *for*, not a
restatement of its members: that `BootVerifier`'s holds the pure re-check
predicates precisely because they need no verifier state, that
`CurseForgePlatform`'s quotes CurseForge's limits rather than choosing them, that
`SuspendAwareDeadline`'s lives outside the class so both boot paths measure
against the same number.

Also filled in `JarScan`'s three bare entries while in that file — it had one
documented value out of four, and the useful distinction is that `ERROR` is not a
sideness claim.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
First batch of the app module's dokka backlog: 626 -> 504 -> 442 undocumented.

Grounded rather than generated. The GuiProps icons name the component that
actually uses each one, found by grepping the call sites (folderAddIcon is the
inclusions editor's add-source button, closeIcon is 8x8 because it sits inside a
tab label); the ConfigEditor accessors name the widget each reads or writes,
which is the one thing the ServerPackConfigTab interface doc cannot say.

Where a whole class shares one fact, it went on the class: BeanConfiguration's
beans all re-expose one ApiWrapper, and the note worth leaving is that they call
apiWrapper() rather than taking it as a parameter — which returns the singleton
only because @Configuration is proxied by default, so proxyBeanMethods = false
would silently build a second wrapper per bean.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
EOF
442 → 318 undocumented.

The entity `equals`/`hashCode` overrides were the ones worth reading rather than
filling in, because each deliberately ignores fields and the omissions are the
contract:

- `ModPack.equals` compares project, version, name, source and hash and ignores
  the id, dates, size, counters, status and file — it answers "have we already
  got this upload?", which is what the duplicate-check asks.
- `ServerPack.equals` ignores the counters and the date: a pack downloaded since
  is still the same artefact, and treating it otherwise defeats the dedup.
- `RunConfiguration.equals` is exact and element-wise across all three embedded
  lists, and that exactness is load-bearing — it is how the service decides an
  incoming configuration may be reused, so a looser comparison hands somebody
  else's server pack back.

`ScrollTextField`/`ScrollTextArea` share one fact that belonged on the class:
every member forwards to the *wrapped* text component, not to the scroll pane.
The drop-target overrides get the reason spelled out — Swing delivers a drop to
whatever the mouse is over, which is the scroll pane, so without them dropping a
file on a path field silently does nothing.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
318 -> 226 undocumented. Same standard: where a class shares one fact it goes on
the class, and the fact is one a reader could not get from the signatures.

- TabbedConfigsTab.checkAll carries the warning that matters about it: it is what
  the 500 ms typing debounce fires, across every open tab, so anything added
  there is paid per keystroke-pause times the user's open configs.
- ServerPackCreator.apiWrapper records why it is built before the first log
  statement — ApiProperties is log4j's own ConfigurationFactory, so logging first
  constructs one against an unresolved home.
- RunConfigurationService says the reuse is the point, and VersionMetaResponse
  explains why Fabric is a flat list while Forge is keyed by Minecraft version.
- FileSystemStorageService's single-line constructor was reshaped to one
  parameter per line so both could be documented, which is where the note lives
  that the MessageDigest is injected because it is stateful.

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

Two landmines already recorded in the module's CLAUDE.md now sit on the code
they constrain, where an implementer will actually meet them:

- SettingsEditor's interface doc carries the reload-after-save rule, because
  several settings normalise on write or read, so a panel compared against its
  pre-save widget values reports unsaved changes forever. SettingsHandling.save
  repeats it at the call site that must not drop the reload.
- RunConfigurationRepository's duplicate lookup warns against spelling the query
  with 'In', which derives to "contains any of" rather than "equals" — the
  earlier form matched a configuration sharing a single mod and handed back
  somebody else's server pack.

DocumentChangeListener gets the reason it exists (Swing reports insert, remove
and attribute changes separately; every consumer here wants all three the same),
and ErrorEntry gets the consequence of its message being its own @MongoId: a run
failing identically a hundred times writes one document, not a hundred.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Last batch: the CLI verbs, the remaining web layer, and the GUI leaves.

Reading each one rather than filling it in caught two things worth having:

- LarsonScanner has two companions, and an earlier pass in this session put the
  outer one's prose ("the scanner's fallback colours") on the *inner* one, which
  holds rendering-quality levels. Wrong text on the wrong member is worse than
  none — both now say what they actually hold.
- `UpdateDialogs.updateButton` looked documented and was not: the line above it
  is a commented-out block ending in `*/`, which the insertion pass mistook for a
  doc comment. Only dokka still reporting it revealed that.

Facts recorded where an implementer will meet them rather than only in the module
notes: `ControlPanel.panel` carries the anchoring rule (a running generation is
tied to the always-visible bar, so a tab switch cannot cancel it),
`FileCleanupSchedule` carries the direction of its danger (it deletes files whose
ids are absent from the database, so it must never run against one it cannot
read), and `SmartScroller.adjustmentValueChanged` states its whole purpose — a
log pane that always jumps to the end is unreadable while it is being read.

Module totals now 0/0/0/0. Suites unchanged: api 361, clientside 139,
grinder 352, app 149, zero failures.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
docs(plugin-example): document the addons logger companion
All checks were successful
Continuous / Continuous Pre-Release (push) Successful in 6m42s
Continuous / Build JAR (push) Successful in 15m6s
Documentation / Help image (push) Successful in 6m25s
Continuous / Build AppImage (aarch64) (push) Successful in 3m43s
Docker Test / build image (push) Successful in 21m1s
Qodana / scan (push) Successful in 20m1s
Qodana / notify (push) Successful in 48s
Test / build (push) Successful in 19m10s
Documentation / Writerside webhelp (push) Successful in 2m34s
Continuous / Build AppImage (x86_64) (push) Successful in 2m30s
Continuous / Build Install4J Media (push) Successful in 13m18s
57d22b57cc
The last undocumented declaration in the repository, and it only turned up when
the dokka check was run across *every* module rather than the four reported —
this one is in the plugin example, which is documentation-by-example and so is
exactly where a gap costs most.

The doc says the part a plugin author needs: `AddonsLogger` is not an arbitrary
name. ServerPackCreator configures that appender for plugins, so logging through
it lands in the addons log instead of being mixed into SPC's own.

Repo-wide dokka is now clean.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Three guards, all red against the current store:

  aStoreWrittenByANewerBuildStillLoads       expected 1, got 0
  anUnreadableStoreIsPreservedRatherThanOverwritten  expected 1, got 0
  oneUnreadableRowDoesNotDiscardTheOthers    expected 1, got 0

JsonVerdictStore builds a bare jacksonObjectMapper(), so
FAIL_ON_UNKNOWN_PROPERTIES is on and readValue<List<GrindVerdict>> is
all-or-nothing. A store written by a newer build therefore fails to read,
load() logs "starting empty", and the very next record() calls persist(),
which serialises the whole map over the file. One unknown field costs the
entire store.

aCorruptFileDegradesToEmpty pins "start empty rather than crash" — it does
not pin "and then don't destroy the file", which is why the suite stayed
green while the store was lost.

The forward direction is already safe: Jackson applies Kotlin constructor
defaults for absent properties, which is why the live store's 875 rows
carry no projectId key and load fine.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Turns the three guards from the previous commit green. Three changes, all
protecting the store against being destroyed by its own recovery path:

- FAIL_ON_UNKNOWN_PROPERTIES off, so a store written by a newer build
  survives a downgrade instead of reading as corrupt.
- load() reads row by row rather than as one document, so a verdict this
  build cannot make sense of (a Confidence constant added later, say)
  costs that row instead of every row.
- Whatever could not be read is copied aside as <name>.unreadable-<epoch>
  before returning, because record() persists the whole map immediately
  and would otherwise overwrite the evidence with whatever survived.

A copy rather than a move: the store must still be where the operator
expects it, and the rescued bytes are what a later build or a human needs
to recover the dropped rows.

aCorruptFileDegradesToEmpty is unchanged and still green -- the service
still starts empty rather than crashing. Only the destruction is new.

Grinder suite: 355 tests, 0 failed, 25 skipped.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Red as a compile error, which for a field that does not exist yet is the
only red available:

  GrinderTest.kt:98  Unresolved reference 'declaredClientSide'
  GrinderTest.kt:99  Unresolved reference 'declaredServerSide'
  GrinderTest.kt:100 Unresolved reference 'jarScan'
  GrinderTest.kt:102 Unresolved reference 'bootedLoader'

LoaderVerdict already carries all four and ClientsideReportRenderer
already prints the sidenesses and the jar scan in the CLI's markdown
table -- they simply never reach GrindVerdict, so the grinder's own
report cannot show what the platform declared versus what the jar did.

aVerdictWithoutRecordedSidenessIsNullRatherThanUnknown pins the
distinction that matters: a legacy row has no answer, whereas CurseForge
publishes none for every row (CurseForgePlatform.resolve hardcodes
UNKNOWN). Collapsing both onto UNKNOWN would make the ~870 pre-existing
verdicts indistinguishable from every CurseForge verdict.

loaderVerdict() gains four defaulted parameters; every existing call site
is unchanged.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Turns the previous commit's guards green. GrindVerdict gains four
defaulted fields -- declaredClientSide, declaredServerSide, jarScan and
bootedLoader -- populated at the single site that maps a LoaderVerdict
onto a GrindVerdict, in Grinder's per-loader loop.

The clientside engine already decided all four, and the CLI's
ClientsideReportRenderer already prints the sidenesses and the jar scan
in its markdown table. They simply stopped at LoaderVerdict, so the
grinder's own report could show what the verdict *was* but never what it
was based on.

Sideness and jarScan are nullable rather than defaulted to UNKNOWN:
UNKNOWN is a real answer a platform gives -- CurseForge gives it for
every project, since CurseForgePlatform.resolve hardcodes it and never
queries CurseForge for a sideness field -- while null means the question
was never recorded. A report that renders both as "UNKNOWN" would tell a
reader that ~870 legacy rows had been checked and found not-client-side.

No migration step: absent properties take the Kotlin constructor
defaults, which is the same mechanism projectId already relies on, and
the preceding commit made unknown properties survivable in the other
direction too.

Grinder suite: 357 tests, 0 failed, 25 skipped.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
runPrepared had three call sites -- the first boot, the newest-build
re-check, and each other-version re-check -- each repeating the same four
collaborators. A private boot(pack) now owns that call and the three
sites delegate to it.

Behaviour-preserving, and the evidence is that the whole clientside suite
stays green with no test edited at all: 139 tests, 0 failed, 0 skipped.

The reason to do it before anything else: staging wipes the attempt
directory on every stage, so anything that must happen per attempt has to
be added in exactly one place. Added at two of the three sites it would
silently lose precisely the re-check evidence, which is the evidence a
disputed verdict turns on.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Red: Unresolved reference 'BootArtifacts' -- the unit does not exist yet.

Eight guards over the artifacts a boot produces. Today only the container
console is kept, only for a CRASHED boot, and the server's own logs/ and
crash-reports/ are read nowhere in the codebase: the sole mention of
those directory names is InstallLayerSnapshot excluding them from the
loader-install cache. A mod wrongly cleared, or an error in the checking
itself, therefore leaves nothing to look at.

The guards that carry weight rather than describe shape:

- anOversizedArtifactIsNeverReadWholeIntoMemory measures the allocation
  against a 64 MiB log, mirroring CrashLogStoreTest's existing guard.
  Capping after readText() is how a daemon dies of an OOM it swallowed.
- theArtifactCountIsCappedAndEveryOmissionIsNamedInTheIndex: a cap that
  drops files silently reads as "this is everything".
- aPackWithNoLogsAndNoConsoleYieldsNothing stops the index from being
  emitted to announce its own emptiness.
- onlyANonSurvivedBootIsWorthKeeping puts retention in clientside so the
  CLI verb and the daemon cannot disagree about it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Turns the previous commit's guards green. BootArtifacts.collect reads a
staged pack's logs/ and crash-reports/ alongside the captured console and
returns them as separate entries.

Separate rather than one blob because they disagree in useful ways:
logs/latest.log is log4j's file appender, so it carries entries stdout
never sees and misses the shell and launcher output stdout has. That
difference is what a disputed verdict turns on.

Bounded three ways, each observable rather than silent:
- MAX_BYTES_PER_ARTIFACT keeps the tail via RandomAccessFile.seek, never
  readText-then-trim, with the truncation written into the content.
- MAX_ARTIFACTS keeps the newest 8 by mtime.
- Everything found is named in an index.txt entry, kept or not, with the
  reason -- a capped set of logs is otherwise indistinguishable from a
  complete one.

An unreadable file becomes an artifact saying so rather than vanishing:
failing to read a crash report is itself evidence about the boot.

worthKeeping() puts retention here rather than in the grinder, so the CLI
verb and the daemon cannot disagree about which boots are worth keeping.

Fixed one guard's own expectation while making it green:
anOversizedArtifactIsNeverReadWholeIntoMemory asserted a single artifact
where collect correctly also returns the index. The memory assertion it
exists for is unchanged.

Clientside suite: 146 tests, 0 failed, 0 skipped.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Red: "Too many arguments for runPrepared(...)" and "Unresolved reference
attemptName" -- neither the hook nor the accessor exists yet.

Six guards. The two that are not about the happy path:

- onlyOneCallSiteInvokesRunPrepared asserts the structure the feature
  depends on. runPrepared happens three times per candidate (first boot,
  newest-build re-check, other-version re-check), and a hook added at two
  of three would silently cover the first boot and miss both re-checks --
  a boot that gets re-checked is by definition a contested one. The
  previous commit collapsed the call sites; a later edit can undo that
  with no behavioural test noticing, so the structure is pinned directly.
  Same justification as ReportBindWiringTest and ShutdownWiringTest.
- aThrowingSinkDoesNotFailTheBoot holds the sink to the same rule as the
  live-log sink beside it: a verdict that already ran must not be lost to
  a full disk.

theAttemptNameIsTheStagingDirectoryItBootedFrom pins deriving the tuple
from the log file's parent rather than threading platform/slug/loader
through every attempt -- and it stays correct for the other-version
re-check, which stages into the crashing loader's own directory.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Turns the previous commit's guards green. BootVerifier gains a defaulted
bootArtifactSink, invoked from runPrepared once the outcome is classified,
and Prepared.Ready gains an attemptName accessor deriving the
(platform, slug, loader) tuple from its log file's parent.

Inside runPrepared, not after verify() returns, and that is the whole
point: stageBootPack deleteRecursively()s the attempt directory on every
stage, so the newest-build re-check and each other-version boot destroy
the previous attempt's serverpack/ and boot.log. restoreDecisiveConsole
already repairs the console of the *decided* attempt; everything else --
the server's own logs, its crash reports, and every attempt that was not
the decisive one -- was simply gone. Those are what a contested verdict
turns on, since a crash is the only outcome that reaches HIGH.

attemptName is derived rather than carried as three more fields, and it
stays correct for the other-version re-check, which deliberately stages
into the crashing loader's directory rather than its own.

The invocation is wrapped in runCatching, the same rule the live-log sink
beside it follows: a verdict that already ran must not be lost because a
disk filled up.

Both existing construction sites (VerifyClientsideCommand,
ContainerCandidateVerifier) compile untouched -- the parameter is
trailing and defaulted.

Clientside suite: 152 tests, 0 failed, 0 skipped.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Pure rename, ahead of the store learning to keep more than a crash
console. The untrusted-name machinery is carried verbatim rather than
rewritten -- isInsideStore's string check before the filesystem is
touched, the canonical-parent confirmation against a symlink, the
seek-based tailOf and its truncation notice. That code is scarred and
documented; retyping it is how the path-traversal landmine comes back.

Behaviour-preserving: 357 tests, 0 failed, 25 skipped, with no assertion
edited -- only the class name in BootLogStoreTest and the four reference
sites.

The on-disk directory (<home>/crash-logs) and the two HTTP routes are
deliberately untouched here; both are documented, and one is bookmarked
by anyone who has used the report.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Red: the constructor takes no budget, attemptKey does not exist, and keep
still takes a single console File.

Eleven guards over what the store has to become now that a boot is more
than one console. The ones carrying weight:

- pruningDropsAPreviousGrindsOrphansAndNothingElse is the load-bearing
  bound. Deterministic naming only replaces *this* grind's attempts; a
  re-grind whose re-check samples a different loader or Minecraft line
  writes new names and strands the previous grind's files forever. Without
  pruning, this feature re-introduces exactly the growth class the reaper
  exists for -- the one that reached 98 GB across 1750 directories.
- aSlugContainingTheSeparatorIsStillFoundByItsTuple: slugs and loaders
  both contain '-', so a stored name can never be parsed back apart. The
  lookup has to rebuild the prefix instead.
- everyAttemptOfOneCandidateIsKeptSideBySide: the re-check boots are the
  evidence a contested crash is argued with, so they cannot overwrite each
  other the way boot.log does in staging.
- theBudgetSweepDeletesOldestFirstUntilUnderTheCeiling, paired with
  aStoreUnderItsBudgetIsLeftAlone so the backstop cannot fire constantly.
- aNameThatEscapesTheStoreStillReadsNothing re-asserts the inherited
  traversal guard against the reworked lookup.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Turns the per-attempt store guards green, and completes the capture path
started in -clientside.

BootLogStore now keeps an attempt's whole evidence set rather than one
crashed console: keep() takes BootArtifacts, namesFor() rebuilds the
tuple prefix (a stored name can never be parsed apart -- slugs and
loaders both contain '-', which is why '~' is the separator),
pruneExcept() drops a previous grind's orphans, enforceBudget() is the
ceiling, and adoptLegacy() moves the superseded crash-logs directory in
once, renaming each console into this store's own shape so it is
reachable from its row rather than merely present on the index.

ContainerCandidateVerifier's post-hoc keepCrashConsoles is gone: the sink
fires per attempt, during the boots, because staging wipes the attempt
directory on every stage and nothing readable survives to the end of
verify(). What remains after a candidate is the prune and the budget
sweep.

Two defects found reviewing my own work rather than by a test:

- The record of what a grind wrote was an instance field, but ONE
  ContainerCandidateVerifier serves every GrindPool worker -- so one
  candidate's prune would delete logs another had just written. Same
  cross-candidate class as the unqualified attempt directory that wiped a
  pack mid-boot. Now per-invocation, pinned by
  candidatesPrunedInParallelDoNotDeleteEachOthersLogs.
- namesFor lists the store on every call and the table asked per row, so
  a 875-row page did 875 directory listings. ReportServer now snapshots
  one listing per request and groups it on the owner prefix.

Report: a Logs column replaces Crash log, rendering every kept artifact
behind a <details> disclosure -- collapsed, so a heavily re-checked row
cannot dominate the table, and native HTML so the page still needs no
JavaScript and still opens from disk. /boot-log and /boot-logs are the
routes; /crash-log and /crash-logs stay as aliases, because both are
documented and an operator has them bookmarked.

Knobs SPC_GRINDER_BOOT_LOGS and SPC_GRINDER_BOOT_LOG_BUDGET_MIB (2048)
land in the entry point, README table and systemd unit together, as
ReadmeConfigurationTest and SystemdUnitConfigurationTest require.

Existing tests changed, and why -- all behaviour changes, hence feat:
- ContainerCandidateVerifierReapTest: keepCrashConsoles is gone; its
  retention moved into the per-attempt sink. Rewritten against it, with a
  survived-keeps-nothing case added.
- ReportServerTest.servesAKeptCrashLogAndRefusesToEscapeItsStore: new
  keep() signature and routes; both indexes and the alias now asserted.
- VerdictReportRendererTest.onlyARowWithAKeptCrashLogGetsALink: a row now
  carries many logs, so it asserts the disclosure and the label instead.
- linksEveryEndpointBesideTheDownloadButton: /crash-logs -> /boot-logs.

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

Grinder suite: 364 tests (was 351), 0 failed, 25 skipped.
Red: Unresolved reference 'ConsoleRuleFile' / 'ConsoleRuleSet' -- neither
the file loader nor the rung exists yet.

Twenty guards over a rule file that turns a newly-observed clientside
signature into a file edit rather than a release.

The verified gap they close: FML refuses a client-only class with
"Attempted to load class ... for invalid dist DEDICATED_SERVER", and
NeoForge's ServerStarterJar can print that and still exit 0, so the
ladder's exit-code fallback scores it INCONCLUSIVE. That string appears
nowhere in this codebase --
aRuleCrashesAConsoleThatAZeroExitWouldHaveExcused asserts both halves,
the INCONCLUSIVE without a rule and the CRASHED with one.

Placement is the design decision, so it is pinned from both sides:

- ABOVE the exit code and the built-in client-class marker, so a rule can
  raise a crash the exit status excused *and* excuse a console the marker
  would have crashed (a known-broken loader build printing a client
  class).
- BELOW the timeout and killed/OOM guards, so a hand-edited file can
  never manufacture a HIGH out of host trouble. That is not hypothetical:
  a memory-starved Docker VM once turned fat mods into HIGH-confidence
  clientside crashes systematically, and the same principle is already
  pinned for the built-in marker.
- Below a ready-line, which stays decisive: the server demonstrably ran.

The failure modes carry the most weight, because "keep the last good
rules" hides breakage unless it is surfaced:

- anUnknownVerdictDropsTheRuleRatherThanDefaultingToCrashed -- defaulting
  to the one value that reaches HIGH is how a typo publishes a wrong
  fallback-list entry.
- anUncompilableRegexDropsOnlyItsOwnRule, so one typo cannot disable
  every rule beside it.
- aMalformedFileKeepsTheLastGoodRulesAndReportsTheError -- both halves.
- anAbsentFileIsAnEmptyRuleSetAndNotAnError, and anEmptyRuleSetChangesNothing
  over all four ladder outcomes, so the default install behaves exactly as
  it did before rules existed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Turns the previous commit's guards green. A rule file maps console text
to a boot result, so a newly-observed clientside signature is a file edit
rather than a release.

Rung 7 of the ladder, and the placement is the design:

  1 ready-line  2 timed out  3 setup abort  4 launch failure
  5 loader bootstrap  6 killed / OOM
  7 *** the operator's rules ***
  8 client-class marker  9 dependency failure  10 exit code

Below 1-6 because all of those mean the mod never got a fair run, and a
hand-edited file must never be able to manufacture a CRASHED -- hence a
HIGH -- out of host trouble. A memory-starved VM doing exactly that to
the biggest mods, systematically, is on this engine's record. Above 8-10
so a rule can raise FML's "for invalid dist DEDICATED_SERVER" on a zero
exit, which is the verified gap, and can excuse a console the built-in
marker would crash.

classify() keeps its three-argument form, delegating with an empty rule
set, which is why theGuardOrderIsPinnedAsAWhole and ~20 other existing
BootLogClassifierTest cases stayed green with no edit -- the rung is
purely additive. That guard was extended rather than joined by a sibling,
because it pins the order *as a whole* and a second place stating the
order is how the two drift.

A rule with no verdict identifies without deciding: it names itself on
the Classification and lets the ladder settle the result. First match in
file order wins -- the file's order is the only precedence its author can
see.

The loader is deliberately unforgiving in one direction and forgiving in
the other: an unknown verdict string, a missing id, a missing pattern or
an uncompilable regex drops that rule and records why, while a whole-file
parse failure keeps the last good set. Defaulting an unrecognised verdict
to CRASHED would let a typo publish a wrong entry to everyone polling the
fallback list.

Reload is a (lastModified, length) stat on read, not a WatchService:
classify runs once per boot and a boot takes minutes, so the check costs
microseconds far below once a second, while a watcher costs a thread, a
platform-specific backend and tests that need sleeps. The one-second
mtime granularity that makes the pair necessary is landmined in the doc.

Clientside suite: 170 tests (was 152), 0 failed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Red: "Too many arguments for outcomeFor(...)" and "Unresolved reference
firedRule".

Provenance as a *field*, not only prose. "How many verdicts did rule X
decide?" is the only way to find a rule firing too broadly, and a
sentence inside a human-readable detail string cannot answer it -- an
unauditable heuristic deciding what reaches everyone polling the fallback
list is the recurring defect class here.

anOutcomeNoRuleTouchedIsUnchanged compares the detail of a boot run
without rules against one run with rules that do not match, so the
default install's output is pinned byte-for-byte rather than assumed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Turns the previous commit's guards green. BootOutcome gains firedRule,
outcomeFor takes the rule set and threads the match through, and
BootVerifier gains a consoleRules supplier.

A supplier rather than a captured value, asked once per attempt: that is
what makes an edit during a multi-day run take effect on the next boot
instead of the next restart, which is the whole point of a hot-reloadable
file.

The rule appears twice on purpose. In the detail, as
"... [rule 'fml-invalid-dist': FML refused a client-only class]", for
whoever reads the report; and as a field, for whoever has to count how
often a rule fired before judging it too broad. Prose cannot answer the
second question, and an unauditable heuristic deciding what gets
published to everyone polling the fallback list is the recurring defect
class here.

Parameter ordering is load-bearing, and I got it wrong first: `rules` was
appended AFTER the bootArtifactSink function parameter, which silently
re-bound every trailing-lambda call site to it --
BootVerifierArtifactSinkTest stopped compiling with "actual type is
(...) -> ..., but 'ConsoleRuleSet' was expected". The sink stays last so
callers can pass it as a trailing lambda, and the doc says so.

Clientside suite: 173 tests (was 170), 0 failed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Completes the rule engine: SPC_GRINDER_BOOT_RULES (default
<home>/boot-rules.json) reaches BootVerifier through
ContainerCandidateVerifier as a supplier, so a file edited during a
multi-day run takes effect on the next boot.

firedRule is carried LoaderVerdict -> GrindVerdict -> report and CSV, as
a column rather than only a phrase inside Detail. "How many verdicts did
this rule decide?" is the only way to find a rule firing too broadly, and
sorting the table on it answers that at a glance.

/status gains bootRules { source, ruleCount, errors }. That is not
decoration: the loader deliberately keeps the last good rule set when a
save breaks the file, which would otherwise hide the breakage completely
-- an operator would see rules that silently stopped matching.

deploy/boot-rules.example.json ships beside the unit with the two worked
examples, including one that deliberately carries NO verdict, since
"NoClassDefFoundError on another mod's screen class" is usually a
dependency problem rather than sideness.

Three existing expectations changed, all the CSV header gaining Rule:
VerdictCsvExporterTest's two header assertions and
VerdictReportRendererTest.embedsTheCsvForTheDownloadButton. An existing
expected value changing is why this is feat: and not refactor:.

Knob documented in README and the systemd unit in this same commit, as
ReadmeConfigurationTest and SystemdUnitConfigurationTest require.

Clientside 173 tests, grinder 364 tests, 0 failed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
An unknown verdict string used to drop the rule, and an absent one used to
mean "annotate, let the ladder decide". Both now resolve to INCONCLUSIVE.

Why that is the safer direction, and it is not obvious: dropping a rule
sounds neutral, but it hands the console straight back to a ladder that
may reach CRASHED on its own -- and CRASHED is the one outcome that
publishes to everyone polling /as-properties. INCONCLUSIVE is the only
verdict that can never reach HIGH, so a rule somebody has not finished
thinking about now costs coverage instead of risking a false positive.

The tradeoff, stated because it reverses an earlier requirement ("if none
is specified, determine by grinder"): pure annotate-only rules no longer
exist. A matching rule always decides. The shipped example's second rule
relied on that, and now documents INCONCLUSIVE as what it means.

Failing safe still does not mean failing silently -- a misspelt verdict
is recorded in ConsoleRuleSet.errors and surfaced on /status, so an
operator sees the typo rather than wondering why a rule "stopped
working".

Drops and fallbacks now go deliberately opposite ways: no id or no
pattern still DROPS the rule (it could never be traced back to, or could
never match), while an unreadable verdict FALLS BACK.

Clientside suite: 173 tests, 0 failed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Red: Unresolved reference 'versionConstraint' -- ModDependency has no
such field yet -- plus a changed expected value in
fabricDependenciesAreRecordedWithoutThePlatform.

The exclusion sets conflate the platform with a mod, and their own doc
comment gives the rule they break: "ids that are the platform rather than
a mod".

  FabricScanner  (fabric|fabricloader|java|minecraft)
                  ^^^^^^ Fabric API -- a mod, and the most depended-on
                         one in the ecosystem
  QuiltScanner   (quilt_loader|quilt_base|quilted_fabric_api|java|minecraft)
                                          ^^^^^^^^^^^^^^^^^ QFAPI, Quilt's
                                          port of it -- also a mod

Both are genuinely required on a server by the mods that declare them, so
dropping them meant they could never be reported as the dependency they
are, and never rescued back into a pack that had disabled them.

String.matches is a FULL match, so removing `fabric` affects only the
literal id -- `fabric-api-base` never matched it either way, and
aBareQuiltDependencyCarriesNoConstraint plus the extended both-forms
guard pin that a dependency stating no range keeps a null constraint
rather than an invented one.

These are manifest parsers, which fail silently -- a wrong branch yields
a plausible value, not an error -- so every guard here builds a real jar
in a @TempDir and executes the scanner, per the module's established
pattern.

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

Both exclusion sets carried the doc comment "ids that are the platform
rather than a mod" and both broke it. fabricloader, quilt_loader and
quilt_base are the platform; `fabric` is Fabric API and
`quilted_fabric_api` is Quilt's port of it -- mods, and the ones a server
most often genuinely needs. An embedder reading ScannedMod.dependencies
was told a Fabric mod depended on nothing but its non-API dependencies.

ModDependency gains versionConstraint, kept verbatim and unparsed:
Fabric/Quilt write npm-style ranges (>=0.92.0) and Forge/NeoForge write
Maven ranges ([15.2,)), so normalising here would lose the information a
consumer needs to tell them apart. @JvmOverloads preserves the old
(String)V and (String, Sideness)V JVM constructor descriptors -- pf4j
loads COMPILED plugin jars, so binary compatibility is the contract that
actually binds, not merely source compatibility.

Downstream consequence, checked rather than assumed: ModListCompiler runs
a dependency-rescue loop, so a disabled Fabric API / QFAPI jar is now
pulled back into a pack when a kept mod depends on it. That is correct --
you cannot strip Fabric API from a Fabric server -- and its blast radius
on a stock install is nil, because neither id appears in the shipped
fallback clientside list, so nothing is disabled to be rescued. The fix
becomes protective the moment a custom or polled list contains such an
entry, including the list the grinder itself publishes.

Recorded in claude-docs/API-BEHAVIOUR-CHANGES.md.

API suite: 365 tests, 0 failed, 1 skipped -- including generation, which
is what the rescue loop feeds.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Red: Unresolved reference 'nekodetectorFindings'.

Reported 2026-08-29 with a full stack: a host whose runtime classpath
lacked SecurityScans died with an unhandled NoClassDefFoundError straight
out of checkConfiguration, taking the generation coroutine with it
("Exception in thread pool-5-thread-1"). The modpack was fine; the
scanner was simply not there.

Two independent reasons it was fatal, both pinned here:

- scanUsingNekodetector catches `Exception`, and NoClassDefFoundError is
  an `Error`. It was never going to be caught.
- The failure happens while RESOLVING THE CALL -- the class cannot be
  loaded, so no statement inside that method ever executes. Catching
  inside it could not have helped at any point. The guard has to sit at
  the call site, which is what nekodetectorFindings will be.

Nekodetector is a third-party scanner resolved from jitpack and is an
optional safety net, not a precondition for building a server pack.

realFindingsAreStillReported is the counterweight: this is the malware
path, so a guard that silently emptied a real result would be worse than
the crash it replaces.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Turns the previous commit's guards green, and repairs the reported crash.

ConfigurationHandler now calls nekodetectorFindings(path), which runs the
scan inside a try and returns nothing when it cannot run. Two deliberate
choices, both unusual enough to be documented at the call site:

- It catches Throwable. Normally wrong, correct here: the reported
  failure was NoClassDefFoundError, an Error, which the scanner's own
  `catch (ex: Exception)` could never have caught.
- The guard sits at the CALL SITE, not inside SecurityScans. The class
  could not be loaded at all, so no statement inside that method ever
  ran. Only a guard around the invocation can contain that -- which is
  also why the scan is a parameter: it keeps the SecurityScans reference
  inside a lambda body evaluated within the try, and lets a test inject a
  scanner that cannot load.

A failure is logged loudly and NOT added to the config errors: an
unavailable scanner is a defect in this installation, not in the user's
modpack, so it must not block their build. The log line says as much, so
whoever reads it is not sent hunting through their mods.

SecurityScans.scanUsingNekodetector widens its own catch to Throwable for
the same reason -- it is a third-party jitpack artifact, so a missing
transitive class arrives as a LinkageError.

NOT reproduced locally, and worth saying: every local artifact checked
contains the class -- serverpackcreator-api-dev.jar, the compiled classes
output, and the nested api jar inside the app fat jar, whose
nekodetector-Version-1.1-pre.jar also carries me/cortex/jarscanner/Main.
So the crashing run used some other classpath. The fix is correct
regardless: an optional scanner must never be able to take generation
down, however it comes to be missing.

API suite: 368 tests, 0 failed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Red: Unresolved reference 'VersionConstraint'.

Two grammars, because the loaders disagree and B1 deliberately carried
each descriptor's constraint verbatim so they could be told apart:
Fabric/Quilt write npm-style ranges (>=0.92.0, ^1.2.0, ~1.2.0) and
Forge/NeoForge write Maven ranges ([47,), (,3.0]).

The load-bearing guard is anythingUnreadableAccepts. A constraint this
parser cannot read must ACCEPT, never refuse: refusing would make a gap
in grammar coverage indistinguishable from every mod's dependencies being
unsatisfiable, turning a parser shortfall into a catalog-wide
mass-INCONCLUSIVE event. anUnknownVersionAccepts is the same rule from
the other side -- a version we cannot determine cannot be judged.

Beside those, the cases that bite in the wild:
ignoresBuildMetadataWhenComparing (0.92.2+1.20.1 is what Fabric API
actually publishes), comparesComponentsNumericallyRatherThanAsText (or 10
sorts below 9), and aShorterVersionIsPaddedRatherThanRejected.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Turns the previous commit's guards green. Reads both grammars B1's
verbatim constraints made distinguishable: npm-style clauses with
comparators, ~ and ^ shorthands and .x wildcards (Fabric/Quilt), and
Maven ranges with inclusive/exclusive bounds and open ends
(Forge/NeoForge). || is a disjunction, spaces a conjunction. Build
metadata after + or - is ignored, since Fabric API publishes
0.92.2+1.20.1 and that is the common case, not an edge one.

anythingUnreadableAccepts earned its place immediately: my first
implementation FAILED it. numbersOf maps a digit-less component to 0, so
a clause like "whatever" compared equal to 0.0.0 and REFUSED every real
version -- exactly the direction the design forbids, and invisible to
inspection because every deliberate test case was well-formed. A
looksLikeVersion check now gates each comparison site, and its doc says
what it is protecting against.

That direction is the whole safety property: an unreadable constraint
must fail towards ACCEPT, because a refusal is indistinguishable from the
dependency being genuinely unsatisfiable, and would turn a gap in grammar
coverage into a catalog-wide mass-INCONCLUSIVE event. The worst a gap can
do now is fail to narrow a choice that would have been made anyway.

Clientside suite: 184 tests, 0 failed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Red: "Too many arguments for pickDependencyFile" -- it takes no constraint
yet, and ModFile carries no version.

aConstraintNarrowsTheChoiceButNeverEmptiesIt is the load-bearing one, and
it asserts all three directions: the satisfying file is preferred, a set
where NOTHING satisfies still returns what it returned before, and a null
constraint is byte-identical to the old two-argument call.

That middle assertion is the point. Returning null where the old code
returned a file would turn a bootable candidate into a refusal, and
refuseForMissingDependencies scores a refusal INCONCLUSIVE -- so the mod
would quietly stop being verified at all rather than fail loudly.

aFileWithNoKnownVersionIsStillEligible is the same rule for the other
unknown, and theQuiltFallbackStillAppliesWithAConstraint guards the
fallback that exists precisely for Fabric API, which is what this whole
feature is about.

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

ModFile gains `version`, populated from Modrinth's version_number and
CurseForge's displayName. Both platforms already had it and both threw it
away, so a constraint could be recorded but never actually matched.
CurseForge's displayName is often decorated ("JEI 15.2.0.27 for 1.20.1"),
which is fine: VersionConstraint reads what it can and accepts what it
cannot.

pickDependencyFile takes an optional constraint and treats it as a
PREFERENCE, never a filter: it narrows to the satisfying files, and falls
back to the whole set when none satisfies. Returning null where it used
to return a file would turn a bootable candidate into a refusal, and
refuseForMissingDependencies scores a refusal INCONCLUSIVE -- so the mod
would silently stop being verified rather than fail visibly. The
narrow-then-fall-back shape is what makes that impossible by
construction, and the loader resolution (including the one-way
Quilt-to-Fabric fallback that exists precisely for Fabric API) is
extracted so both attempts share it.

Clientside suite: 187 tests, 0 failed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Red: Unresolved reference KnownModIds.

A jar manifest names `fabric`; the platform wants a Modrinth slug
(fabric-api) or a CurseForge numeric id (306612). Without this bridge the
most-depended-on mod in the ecosystem cannot be resolved from a manifest
at all -- which is the whole point of reading manifest dependencies.

Two asymmetries are pinned deliberately:

- anUnknownIdIsTriedAsAModrinthSlug: Modrinth resolves a project by slug
  OR id and most mod ids are their slug, so a guess costs one lookup that
  may miss -- far cheaper than never resolving the dependency.
- anUnknownIdIsNotGuessedOnCurseForge: CurseForge addresses projects by
  NUMERIC id, so a mod id is never a valid ref. Guessing would spend API
  quota on a search that cannot be verified from the id alone.

The table stays tiny on purpose: the platform-declared path already
resolves everything the platform knows, so this covers only what the
platform metadata omits. A large guessed table is un-pinned data that
goes stale silently.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Turns the previous commit's guards green. KnownModIds bridges the two
vocabularies: a jar manifest names `fabric`, a platform wants a Modrinth
slug (fabric-api) or a CurseForge numeric id (306612).

Both spellings of Fabric API are seeded, because descriptors use both --
`fabric` in fabric.mod.json, `fabric-api` in some Quilt and Fabric ones.
Those two entries are the entire table, and it should stay that way: the
platform-declared path already resolves everything the platform knows, so
this covers only ids declared in the jar and nowhere else. A large table
of guesses would be un-pinned data going stale in silence.

The asymmetry between platforms is the design, not an oversight. Modrinth
resolves a project by slug OR id and most mod ids are their own slug, so
an unknown id is handed over as-is: one lookup that may miss beats never
resolving the dependency. CurseForge addresses projects by NUMERIC id, so
a mod id is never a valid ref there, and searching would spend API quota
on a match nothing could verify -- an id that maps nowhere is reported,
never fabricated.

The test uses the platform-name literals the rest of -clientside uses
rather than the grinder's ModPlatforms constants, which this module
cannot reference (grinder depends on clientside, not the reverse).

Clientside suite: 193 tests, 0 failed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A one-shot grind of modelfix on Docker 29.7.2, 456s, all four loaders.
What only a real runtime could answer:

- The NeoForge attempt crashed and left a genuine NeoForge
  ModLoadingCrashException crash report -- an artifact this engine had
  never captured. Five artifacts kept, index.txt reporting 3 kept /
  0 not kept.
- Retention held: every SURVIVED attempt kept nothing, including the
  other-version re-check.
- The run happened to exercise the exact case the per-attempt sink exists
  for: the NeoForge crash was superseded by a Fabric 1.20.4 re-check that
  staged into the crashing loader own directory, so a post-hoc copy after
  verify() would have found it already overwritten.
- The console/server-log difference is now measured, not asserted: the
  console holds launcher output latest.log lacks, and latest.log holds
  100 lines the console lacks.
- Report verified end to end: the details disclosure with five
  boot-log links, both indexes, the CSV Rule column, and /status
  reporting bootRules with no rule file present.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A sweep of malformed constraints found a SECOND instance of the hole
looksLikeVersion was added to close, reached through a branch that
already returned before the guard: a bare ".x" (or ".*") took
prefix = "" and then matched nothing, so it refused every version.

The hand-picked cases in anythingUnreadableAccepts missed it, which is
the point of adding the sweep. That rule's whole purpose is covering
input nobody anticipated, so examples chosen by the same person who wrote
the parser are a thin net for it -- both holes found so far were in
branches that looked obviously fine.

VersionConstraintFuzzTest now runs 30 malformed shapes against 6 real
versions and asserts not one refuses: empty brackets, dangling
comparators, half-written ranges, disjunctions with a junk arm, and text
with no digits. It is paired with readableConstraintsStillRefuseWhenThey
Should, so "accept the unreadable" cannot quietly become "accept
everything" -- which would be the same defect wearing the opposite mask.

Why a refusal matters more than it looks: it is indistinguishable from
the dependency being genuinely unsatisfiable, so a gap in grammar
coverage would surface as a catalog-wide mass-INCONCLUSIVE event rather
than as a parser bug.

Clientside suite: 196 tests, 0 failed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Red: refuseForMissingDependencies takes no `unmapped`, and neither
unmappedDependencyNote, stageableRequirements nor
refuseForTooManyDependencies exists.

This split is the decision that determines whether reading jar-manifest
dependencies improves the engine or wrecks it, so it is pinned before any
of it is built.

Platform refs and manifest ids are not equally trustworthy. A platform
ref is a project the author explicitly linked. A manifest id is a bare
string that may name something bundled inside another jar
(fabric-api-base ships INSIDE Fabric API), something the loader itself
provides, or something optional in practice. refuseForMissingDependencies
aborts a boot as INCONCLUSIVE -- so treating every unresolvable manifest
id as a refusal would convert a large share of today's WORKING boots into
INCONCLUSIVE. That is a strict regression wearing a feature's clothes,
and anUnmappedManifestDependencyDoesNotRefuseTheBoot is what stops it.

  unsatisfied -> refuses. Platform misses, and manifest ids the registry
                 DID map and then failed to stage: cases we chose to
                 trust, so a failure there is a real gap.
  unmapped    -> never refuses, always reported. A registry coverage gap
                 must be visible, not silent.

Beside those: the environment's own ids (minecraft, java, the loaders)
are never staged as mods; a requirement the platform already resolved is
not downloaded twice; and MAX_INJECTED_DEPENDENCIES caps the pack,
because a 40-jar pack's failure says nothing about the candidate.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Turns the previous commit's guards green, and the split is structural
rather than a flag: an unmapped manifest id never reaches
refuseForMissingDependencies at all, so it cannot refuse a boot even by
accident. That function keeps its exact signature and behaviour, and its
three existing pins in BootVerifierOutcomeTest stay green untouched --
which is the evidence the refusal semantics were reused rather than
rewritten.

Why the asymmetry. A platform ref is a project the author explicitly
linked. A manifest id is a bare string that may name something bundled
inside another jar (fabric-api-base ships INSIDE Fabric API), something
the loader provides, or something optional in practice. Since a refusal
is scored INCONCLUSIVE, treating every unresolvable manifest id as one
would convert a large share of today's working boots into INCONCLUSIVE --
a regression dressed as a feature.

Three helpers, all pure and unit-tested:

- stageableRequirements drops the environment's own ids (minecraft, java,
  fabricloader, forge, neoforge, quilt_loader, quilt_base) and anything
  the platform already resolved, so the two overlapping sources do not
  download the same jar twice.
- unmappedDependencyNote names what went unresolved. It does not refuse,
  but it must be SAID, or a gap in KnownModIds is invisible: the boot is
  quietly less faithful for a reason nobody can see in the verdict.
- refuseForTooManyDependencies caps the graph at MAX_INJECTED_DEPENDENCIES
  = 12. A forty-jar pack that fails says nothing about the candidate,
  since any one of the forty could be the cause.

Clientside suite: 204 tests, 0 failed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Completes the resolution half of B. downloadWithDependencies now scans
each staged jar's manifest and resolves what the platform metadata never
mentioned -- the case Fabric API most often falls into, and which until
the -api exclusion fix was not even reported as a dependency.

The two sources overlap heavily, so anything already visited is skipped
rather than downloaded twice. What is genuinely new is the author who
declared a dependency only in fabric.mod.json.

The refusal split is honoured at every branch, and the asymmetry is the
whole point:

  maps to no project      -> unmapped  (never refuses)
  mapped but unresolvable -> unmapped  (a guess that missed)
  no file for this combo  -> unmapped
  mapped, resolved, then
  failed to download      -> unsatisfied (refuses -- we chose to trust it)

Also wired: the injected list feeds refuseForTooManyDependencies, and
unmappedDependencyNote is logged so a KnownModIds gap is visible rather
than silently making a boot less faithful.

ModPlatform gains `name`, replacing an `is CurseForgePlatform` instanceof
I had just written and the four scattered "Modrinth"/"CurseForge"
literals in the two implementations. A candidate's platform and a
recorded verdict's platform must agree exactly -- the grinder re-grinds
the same project forever otherwise -- so one property beats literals that
can drift apart.

Clientside suite: 204 tests, 0 failed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Red: Unresolved reference 'InjectedDependency' / 'DependencyAttribution',
and BootOutcome has no blamedDependency.

attributionNeverChangesTheBootResult is the load-bearing guard: it runs
the same crashed outcome with and without injected dependencies and
asserts the RESULT is identical, only the annotation differs. If it ever
fails, the feature has quietly become a downgrade mechanism.

Why annotate rather than downgrade (decided 2026-08-29): the candidate
did crash a server in the configuration a real pack produces.
Downgrading that on a string heuristic trades a false positive for a LOST
TRUE POSITIVE, which is the more expensive direction for a list deciding
what SPC strips from every pack built against it. Requeuing the
dependency as its own candidate answers the question with data -- grind
it, see whether it crashes alone -- instead of with a guess.

anInformationalMentionOfADependencyIsNotAttributed is the guard that
keeps the heuristic worth having. A dependency's name appears in every
"loading mod" line of a normal boot, so blaming on a bare mention would
attribute nearly every crash to whichever dependency happened to be
listed -- worse than not attributing at all. Blame requires a crash
marker or a stack frame, and stands down when the candidate's own name
shares the frame.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Turns the previous commit's guards green. DependencyAttribution.blame
finds the injected dependency a crash names; BootVerifier.attribute
annotates the outcome with it and BootOutcome gains blamedDependency and
stagedDependencies.

attribute() only ever looks at a CRASHED outcome and returns one whose
result is the input's, always. The annotation exists to make a suspicion
visible and countable, never to overrule a boot -- so the feature cannot
silently start eating true positives.

Two conservative choices, both of which the tests forced:

- A bare mention is not evidence. A dependency's name appears in every
  "loading mod" line, so a line must carry a crash marker or be a stack
  frame. Without this, nearly every crash would be attributed to whatever
  dependency happened to be listed.
- Blame stands down when the candidate is named ANYWHERE in the crash
  context, not merely on the same line. I wrote the narrow version first
  and aCrashNamingTheCandidateItselfIsNotAttributedToADependency caught
  it: "NoClassDefFoundError: com/benbenlaw/core/Thing" thrown from "at
  com.strawberrymod.Main" is ONE crash naming both, and judging line by
  line blamed the dependency on the strength of the first line alone.

Name matching normalises away the separators that differ between a jar
name and a package path (benbenlaw-core-1.20.1.jar vs com/benbenlaw/core)
and cuts the stem at the first digit group, with a four-character floor so
short ids cannot match arbitrary text.

Clientside suite: 210 tests, 0 failed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Completes B5. A dependency the crash names is queued as its own candidate
through RequeueStore, and the staged jar set reaches the report and CSV as
a Dependencies column.

The requeue is the whole point of attribution. The blame is a string match
over a console and is never allowed to move a verdict, so the suspicion
has to be settled some other way: grind the dependency alone and see
whether it crashes by itself. That converts a guess into evidence instead
of acting on it.

InjectedDependency and BootOutcome carry the dependency's project link
alongside its file name, because a jar name is not something the queue can
grind. Queueing failures are logged and dropped -- a problem putting work
on a queue must not cost the verdicts just earned.

The Dependencies column exists because a verdict reached with jars
injected beside the mod is a different claim from one reached with the mod
alone, and a reader should be able to see which they are looking at.

Clientside 210 tests, grinder 364 tests, 0 failed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Both are inversions of the obvious, and both cost something to get wrong:
VersionConstraint fails towards ACCEPT (a refusal is indistinguishable
from a genuinely unsatisfiable dependency, so a grammar gap would look
like a catalog-wide outage), and the refusal split keeps unmapped
manifest ids away from refuseForMissingDependencies structurally rather
than by a flag.

Also recorded: the accept-direction shipped broken twice during
development, both times invisible to inspection, the second found only by
a deliberate adversarial sweep. That is why the fuzz guard exists rather
than more hand-picked cases.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
SPC_GRINDER_RULE_FALLBACK, default `grinder`: a rule stating no verdict
leaves the decision to the built-in ladder and merely names itself on the
verdict -- which restores "if none is specified, determine by grinder"
and brings back labelling a signature without deciding it.
`inconclusive` opts into the conservative reading, for a run where
unfinished rules are expected.

The distinction that makes this coherent: an ABSENT verdict is undecided,
an UNREADABLE one is broken. A misspelt verdict therefore stays
INCONCLUSIVE whatever the setting says -- the author tried to state an
intention and failed, and a typo is not an intention to honour. The
opt-in also governs undecided rules ONLY: theOptInDoesNotOverrideARule
ThatStatesAVerdict pins that turning it on cannot silently rewrite
somebody's deliberate CRASHED rules.

ConsoleRule.verdict goes back to nullable so a rule stays as authored, and
the policy is applied at classify time rather than baked in at load. That
is what lets /status report which mode is in force (`undecidedVerdict`)
rather than showing rules whose meaning has already been rewritten.

Three tests changed from pinning the old unconditional default to pinning
BOTH modes, which is the honest shape now that it is a choice.

QFAPI seeded into KnownModIds while I was here, verified against both live
APIs rather than guessed: Modrinth `qsl` (qvIfYCYJ), CurseForge `634179`.
That mapping is unguessable from `quilted_fabric_api` -- the Modrinth
slug-fallback would miss it outright -- which is the clearest argument yet
for the registry existing at all. The pre-seeded Fabric API id 306612 was
confirmed the same way.

Clientside 214 tests, grinder 364 tests, 0 failed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
On the grind path rather than the report path, and it wants a measurement
before a fix -- this repo justifies performance-shaped changes with
numbers, and taking that measurement properly needs a store far larger
than the 875 rows on hand.

Recorded with the distinction that matters: this is NOT the report layer
in-memory filter/sort, which was measured as fine for a human-facing page.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Lands GREEN, deliberately: these are characterization guards over
correct-but-fragile code, put in place to protect the column restructure
that follows. "Red first" applies to tests for new behaviour, and this is
not new behaviour.

everyColumnRendersTheValueItsHeaderNames is the guard counting cannot
give you. everyHeaderHasACellBeneathIt proves the NUMBER of cells matches
the number of headers -- which is exactly what an off-by-one preserves:
drop a column and add another and every cell past the gap shows its
neighbour's data with the count still right. Each field now carries a
distinct sentinel and cell i is asserted to hold column i's.

theCsvAndTheTableAgreeOnTheirDataColumns pins that the two renderings of
one column list describe the same fields. They have drifted before -- the
CSV header carried seven while the table carried eight, for a long time --
so the relationship is stated as an assertion rather than left to whoever
next adds a column.

Fixed one sentinel while writing it: the date column reads Instant.EPOCH
from the fixture, so the expectation is 1970, not 2026.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Red: Unresolved reference 'VerdictSelection' / 'VerdictQuery' /
'QueryParams' / 'VerdictField'.

Pinned as a pure function rather than through the HTTP handler on
purpose. Every edge case here -- page 0, page past the end, size=banana,
a sort naming a dropped column, a filter value carrying & -- costs three
lines and microseconds; through the server each would be a socket round
trip asserting against a 60 KB blob. It is also what will make the table
and the CSV PROVABLY agree: they will agree because they run this
function over the same query, not because two renderers were kept in step
by hand.

The two that carry the most weight:

- junkInTheQueryFallsBackInsteadOfThrowing. These values arrive from
  bookmarks, shared links and people editing the address bar. A report
  that 500s is worse than one that quietly falls back.
- theSizeInForceIsAlwaysOfferedEvenWhenTheResultCountWouldNot. Filter
  104,000 rows down to 12 while size=1000 and a naive "only offer sizes
  the data justifies" leaves the control with no matching option, so the
  browser shows the first one and the page silently disagrees with its
  own URL.

emptyParametersReadAsAbsentAndAreNotEmitted exists because a GET form
submits its empty controls, so ?f.name=&q= arrives constantly and would
otherwise ride along in every shared link.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Turns the query guards green. VerdictField is now the single declaration
of a report column -- header, CSV header, URL token, filter kind and cell
text in one place -- and QueryParams/VerdictQuery/VerdictSelection do the
selecting as pure functions with no store, server or clock.

VerdictField also adds the Platform, Project sideness and Jar sideness
columns. Project sideness is rendered honestly rather than as a bare
enum: "not recorded" when nobody asked, "not published by CurseForge"
where the platform publishes none for ANY project, and the pair otherwise.
Showing UNKNOWN for both would tell a reader we checked and found the mod
server-safe.

Two bugs the tests caught that reading would not have:

- toQueryString built its parts inside buildList, whose MutableList
  receiver SHADOWS the `size` property. So `size != DEFAULT_PAGE_SIZE`
  compared the list's length and emitted it as the value: size=250 became
  "size=0" and size=2 became "size=4". Every shared link would have
  carried a wrong page size. Bound outside the block now, with the trap
  named in a comment.
- My own expectation for the size ladder was wrong, not the code. For
  1,842 rows, 2,000 would show everything on one page -- which is what
  `all` already is -- so sizes at or above the count are omitted as
  duplicates rather than kept as "the next one up".

offeredSizes always includes the size in force even when the count would
not justify it: filter a large store down to a handful while a big size is
set and a purely count-derived list leaves the control with no matching
option, so the browser shows its first one while the URL says another.

Grinder suite: 382 tests, 0 failed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Completes the report work. The table is built from a VerdictPage, so
filtering, sorting and paging all happen server-side and every one of them
lives in the URL -- which is what makes a view bookmarkable and shareable.

Measured against the real 875-row store rather than asserted:

  875 rows -> 4 pages at size 250; sizes offered [100, 250, 500, all]
  HIGH=39, Fabric=229, q=create -> 8   (matches the store's own profile)
  20 filtered+sorted selections in 13ms  (~0.65ms each)
  page bytes: size=250 -> 110,703   size=all -> 369,873

So the cost was never the filtering -- it is the HTML, and paging is what
addresses it. A 3.3x smaller default page on 875 rows, and the ratio grows
with the store.

Filtering needs NO JavaScript: CHOICE columns get a <select> of the values
actually present, TEXT columns an <input>, all inside one GET form.
Submitting IS the URL update. Sorting became header links carrying the
rest of the query, so the DOM sort is gone -- it was lost on every reload
anyway, and could not be shared.

The CSV button is now a link to /export.csv with the current filters and
sort but size=all, since "download the filtered set" means all of it. The
embedded CSV literal is gone, and its removal is pinned: an embedded copy
would silently disagree with a filtered export, and dropping it also stops
the page putting mod-supplied text inside a <script> block at all.

/export.csv runs the SAME VerdictSelection the table does, so the two
cannot disagree -- they agree by sharing the function, not by two
renderers being kept in step. A bare /export.csv still exports everything
(defaultSize = null), which is the documented behaviour operators script.

Three columns land with it: Platform, Project sideness and Jar sideness.
Project sideness is rendered honestly -- "not recorded" when nobody asked,
"not published by CurseForge" where the platform publishes none for ANY
project -- because a bare UNKNOWN would read as "checked, and it is fine".

Existing expectations changed, hence feat: the CSV header gains three
columns, the sort assertions move from onclick to links, and
embedsTheCsvForTheDownloadButton becomes
theDownloadButtonLinksTheFilteredCsvExport, which now pins the removal.

Grinder suite: 382 tests, 0 failed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The numbers are from the real 875-row store rather than reasoning: 13ms
for 20 filtered+sorted selections, and 110,703 page bytes at size=250
against 369,873 for all. The cost was never the filtering -- it is the
HTML, which is what paging addresses.

Also recorded: the buildList `size` shadowing bug, because it is
invisible by reading and would have put a wrong page size in every shared
link, and the size-ladder correction where my expectation was wrong
rather than the code.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
main 294 -> 259 lines, but the line count was never the point. ~20
assertions across 8 test files grepped that function's SOURCE TEXT,
because main boots Docker and cannot be executed -- and a source scan
degrades silently, stopping coverage of anything that moves out of the
file it scans without ever failing.

GrinderConfiguration reads every knob once and is executable. KNOBS is a
real list the README and systemd-unit guards now ITERATE rather than
regex out of Kotlin, and from(lookup) takes the environment as a
parameter, so a test asserts what the daemon would do with a value
instead of that a literal appears somewhere. Those two guards went to
ZERO source greps and gained assertions they could not previously make at
all: a malformed number falls back to its documented default, a blank
value reads as unset, every path defaults beneath the home while staying
individually overridable.

GrindLoop is the sweep, and it had NO TEST WHATSOEVER while it lived
inside main -- requeue-before-catalog, committing only what was reached,
polling `running` between steps. It takes evictUnusedInstalls and
verdictCount as functions rather than LoaderCache and VerdictStore, which
is what keeps its tests free of a Docker-bound installer. The guard that
matters most operationally is now executed rather than grepped: a stop
arriving DURING the drain must not start the catalog pass, or the daemon
burns another boot budget per candidate after being asked to stop.

Two of my first three loop assertions were wrong, and the loop was right:
`running` is polled between steps deliberately, so a counter-based fake
stops it mid-pass and proves nothing. The flag has to be flipped from the
injected sleeper, which is where a real stop lands. Landmined.

18 source greps remain, in ReportBindWiringTest, ContainerLimitsWiring
Test, FallbackListWiringTest, ShutdownWiringTest and GrinderSpc
EnvironmentTest. They assert JOINS -- a configured value reaching the
collaborator it configures, the shutdown hook's ordering -- which
genuinely cannot be executed, and they now grep config.<property> rather
than env("NAME", "default"). The values they stood in for are asserted
for real elsewhere.

Behaviour is unchanged: the daemon reads the same variables with the same
defaults and runs the same passes. Three GrindPoolShutdownTest source
greps were deleted rather than retargeted, because GrindLoopTest asserts
the same properties by execution.

Not done, deliberately: the composition itself stays in main. Extracting
it would move the remaining wiring guards without making any executable,
since what they assert is precisely that the composition happens.

Grinder suite: 386 tests (was 382), 0 failed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The queueing had no unit test -- an omission, since it is the mechanism
that makes attribution safe: the blame never moves a verdict, so the
suspicion is only ever settled by grinding the dependency alone.

Also records what a live daemon confirmed: the report filters, pages and
sorts a real store correctly, /export.csv honours the same query and
parses as well-formed CSV, and --requeue is drained FORCED and ahead of
the crawl on the next pass -- GrindLoop ordering that was a source grep
until this week.

Two counting traps recorded because both looked like defects and neither
was: wc -l under-counts a CSV with no trailing newline, and the row total
moved mid-run because the daemon was grinding.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Both guards fail on this commit, which is the point.

Found by reading 200 of the 609 crash logs the live grinder publishes.
`Fabric API requires version ...` is the single largest failure class in
them -- the requirer in 55 of the 200 sampled logs -- and it is caused by
`pickDependencyFile`, not by any mod.

`pickForLoader` prefers an exact Minecraft match but falls back to the
newest file for the loader whatever version it targets, and that fallback
is consulted *before* the Quilt-to-Fabric loader fallback. CurseForge tags
only recent Fabric API files as Quilt-compatible, so a Quilt boot matched a
`+26.3` file on the loader, took it despite the version mismatch, and never
reached the Fabric build that had the right Minecraft version.

Measured over the sample: 20 of the 35 boots that staged a Fabric API
staged one for the wrong Minecraft version -- all Quilt, all `+26.3`, into
packs as old as 1.19.2. Quilt Loader refused each pack outright and the
*candidate* was scored CRASHED for the grinder's own doing.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Turns the previous commit's two guards green. Clientside suite 216, 0 failed.

The Minecraft version is now fixed across both loader attempts instead of
being a preference inside each. That is what makes the Quilt-to-Fabric
fallback reachable: it used to be consulted only when the project published
nothing for Quilt at all, so a Quilt-tagged file for the *wrong* version
satisfied the first attempt and the Fabric build carrying the right version
was never considered.

A dependency that matches no file for the pack's Minecraft version is now
not staged, and the boot is refused. `refuseForMissingDependencies` scores
that INCONCLUSIVE, which is the honest verdict -- the mod never got a fair
run. The candidate under test keeps its loose fallback: booting it on a
near-miss version still tests the candidate, whereas injecting a near-miss
dependency only manufactures a version conflict to blame on it.

BEHAVIOUR CHANGE, and an existing assertion moved with it:
`dependencyFilePrefersExactMinecraftMatchThenFallsBack` expected a 1.19.2
dependency to be staged into a 1.21 pack. That expectation was the bug, so
the test is renamed `dependencyFileTakesTheExactMinecraftMatch` and now
expects null. Flagged rather than quietly relabelled -- per the conventions
a changed expected value means this is a fix, not a refactor.

Not fixed here, recorded as a known gap: `clientOnlyClassMarker` misses
Fabric intermediary class names (`net/minecraft/class_746` is LocalPlayer),
seen in 2 of the 200 sampled logs. There is no safe pattern -- `class_NNNN`
is intermediary for every class, not only client ones -- so a marker would
trade these false negatives for false positives. It needs a version-specific
ID list or nothing.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Four of the five guards fail on this commit. The fifth,
`aClientClassCrashOutranksTheNetworkExcuse`, passes by construction today
and is here to stay green through the next commit -- it is what stops the
new network excuse being placed above the decisive client-only marker.

Written from a census of 200 of the 609 crash logs the live grinder
publishes. 130 of the 200 (65%) carry no client-side evidence at all, yet
every one is scored CRASHED. The four largest recognisable causes, none of
which says anything about sideness:

  63  Quilt solver `requires version [x, y) of z`
  15  network denied (UnknownHost/Connect/SocketTimeout)
   6  mixin ClassMetadataNotFoundException naming an unstaged class
   6  ClassNotFoundException on the legacy MixinTweaker

The network class is the clearest: boots run `--network none`, so a mod
whose loader phones home at startup cannot pass here and would not fail
anywhere else. OneConfig fetches its stage1 from api.polyfrost.org, falls
back to a Swing dialog when it cannot -- hence `Fontconfig error: No
writable cache directories` in a headless container -- and exits.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Turns the previous commit's four red guards green, and keeps the fifth --
the ordering guard -- green. Clientside suite 221, 0 failed.

Adds one marker and widens another, both in the band *below*
`clientOnlyClassMarker`, so decisive client-only evidence still outranks
every excuse here:

  sandboxNetworkMarkers (new)   UnknownHost/Connect/NoRouteToHost/
                                SocketTimeout -- boots run `--network none`
  dependencyFailureMarkers      + Quilt's `requires version [x, y) of z`
                                + mixin ClassMetadataNotFoundException
                                + the legacy MixinTweaker CNFE

Measured by classifying the real published logs before and after:

  the 21 logs Griefed sent   21 CRASHED  ->  11 CRASHED / 10 INCONCLUSIVE
  200-log random sample     200 CRASHED  -> 113 CRASHED / 87 INCONCLUSIVE

Both true positives in the 21 are retained: `arcane-vortex` on FML's
`for invalid dist DEDICATED_SERVER`, and `avm-mod` on `net/minecraft/
class_746`. Nothing that carried client-side evidence moved.

Deliberate false negative: a mixin whose missing target *is* a client class
is now excused as INCONCLUSIVE, because ClassMetadataNotFoundException does
not match the client-only marker. That is the safe direction -- an excused
true positive costs a re-check, a published false positive costs a user a
working mod -- and the alternative is broadening the one marker the ladder
documents as un-fakeable.

Residual, not addressed here: 113 of the 200 still score CRASHED and some
carry no sideness signal either (a mod's own missing dependency, a loader
built against another Minecraft version). Those need per-case evidence
rather than another blanket marker; the console-rule file exists for them.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Two corrections found while checking the plan for leftovers.

`report/CLAUDE.md` still described the embedded-CSV button that the query
layer replaced, which reads as authoritative and is not.

README section 7 documented every `/status` field except `bootRules`, added
with the console-rule file. An operator checking whether their rules loaded
had nothing to look at.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`StoreWriteBenchTest` was swept into 47ccb99d4 as a scratch file and ran on
every build, seeding a 100 k-row store for no one's benefit. It is now gated
behind SPC_GRINDER_BENCH=1 and gains a second case that splits persist()
into its three costs, which is what B35 actually asked about.

Per record(): 1 k -> 18-25 ms, 10 k -> 74-83 ms, 100 k -> 787-1050 ms
(45.8 MiB file).

B35 asked whether dropping the pretty-printer would be enough. It is not.
At 100 k rows: sort 40.6 ms, pretty write 707.5 ms, compact write 360.7 ms.
The printer is about half the write and 16% of the bytes -- a 2x win that
still leaves ~360 ms per verdict. The O(n) whole-file rewrite is the cost.

The deployed store holds 38,258 verdicts and persist() is @Synchronized on
the grind worker's thread, so at the observed ~4.3 verdicts/second this
already serialises every worker.

No fix landed. Both remaining candidates -- coalesced writes, or an
append-log with compaction -- change either durability or the store format
on a file holding 38 k live verdicts, and neither belongs at the tail of
this branch. Both are written up in BACKLOG.md with the numbers to size
them against.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Definition-of-done #4 for the two fixes on this branch: the dependency
Minecraft-version rule in BootCandidateSelector and the environment excuses
in BootLogClassifier, with the before/after verdict counts on real logs and
the one gap left deliberately unfixed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Red on this commit: the module does not compile, because `flushInterval`,
`flush()` and `close()` do not exist yet.

Five guards for the fix B35's measurements point at. The first is the point
of the change -- a buffered store must not rewrite the whole file per
verdict -- and it is asserted on the file never appearing, because a size
or mtime comparison would race the flusher. The others cover the explicit
flush, the scheduled flush firing unasked, and the two data-loss paths that
matter: close() during an orderly shutdown, and the default staying
write-through so no existing caller silently loses durability.

Numbers behind it (StoreWriteBenchTest, 2026-08-29): 18-25 ms per record()
at 1 k rows, 74-83 ms at 10 k, 787-1050 ms at 100 k. Dropping the
pretty-printer was measured and is not enough -- 707 ms to 361 ms at 100 k.
Only writing less often is.

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

`persist()` serialises the whole store, and `record()` called it per verdict
while holding the lock on a grind worker's thread. Measured before/after on
the same run (StoreWriteBenchTest, SPC_GRINDER_BENCH=1):

  rows      write-through        coalesced
  1 000        20.8 ms             2.5 us
  10 000       94.7 ms             2.1 us
  100 000    1242.5 ms             2.6 us

Coalesced `record()` is flat -- it no longer scales with the store at all,
which is the actual defect. The deployed store holds 38,258 verdicts and
grows monotonically, so write-through was getting worse every pass.

Shape: `record()` buffers and marks pending; a daemon flusher persists on
SPC_GRINDER_STORE_FLUSH_SECONDS (default 30); the shutdown hook flushes
last, after the workers have stopped, so an orderly stop loses nothing. The
pending flag is cleared *after* a successful write, so a failed flush is
retried on the next tick rather than dropping what it was holding.

Nothing about the file format, the pretty-printing or the atomic move
changes -- this only changes how often the write happens.

Durability, stated plainly: a hard kill can now lose up to one interval of
verdicts, which the re-verify TTL re-derives. The default is deliberately
write-through (Duration.ZERO), so coalescing is opted into at the
composition root and no existing caller -- or test -- silently loses the
durability it was written against.

`VerdictStore.flush()` is defaulted to a no-op so the in-memory store needs
no ceremony and the shutdown hook can flush without knowing which it holds.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
BACKLOG.md says a landed item is deleted there and recorded in
REFACTOR-LOG.md, so B35's entry is removed rather than marked closed and the
file returns to its documented empty state. The numbering note already
explains why that is not a reset: B36 is next, and IDs are never reused.

The log entry carries the crash-log census the two clientside fixes came
out of, the before/after verdict counts on the real logs, B35's before/after
timings, and why the append-log was not needed after all.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Fifty-six commits. The nine features Griefed asked for, plus the defects
that reading the live grinder's own crash logs turned up.

Features: all boot artifacts kept per attempt and browsable per row; Fabric
API staged as a real dependency for Fabric and Quilt; recursive dependency
resolution from both platforms and from jar manifests; report pagination,
per-column filter and search, and the whole query in the URL; a hot-editable
console-rule file with an operator-chosen fallback verdict; project- and
jar-sideness columns; and the grinder's own complexity cut down (GrindLoop,
GrinderConfiguration, VerdictQuery extracted from a 300-line main).

Fixes found along the way, each pinned red before it went green:

- JsonVerdictStore could lose the entire store: FAIL_ON_UNKNOWN_PROPERTIES
  was on, so a downgrade failed the read, logged "starting empty", and the
  next record() overwrote everything. Found while planning, fixed before any
  field was added -- adding one would have armed it.
- A dependency was staged for the wrong Minecraft version. 20 of 35 sampled
  Quilt boots got a `+26.3` Fabric API into packs as old as 1.19.2, and the
  candidate wore the resulting verdict.
- Harness failures scored as the mod's crash. Re-classifying real logs: the
  21 Griefed sent went 21 CRASHED -> 11/10, a 200-log sample 200 -> 113/87,
  both true positives retained.
- B35: verdict-store writes coalesced. 1242.5 ms -> 2.6 us per record() at
  100k rows, and flat rather than growing with the store.

Full build green: 1137 tests, 0 failed, 29 skipped.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
docs: file B36 — the published HIGH verdicts need re-grinding after deploy
Some checks failed
Qodana / scan (push) Successful in 15m26s
Docker Test / build image (push) Failing after 15m30s
Qodana / notify (push) Successful in 14s
Test / build (push) Failing after 22m31s
592f1ce21c
The clientside fixes merged today change 38% of the verdicts that are
actually published. HIGH is the only confidence that reaches
/as-properties, which SPC instances poll as their fallback.updateurl, so
each false positive is a working mod being stripped from users' server
packs.

Measured against the live service rather than estimated: /export.csv holds
38,532 verdicts of which 466 are HIGH; 455 of those matched a published
crash log; re-classifying all 455 with the fixed BootLogClassifier leaves
281 CRASHED and excuses 174.

By loader that is Quilt 118, Forge 52, NeoForge 4 -- the Quilt
concentration being the pickForLoader bug staging a +26.3 Fabric API into
packs as old as 1.19.2.

Filed rather than done because it cannot be done from here: the deployed
daemon still runs pre-branch code, so the fixes must be deployed first, and
the re-grind is hours-to-days of host time to schedule.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Red on this commit: VerdictSelection.select takes no log-count lookup yet,
so the module does not compile.

Logs was exempted from sorting as well as filtering, on the reasoning that
it is not derivable from a GrindVerdict. Filtering it stays exempt; sorting
is not meaningless at all. Not every entry gets logs -- artifacts are kept
only for boots that did not survive, and the reaper drops the oldest
attempts once the budget is passed -- so "which rows actually have something
to read" is the question a maintainer opens this table to ask.

Six guards: both directions, the round trip through the URL, the no-lookup
case, and the header being a real sort link (without which the feature is
unreachable by clicking). The tie-break is pinned as stable in BOTH
directions, unlike the field sorts which reverse their whole comparator --
reversing it here would shuffle every log-less row when a reader merely
flipped the arrow, and those rows are the majority.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Turns the previous commit's six guards green. Grinder suite 402, 0 failed.

Sorting is now keyed by a `SortKey` rather than a `VerdictField`, because
the sortable columns and the verdict-derived ones are not the same set:
Logs is rendered from a directory listing, so it can be sorted but never
filtered, searched or exported. Modelling that as a sealed type keeps the
difference in the compiler instead of in a comment -- a VerdictField cannot
be asked for a log count, and SortKey.Logs cannot be asked for cell text.
VerdictField itself is untouched.

`VerdictSelection.select` takes an optional log-count lookup, defaulting to
"nothing has logs" so every caller that does not sort by them needs no
directory listing. The server passes the same per-request snapshot the
renderer already uses, through one shared `logNamesFor`, so the count a row
is sorted by and the links it then shows cannot drift apart.

The Logs tie-break runs by slug in BOTH directions, deliberately unlike the
field sorts which reverse their whole comparator: reversing it here would
reshuffle every log-less row whenever a reader flipped the arrow, and those
rows are the majority.

`/export.csv` deliberately passes no lookup -- that export has no Logs
column, so `sort=logs` degrades to the slug/loader order rather than costing
a listing for a column nobody is exporting.

Landmine worth naming: SortKey.Column's property is `column`, not `field`.
Inside a property getter `field` is the backing-field keyword, so
`field.param` bound to a backing field the property does not have, and the
compiler reported "Property must be initialized" -- naming neither the cause
nor the collision.

Also corrects the README, which still described 6 columns and no filtering
long after the table grew to 13.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The unit test pins the ordering; this pins the wiring. The count the sort
uses and the names the cells render must come from the same per-request
snapshot -- they were two separate lookups in the first draft, which is
exactly how a row sorts as having logs and then renders an em-dash.

Drives the real HTTP handler against a real BootLogStore directory:
GET /?sort=logs&dir=desc must lead with the row holding artifacts, render a
sort link on the header, and still show the "2 log(s)" count it was ordered
by.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
That line was the reasoning the exemption was built on, and it was wrong for
the reason Griefed gave: not every entry has logs, so ordering by them is
how a maintainer finds the rows with anything to read.

Records what replaced it -- the SortKey sealed type, the single snapshot
shared by the sort and the cells, the `field` backing-field collision, and
why the Logs tie-break does not reverse with the sort.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
B36 planned a targeted requeue of the 428 published projects whose HIGH
verdicts the merged fixes invalidate. Griefed deployed and reset the store
instead, which achieves the same thing and more.

Verified against the live service rather than assumed: /status now carries
the bootRules block, so it is running the merged code; the store went
38,258 -> 186 verdicts; and the spread is 130 LOW / 54 MEDIUM / 2 HIGH. The
466 HIGH and the 174 false positives among them are gone, and
/as-properties is back to essentially the shipped list.

Deleted per the backlog's convention and recorded in REFACTOR-LOG.md, where
the measurement keeps its value: it is the only end-to-end evidence of what
the pre-fix engine published, and the number to compare against once the
fresh sweep covers comparable ground.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Definition-of-done #4: what changed, why the original exemption was wrong,
and the two things a reader needs before touching it -- that SortKey is a
sealed type because the sortable and verdict-derived column sets differ, and
that the sort's count and the cell's links must share one snapshot.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
merge: sorting the report by the Logs column
Some checks failed
Documentation / Writerside webhelp (push) Successful in 6m16s
Continuous / Build JAR (push) Failing after 12m30s
Continuous / Build AppImage (x86_64) (push) Has been skipped
Continuous / Build AppImage (aarch64) (push) Has been skipped
Continuous / Build Install4J Media (push) Has been skipped
Continuous / Continuous Pre-Release (push) Has been skipped
Qodana / scan (push) Successful in 16m41s
Test / build (push) Failing after 13m8s
Docker Test / build image (push) Failing after 25m42s
Qodana / notify (push) Successful in 23s
Documentation / Help image (push) Successful in 5m50s
388b6e3a29
Logs was exempt from sorting as well as filtering, on the reasoning that it
is not derivable from a GrindVerdict. Filtering it stays exempt; the sorting
half of that reasoning was wrong, and for the reason Griefed gave: not every
entry has logs -- artifacts are kept only for boots that did not survive,
and the reaper drops the oldest past the budget -- so ordering by them is
how a maintainer finds the rows with anything to read.

The sort key is now a sealed SortKey (Column(VerdictField) or Logs) rather
than a VerdictField, because the sortable columns and the verdict-derived
ones are not the same set. VerdictField is untouched. VerdictSelection.select
takes an optional log-count lookup defaulting to "nothing has logs", and the
server passes the same per-request snapshot the cells render from through
one shared logNamesFor -- two lookups is how a row sorts as having logs and
then renders an em-dash, which is why the wiring is pinned through the real
HTTP handler and not only as a pure unit.

Also closes B36. It planned a targeted requeue of the 428 published projects
whose HIGH verdicts the previous merge invalidates; Griefed deployed and
reset the store instead, which achieves the same and more. Verified live:
/status now carries bootRules, the store went 38,258 -> 186 verdicts, and
the spread is 130 LOW / 54 MEDIUM / 2 HIGH. The measurement is kept in
REFACTOR-LOG.md as the only end-to-end evidence of what the pre-fix engine
was publishing.

Full build green: 1144 tests, 0 failed, 29 skipped.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Three of the four guards fail on this commit. The fourth,
theCsvDefaultOrderIsTheSameOrdering, passes today because both layers
happen to declare identical rank tables -- it is here to fail if they ever
drift, which is the whole reason the plan wanted them collapsed into one.

Observed on the live report: `?sort=confidence` returned HIGH, HIGH,
INCONCLUSIVE, INCONCLUSIVE, INCONCLUSIVE, LOW. That is alphabetical order,
in which INCONCLUSIVE -- "nothing was learned" -- outranks both MEDIUM and
LOW. Only the *default* order ever carried a rank; a named sort fell through
to VerdictField.text, which for this column is the enum name.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Turns the previous commit's three red guards green and keeps the fourth
green. Grinder suite 407, 0 failed.

VerdictField gains the `sortKey` the plan specified -- defaulting to `text`,
overridden only by CONFIDENCE, whose cell text is an enum name. Sorted as
text that ran alphabetically, so INCONCLUSIVE ("nothing was learned")
outranked MEDIUM and LOW whenever a reader clicked the header. Only the
default order had ever carried a rank.

The rank itself now lives once, as VerdictField.CONFIDENCE_RANK. Both the
report's default order and VerdictCsvExporter's own copy used to declare it
separately, so the table and the export could drift into disagreeing about
what "highest confidence first" means. The default order is now expressed
through the same sortKey as the named sort, so those two cannot disagree
either.

The key is zero-padded ("00".."03") so it sorts as text alongside every
other column, and the sorter needs no special case for a numeric one.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Definition-of-done #4 for the confidence-sort fix, and a warning worth
leaving: the column's cell text is an enum name, so anything that sorts it
as text gets alphabetical order in which INCONCLUSIVE beats MEDIUM.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The plan required it for the report restructure and it had never been run.
Base 57d22b57c's unmodified test tree against this work's production code.

Four files could not compile, enumerated rather than worked around: the
CrashLogStore -> BootLogStore rename with its reshaped keep(), the
keepCrashConsoles -> bootArtifactSink hook, and toHtml(List) ->
toHtml(VerdictPage). All three are data-shape changes rather than renames,
so adapting them would mean rewriting fixtures.

The other 48 files ran: 323 tests, 16 failed, zero regressions. 14 are
source-text guards over main()'s body, which item 9 deliberately moved into
GrinderConfiguration and GrindLoop -- their HEAD replacements execute what
these could only grep. 2 are the CSV header growing from 7 columns to 12.

Also states what was NOT run: B6's merge gate, which needs Docker and hours
of boots and has been overtaken by the live reset-and-re-grind.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Two things the plan specified that had never landed, found by re-reading it
rather than by trusting my own account of it.

The Confidence header sorted alphabetically. Observed live:
`?sort=confidence` returned HIGH, HIGH, INCONCLUSIVE, INCONCLUSIVE,
INCONCLUSIVE, LOW -- an order in which INCONCLUSIVE, meaning nothing was
learned, outranks MEDIUM and LOW. Only the default order had ever carried a
rank; a named sort fell through to VerdictField.text, which for that column
is the enum name. VerdictField now carries the `sortKey` the plan asked for,
defaulting to `text` and overridden only by CONFIDENCE.

The rank table also existed twice -- once in the report's sorter, once in
VerdictCsvExporter -- so the table and the export could drift on what
"highest confidence first" means. It is now VerdictField.CONFIDENCE_RANK,
declared once, with a guard that fails if the two ever disagree.

And the equivalence proof the plan required for the report restructure,
which had never been run: base 57d22b57c's unmodified tests against this
work's production code. Four files could not compile, all three signature
changes enumerated in REFACTOR-LOG.md; the other 48 ran 323 tests with 16
failures and zero regressions -- 14 source-text guards over a main() that
item 9 deliberately emptied, and 2 for the CSV header growing to 12 columns.

Full build green: 1148 tests, 0 failed, 29 skipped.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The verification step the plan asked for and that had never been run: a real
server-pack generation over a modpack containing Fabric API, with and
without a clientside list naming it.

Two of the three pass. `aHistoricalFabricDependencyAlsoRescuesFabricApi`
fails, and it is a real defect rather than a bad test.

Verified against the real artifact rather than assumed: Fabric API
0.92.11+1.20.1, fetched from Modrinth's CDN, declares `"id": "fabric-api"`
and `"provides": ["fabric"]`. So a mod writing `depends: {"fabric": "*"}` --
the historical id, and the one Griefed named when asking for this work -- is
satisfied by that jar through `provides`.

Neither FabricScanner nor QuiltScanner reads `provides`; both record only
`id` and `depends`. ModListCompiler's rescue then matches
`ModDependency.modID` against the disabled mod's own `modID` literally,
"fabric" against "fabric-api", and misses. A custom clientside list naming
Fabric API therefore still strips it out from under every mod that declares
the historical id -- producing exactly the pack that installs and dies on
load which B0 was meant to prevent.

So B0 only half-landed: it fixed the exclusion sets, but the rescue cannot
use what it now records unless the alias is recorded too.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Turns the previous commit's red guard green. API suite 374, 0 failed.

Completes B0, which had only half-landed. Making `fabric` a real dependency
again was necessary but not sufficient: ModListCompiler's rescue matched
ModDependency.modID against the disabled mod's own modID literally, so
"fabric" was compared to "fabric-api" and missed.

Verified against the real artifact rather than reasoned about: Fabric API
0.92.11+1.20.1 from Modrinth's CDN declares "id": "fabric-api" with
"provides": ["fabric"] and 53 nested jars. The newest builds have dropped
the block entirely, so its absence must stay normal -- pinned.

ScannedMod gains `provides`, behind @JvmOverloads so the previous
(File, String, Sideness, List) JVM constructor descriptor survives; pf4j
loads compiled plugin jars, so binary compatibility is the contract that
binds. FabricFamilyScanner gains an open readProvides defaulted to empty,
overridden by FabricScanner (flat array) and QuiltScanner (nested under
quilt_loader, entries either a bare string or an object with an id -- both
shapes occur and both are pinned, because reading one silently yields a
plausible empty list rather than an error).

The rescue loop's condition and body had duplicated the same three-level
match; both now call one `dependantOf`, which is where the alias lookup
lives.

Recorded in API-BEHAVIOUR-CHANGES.md.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Three verification steps the plan specified and that had never been run.
The middle one found the `provides` defect fixed in this branch; the third
proves the whole staging chain on a real boot, with the "previously" half
confirmed from the pre-branch source rather than asserted.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The plan's last unrun verification. Six pre-registered candidates ground on
57d22b57c and on current code, fresh home each, shared loader cache.

Four false positives removed, three controls held, zero regressions. The
controls holding is what makes the removals meaningful: arcane-vortex's FML
invalid-dist and avm-mod's class_746 still reach HIGH, so this is not a
blanket softening of the classifier.

astronomical/Quilt is the strongest result -- HIGH(CRASHED) to LOW(SURVIVED)
with the correct QFAPI for Minecraft 1.19.2 staged. Not excused, proven
server-safe once the pack was assembled correctly.

Scale is stated rather than implied: 6 candidates, not the plan's ~100. The
available Docker VM was 2 CPUs / 1.93 GiB, where one multi-loader candidate
costs ~20 minutes.

Also notes a cosmetic defect the results surfaced: stagedDependencies can
list the same jar twice when it resolves through both the platform and the
jar manifest.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The clientside test tree did not compile from clean, and had not since
`18f59b4bf feat(clientside): stage the dependencies a jar manifest declares
alone` added an abstract `val name: String` to ModPlatform without updating
the two anonymous implementations in the test tree.

Incremental compilation hid it: adding an abstract member to an interface is
an ABI change, but Gradle never recompiled those two files, so every build
since -- including the full green ones this work was merged on -- was green
without ever compiling them. `--rerun-tasks` is what exposed it. A build
that is green for the wrong reason is worth more attention than the two
lines it took to fix.

`platform.name` is read on the staging path (`platformRefFor` maps a
manifest mod-id per platform), so neither fixture could answer with
`error(...)` the way its other members do. AttemptStagingIsolationTest's
factory already took a `name` it never wired up, and now returns it -- the
same name its ProjectFiles carry, which is precisely the agreement that
fixture exists to exercise. BootVerifierSelectionTest gets "unused", a
spelling no platform uses, so a mod-id resolves to nothing there.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Red on this commit. Found by reading B6's merge-gate results rather than
the code: `amblekit/Fabric` recorded `fabric-api-0.100.8+1.20.6.jar` twice
in its stagedDependencies.

A dependency is reachable under two different-but-equally-valid
identifiers -- the platform ref the author linked (`P7dR8mSH`) and the mod
id the jar's own manifest declares (`fabric`, mapped by ModIdRegistry to the
slug `fabric-api`). `stageableRequirements` dedupes by *ref*, so it cannot
see that those resolve to one file; only the file name can.

Cosmetic in the report, but not against the cap: double counting refuses a
pack that is within it, and `refuseForTooManyDependencies` scores a refusal
INCONCLUSIVE -- so the mod silently stops being verified. The guard uses
seven distinct jars recorded twice, because six duplicated is twelve entries
and the cap is twelve, which would have passed while testing nothing. The
companion guard keeps the cap biting on thirteen genuinely distinct ones.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Turns the previous commit's guard green. Clientside suite 223, 0 failed.

Fixed at both layers, because they are two different contracts:

- The staging site no longer records a file it already holds, which is what
  reaches `stagedDependencies` and so the report's Dependencies column and
  the CSV.
- `refuseForTooManyDependencies` counts distinct files, so the cap's own
  contract is honest for any caller rather than only for the one path that
  now happens to hand it a clean list.

Ref-level dedupe cannot do this. `stageableRequirements` compares the
platform ref the author linked (`P7dR8mSH`) against the mod id the manifest
declares (`fabric`, mapped to the slug `fabric-api`); both name the same
project and neither string equals the other, so only the resolved file name
identifies it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Two things B6's gate surfaced that are worth a reader knowing before they
touch staging: a dependency is identified by its resolved file rather than
by a ref, because one project has two equally-valid ref spellings; and a
green build is not evidence that the test tree compiles, which is why
--rerun-tasks earns its place.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
merge: B6's merge gate, the provides rescue, and a build that was green for the wrong reason
All checks were successful
Documentation / Writerside webhelp (push) Successful in 2m33s
Continuous / Build JAR (push) Successful in 13m27s
Qodana / scan (push) Successful in 12m27s
Docker Test / build image (push) Successful in 16m46s
Documentation / Help image (push) Successful in 3m9s
Continuous / Build AppImage (x86_64) (push) Successful in 2m35s
Continuous / Build AppImage (aarch64) (push) Successful in 2m55s
Qodana / notify (push) Successful in 9s
Continuous / Build Install4J Media (push) Successful in 9m35s
Test / build (push) Successful in 15m30s
Continuous / Continuous Pre-Release (push) Successful in 5m0s
bd5f8282c6
Closes the last unrun item of the plan, and two defects found by running it.

B6's merge gate: six pre-registered candidates ground on 57d22b57c and on
current code, fresh home each, shared loader cache. HIGH verdicts 8 -> 4 --
four false positives removed, three controls held, zero regressions. The
controls holding is what makes the removals meaningful: arcane-vortex's FML
invalid-dist and avm-mod's class_746 still reach HIGH, so this is not a
blanket softening of the classifier. astronomical/Quilt is the strongest
result, HIGH(CRASHED) -> LOW(SURVIVED) with the correct QFAPI for Minecraft
1.19.2 staged: not excused, proven server-safe. Scale is stated rather than
implied -- 6 candidates, not the plan's ~100, on a 2-CPU / 1.93 GiB Docker
VM where one multi-loader candidate costs ~20 minutes.

Two defects, each pinned red before its fix:

- B0 had only half-landed. Fabric API 0.92.11+1.20.1 declares
  "id": "fabric-api" with "provides": ["fabric"] (verified against the jar
  from Modrinth's CDN; the newest build has dropped the block). No scanner
  read `provides`, so ModListCompiler's rescue compared "fabric" to
  "fabric-api" and missed, and a clientside list naming Fabric API still
  stripped it from under every mod using the historical id. ScannedMod now
  carries `provides` behind @JvmOverloads, and the rescue matches a mod's id
  and its aliases.
- A staged dependency was counted by ref rather than by file, so one jar
  reachable as both `P7dR8mSH` and `fabric-api` was recorded twice -- and
  double-counted toward MAX_INJECTED_DEPENDENCIES, refusing packs within the
  cap and scoring them INCONCLUSIVE.

And one that was hiding: the clientside test tree had not compiled from
clean since 18f59b4bf added an abstract ModPlatform.name without updating
two anonymous implementations in tests. Gradle's incremental compilation
never recompiled them, so every green build since -- including the ones this
work was merged on -- was green without ever compiling those files.

Also here: the Docker integration suite (9/9, no skips) and the Fabric API
acceptance check, both specified by the plan and never run until now.

Full build green: 1156 tests, 0 failed, 29 skipped.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
deploy/update-grinder.sh: clone a branch, build it as an unprivileged
account, install it, restart. Run as root, which is the inverse of
install-grinder.sh and for a reason -- its only privileged act is dropping
to the build account.

It builds as a dedicated build account rather than as `grinder`. The service
account is created nologin and without sudo, which is correct for a service
account, and the installer needs both -- it calls sudo about thirty times.
`sudo -u grinder -i ./install-grinder.sh` therefore fails twice over: -i
runs the account's login shell, which is /usr/sbin/nologin, and the account
could not sudo even if it had one. Both prerequisites are checked before
anything is cloned, because otherwise the missing docker group surfaces
inside the installer as a stopped daemon and the missing NOPASSWD as a hang
on a prompt with no TTY to answer it.

Three decisions that are easy to get wrong by hand, so the script encodes
them:

- The checkout lives OUTSIDE the install prefix, and a path inside it is
  refused. install-grinder.sh ends with `chown -R root:root $PREFIX`, which
  would take the build tree with it and leave the next build unable to write
  its own source. The guard uses a trailing slash so a sibling like
  /opt/spc-grinder-src is not mistaken for a child of /opt/spc-grinder.
- The service is not stopped or started here. The installer does both, and
  its EXIT trap restarts the service if the install dies halfway -- which
  only fires if the installer was the one that stopped it.
- `bash -lc`, never `-i`, and `-H` so Gradle gets the build account's own
  ~/.gradle instead of failing on root's.

Preflight mirrors the installer's: `set -Eeuo pipefail`, an ERR trap naming
the line, and the same absolute/two-components-deep shape check on the
directory it is about to `rm -rf`. Untested by design -- no test in this
module references the deploy scripts.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
deploy/update-grinder.sh — clone a branch, build it as an unprivileged
account, install it, restart. Run as root, whose only privileged act is
dropping to the build account.

The question it answers: `sudo -u grinder -i ./install-grinder.sh` cannot
work, and not for a shell-syntax reason. The service account is created
nologin and without sudo — correct for a service account — while the
installer needs both, calling sudo about thirty times for systemctl, useradd
and everything under the prefix. So the build runs as a separate account
whose docker membership and passwordless sudo are verified before anything
is cloned.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The strict NOPASSWD precondition was wrong for the account operators will
actually reach for. Running with --build-user grinder failed on it, and no
amount of interactivity could have fixed that: root running `sudo -u
grinder` needs no password, but the installer's own sudo -- grinder going
back to root -- does, and install-grinder.sh creates that account with
`useradd --system`, which leaves the password locked. There is no password
anyone can type.

So the script now grants passwordless sudo for the duration and removes it
on exit, which it can do because it is root already. That is not a
meaningful escalation: the account is in the docker group, and `docker run
-v /:/host` owns the host, so it could take root whenever it liked.

The grant is validated with `visudo -c` before it counts -- a malformed
drop-in breaks sudo for the whole box, including the root shell that would
have to repair it -- written 0440, and proven to work rather than assumed,
because NSS or a mistyped account name would otherwise surface much later as
the installer hanging on a prompt. Removal is on EXIT, not on the success
path, so a build that dies at Gradle or is interrupted does not leave
permanent passwordless root behind. A drop-in left by a killed run is
refused rather than silently reused.

--no-temp-sudo keeps /etc/sudoers.d untouched. With a TTY it warns and lets
the installer prompt -- useful only for an account that has a password, and
it says so rather than implying the prompt will work for a service account.
Without one it fails as before.

Verified: the flags parse, the usage block renders, and a replica of the
trap ordering shows the drop-in removed when a later step dies, with the
exit status preserved.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Running with --build-user grinder hit the strict NOPASSWD precondition, and
no amount of interactivity could have satisfied it: the password prompt that
appears is the installer's own sudo going back to root, and
install-grinder.sh creates that account with `useradd --system`, which
locks the password. There is nothing anyone could type.

The script now grants passwordless sudo for the duration and removes it on
exit -- something it can do because it is root already, and something that
costs nothing in privilege terms because the account is in the
root-equivalent docker group either way. Validated with visudo before it
counts, proven to work rather than assumed, and removed on EXIT so an
interrupted build leaves nothing behind.

--no-temp-sudo keeps /etc/sudoers.d untouched and, with a TTY, lets the
installer prompt -- while saying plainly that a service account has no
password for that prompt.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Red: planManifestDependency does not exist yet.

Observed live on CurseForge/attributefix at Minecraft 1.21.11. Its manifest
declares `Depends on 'fabric-api' (-∞, ∞)`; nothing was staged for it, and
the boot ran anyway. Quilt Loader refused the pack with "AttributeFix
requires any version of fabric-api, which is missing!" and the candidate
wore the verdict -- precisely what the refusal path exists to prevent.

The cause is a contract this file already documents and the code does not
keep: unsatisfied is "platform-declared misses, AND manifest ids the
registry did map and then failed to stage", unmapped is "ids that map to no
project at all". stageManifestDependencies has three failure branches, and
only the download one honours that. The `no usable file` branch files a
mapped, resolved dependency under unmapped, which never refuses.

Pinned as a planner rather than through the boot path: the decision was
spread over three adjacent branches that had already drifted, which is how
this happened, and driving stageManifestDependencies directly needs the full
boot harness. Four guards -- the refusal, both guess-that-missed cases, and
the happy path picking a file that actually fits the pack.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Turns the previous commit's four guards green. Clientside suite 227, 0 failed.

stageManifestDependencies had three failure branches and only the download
one kept the rule the function's own doc states. A dependency that mapped to
a real project, resolved on the platform, and then had no file for this
loader and Minecraft version was filed under `unmapped` -- which never
refuses -- so the boot went ahead without it.

That is what happened to CurseForge/attributefix at Minecraft 1.21.11: its
manifest declares `fabric-api`, nothing was staged, the boot ran, and Quilt
Loader ended it with "AttributeFix requires any version of fabric-api, which
is missing!". The mod was blamed for a pack we assembled incomplete.

The decision now lives in one place, `planManifestDependency`, returning a
ManifestDependencyPlan rather than being spread over branches that can drift
apart again. It is pure -- no network, no disk -- so the rule is testable on
its own; `visited` bookkeeping stays in the loop, claimed before resolution
exactly as before, which keeps it that way.

Not a blanket tightening: an id that maps to nothing, or to a project the
platform does not carry, is still only a guess that missed and still never
refuses. Only "we mapped it, we resolved it, and then we could not stage it"
became fatal, which is the case the split was written for.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Red. Observed live on Modrinth/architectury-api at Minecraft 1.20.4 / Quilt:
"The Minecraft server .JAR is missing (/srv/pack/server.jar)!" followed by
"Missing game jar at". Quilt's launcher aborts before Loader starts, so no
mod is ever loaded -- and it scored off the exit code alone.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Turns the previous commit's guard green. Clientside suite 228, 0 failed.

Joins launchFailureMarkers, whose band is exactly this: the JVM never got as
far as running the server, so nothing about the mod was exercised. Both
spellings are matched -- the shipped template's own "The Minecraft server
.JAR is missing", and Quilt's "Missing game jar at" -- because a pack can
fail on either depending on which layer notices first.

This only stops the mislabelling. The reason the jar goes missing is a
template bug, fixed separately.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Red on the gap; the companion guard for a complete offline pack passes
already, so the fix cannot buy the first by breaking the second.

Observed live 2026-08-30: Modrinth/architectury-api at Minecraft 1.20.4 died
with "Missing game jar at /srv/pack/server.jar" and the mod was blamed. The
vanilla jar is fetched only as a side effect of installing the launcher --
`--download-server` sits inside the branch that runs when
quilt-server-launch.jar is absent -- so a pack that has the launcher and not
the jar never gets one. A restored backup, a half-cleaned directory, or the
grinder's cached loader install all produce exactly that, and Quilt's
launcher refuses to start without it. Fabric is insulated by its improved
launcher; Quilt has no such fallback.

Executed rather than grepped, per the rule these templates already carry:
this fails silently, producing a plausible-looking pack rather than an
error. The stub for downloadIfNotExist mirrors the real contract -- "false"
for a file already present -- because a stub that always downloaded would
never reach the cached-launcher branch this is about.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Turns the previous commit's guard green; the complete-pack guard stays
green, so this is not bought by breaking the happy path. API suite 376,
0 failed.

setupQuilt only ever obtained the vanilla server jar as a side effect of
installing the launcher: --download-server sits inside the branch that runs
when quilt-server-launch.jar is missing. A pack that kept its launcher and
lost the jar therefore never fetched one, and Quilt's launcher refused to
start with "Missing game jar at .../server.jar". That is a restored backup,
a half-cleaned directory, or -- how it was found -- the grinder's cached
loader install, where Modrinth/architectury-api at Minecraft 1.20.4 was
scored against the mod for it. Fabric does not share the hole: its improved
launcher carries the server itself.

The jar is now checked for and fetched independently, and a failure to get
it crashes with a message naming the cause instead of leaving the launcher
to fail three steps later.

Applied to all three shipped templates so they do not drift.

Verification, stated exactly: bash is EXECUTED by the new guards. fish is
syntax-checked with `fish -n` in an alpine container, because fish is not
installed here and the suite skips that check when it is absent. PowerShell
could NOT be parse-checked on this machine -- the amd64 image crashes qemu
on Apple Silicon and Microsoft publishes no arm64 tag -- so it is verified
by inspection only: every helper it calls (DownloadIfNotExists,
RunInstallerJavaCommand, DeleteFileSilently, CrashServer) exists with that
spelling, $QuiltInstallerUrl is in scope, and both `-Not (...)` and
`Test-Path -Path ... -PathType Leaf` are already used in the file. Worth a
CI parse on an amd64 runner.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
merge: two live failures — one dependency-resolution, one not
Some checks failed
Documentation / Writerside webhelp (push) Successful in 2m31s
Continuous / Build JAR (push) Successful in 14m3s
Continuous / Build AppImage (x86_64) (push) Has been cancelled
Continuous / Build AppImage (aarch64) (push) Has been cancelled
Continuous / Build Install4J Media (push) Has been cancelled
Continuous / Continuous Pre-Release (push) Has been cancelled
Documentation / Help image (push) Has been cancelled
Qodana / notify (push) Has been cancelled
Docker Test / build image (push) Has been cancelled
Test / build (push) Has been cancelled
Qodana / scan (push) Has been cancelled
1d342eb51a
From two boot logs on the running grinder. They looked like one problem and
were three.

CurseForge/attributefix at Minecraft 1.21.11 IS dependency resolution. Its
manifest declares `fabric-api`, nothing was staged for it, the boot ran
anyway, and Quilt Loader ended it with "AttributeFix requires any version of
fabric-api, which is missing!" -- the candidate wearing a verdict earned by
an incomplete pack. stageManifestDependencies has three failure branches and
only the download one kept the rule the function documents: a dependency we
mapped to a real project, resolved, and then could not stage is a case we
chose to trust, so failing it must refuse. "Resolved but nothing usable" was
filed under unmapped, which never refuses. The decision now lives in one
pure planner instead of three branches that had already drifted.

Modrinth/architectury-api at Minecraft 1.20.4 is NOT dependency resolution.
It died on "Missing game jar at /srv/pack/server.jar": setupQuilt fetches
the vanilla server jar only as a side effect of installing the launcher, so
a pack that kept its launcher and lost the jar never gets one. Fixed in all
three templates, and pinned by executing the bash one. The classifier also
learned that a pack with no game jar is a launch failure -- no Loader, no
mods, nothing exercised -- so it can never again be scored as the mod's
crash.

Full build green: 1163 tests, 0 failed, 29 skipped.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
install-grinder.sh --clear deletes the daemon's data directory before
installing; update-grinder.sh --clear forwards it. The goal is a fresh
start: no verdicts, the crawl back at its head, nothing carried over.

It removes SPC_GRINDER_HOME -- /home/grinder/.spc-grinder in the shipped
unit, $HOME/.spc-grinder in GrinderConfiguration.defaultHome -- which holds
the verdict store, cursors, the re-grind queue, kept boot logs and the
loader cache. The cache is the expensive half to rebuild, at ~150 MB per
loader/Minecraft tuple, so the step names each artefact it found rather than
reporting "the data" as one thing.

Placement is load-bearing: it runs AFTER step 3 has stopped the service.
Verdict writes are coalesced and the shutdown hook flushes them, so clearing
a running daemon would just get the store written back out of memory as it
stops -- the one ordering that makes this silently not work.

Guarded before anything is built, on the same shape rule PREFIX already
carries: absolute, at least two components deep, and never the account's own
home or the install prefix. A SPC_GRINDER_HOME of /home/grinder would
otherwise take the dotfiles, ~/.gradle and the Playwright browsers with it;
one of /opt/spc-grinder would delete the binaries just installed.

Verified by executing the real block -- extracted from the shipped script,
run against a fake tree with sudo stubbed: it reports the size, names only
the artefacts actually present, removes the data directory and leaves the
account home standing; the absent-directory branch says so and exits 0. The
four guard cases are exercised in isolation.

Also carries Griefed's change of the update script's default build user from
spcbuild to grinder, which was uncommitted in the working tree.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
merge: a --clear flag for installing onto a clean slate
All checks were successful
Documentation / Writerside webhelp (push) Successful in 1m31s
Continuous / Build JAR (push) Successful in 13m33s
Qodana / scan (push) Successful in 13m24s
Docker Test / build image (push) Successful in 19m46s
Continuous / Build AppImage (x86_64) (push) Successful in 3m13s
Documentation / Help image (push) Successful in 6m56s
Continuous / Build AppImage (aarch64) (push) Successful in 3m27s
Qodana / notify (push) Successful in 24s
Test / build (push) Successful in 18m34s
Continuous / Build Install4J Media (push) Successful in 11m8s
Continuous / Continuous Pre-Release (push) Successful in 6m49s
18cb636260
update-grinder.sh --clear forwards to install-grinder.sh --clear, which
deletes the daemon's data directory before installing: the verdict store,
the crawl cursors, the re-grind queue, the kept boot logs and the loader
cache. Binaries are untouched -- this is about state, not code.

Two things make it safe rather than merely blunt. It runs after the service
has been stopped, which is load-bearing: verdict writes are coalesced and
the shutdown hook flushes them, so clearing a running daemon would only get
the store written back out of memory as it stops. And the path is guarded on
its shape before anything is built, refusing a SPC_GRINDER_HOME that
resolves to the account's whole home or to the install prefix.

Verified by executing the real block against a fake tree with sudo stubbed,
rather than by reading it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Without it the script is an update script and presumes what a first install
leaves behind. The first thing it hits is a chicken-and-egg: it needs the
build account to exist, and the account is created by install-grinder.sh
step 4, which it dies before reaching.

--bootstrap folds three of the five first-install gaps into this script. It
creates the build account when missing -- as a system account with a nologin
shell, the way install-grinder.sh creates the service account, which costs
nothing here because this script runs `bash -lc` rather than `sudo -i`. It
adds that account to the docker group. It passes --install-unit, and
`systemctl enable --now`s the service at the end, so a bootstrapped host
comes back after a reboot.

It grants the docker group ONLY to an account it created. That group is
root-equivalent, install-grinder.sh already refuses to grant it to an
account it did not create, and bootstrapping is not a reason to be less
careful than the script it calls; a pre-existing account is refused with the
usermod line to run deliberately.

Two checks moved earlier or added outright, both because they used to
surface far from their cause:

- Docker's presence and daemon are checked here, not left to the installer,
  which only reaches its own docker checks after this script has wiped the
  checkout and cloned again.
- The BUILD JDK is now checked at all. Nothing did. install-grinder.sh's JVM
  step asks whether systemd will find a java for the SERVICE, which is a
  different question answered much later, so a host with no JDK failed
  inside Gradle talking about JAVA_HOME and nothing about how it got there.

It deliberately installs no packages -- apt/dnf/pacman differ, Docker's
convenience script is its own decision, and installing a container runtime
unattended is a bigger step than an update script should take. Both missing
prerequisites are named with the command to fix them.

The header now carries the whole first-install-vs-update explanation, so the
script answers "can I just download this and run it?" on its own.

Verified: flags parse, --help renders the block, the --install-unit
forwarding neither aborts on an empty array under `set -u` nor duplicates an
argument the caller already passed, and the docker-group decision refuses
for a pre-existing account in both bootstrap and non-bootstrap modes.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Answers "can I download this to a fresh host and run it?" -- which was no,
and the first blocker was a chicken-and-egg: the script needs the build
account to exist, while the account is created by install-grinder.sh step 4,
which it dies before reaching.

--bootstrap creates the build account, adds it to the docker group, passes
--install-unit and enables and starts the service. It grants the docker
group only to an account it created, matching install-grinder.sh's refusal
to hand root-equivalent privilege to an account it did not make.

Two prerequisites that used to surface far from their cause are now checked
up front: Docker's presence and daemon here rather than after a clone, and
the BUILD JDK at all -- nothing checked it, because the installer's JVM step
asks a different question about the service's java, so a host with no JDK
failed inside Gradle.

No packages are installed; both are named with the command to fix them.

The header carries the full first-install-vs-update explanation so the
script answers the question on its own.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Red: installUnavailableMessage does not exist yet.

Reported by Griefed 2026-08-30, reading "Pack post-processing failed: No
cached loader install for Forge 61.2.1 / Minecraft 1.21.11" and reasonably
asking why the grinder does not just install it. It does.
LoaderCache.ensureInstalled runs the installer on a miss; it returns null
only when the install FAILED, or when the tuple is on cooldown after failing
recently. The message named a cache miss, which is the one thing that cannot
be the cause -- a miss is what triggers an install -- and threw away the
reason the cache already knew.

The distinction has to reach the verdict, not just a log line: this string
becomes the verdict detail, and the cooldown path logs at DEBUG, so at
default levels the verdict is the only place an operator can see the cause
at all.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Turns the previous commit's three guards green. Grinder suite 410, 0 failed.

"No cached loader install for <tuple>" named a cache miss, and a miss is
precisely what triggers an install. ensureInstalled returns null only after
an install failed, or while a recent failure is on cooldown, so the message
described the one state that cannot be the cause and discarded the one the
cache knew.

The cooldown is asked for AFTER the null rather than before: ensureInstalled
may have recorded the failure that starts the cooldown during that very
call, and the operator wants the state that resulted.

No behaviour changes -- installs were always attempted on a miss. What
changes is that the verdict detail now distinguishes "the install failed"
from "a recent failure is still on cooldown, so it was not retried", and
says in both cases that this says nothing about the mod. That second half
matters because the cooldown path logs at DEBUG: at default levels the
verdict was the only surface carrying the cause, and it was carrying the
wrong one.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
merge: an unavailable loader install now says why
All checks were successful
Documentation / Writerside webhelp (push) Successful in 2m34s
Qodana / scan (push) Successful in 11m6s
Continuous / Build JAR (push) Successful in 14m0s
Docker Test / build image (push) Successful in 18m50s
Qodana / notify (push) Successful in 20s
Documentation / Help image (push) Successful in 6m31s
Continuous / Build AppImage (x86_64) (push) Successful in 3m9s
Continuous / Build AppImage (aarch64) (push) Successful in 3m40s
Test / build (push) Successful in 19m19s
Continuous / Build Install4J Media (push) Successful in 14m25s
Continuous / Continuous Pre-Release (push) Successful in 7m18s
2df7be3163
"No cached loader install for Forge 61.2.1 / Minecraft 1.21.11" described a
cache miss, which is the one state that cannot be the cause: a miss is what
makes LoaderCache install. The null comes from a failed install, or from a
recent failure still on cooldown -- and the message threw that away.

No behaviour change; installs were always attempted on a miss. The verdict
detail now distinguishes the two, and says in both cases that it says
nothing about the mod. That matters because the cooldown path logs at DEBUG,
so the verdict was the only surface carrying a cause at all.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`6de769d05 refactor(grinder): extract the configuration and the sweep out of
main` removed 239 lines from GrindPoolShutdownTest and left an empty class:
a licence header, ten unused imports, a KDoc still claiming to pin what
`systemctl stop` must do, and `{ }`.

Three of its eight guards were re-homed to the GrindLoopTest that commit
added -- they covered the sweep, which moved. Five covered GrindPool itself,
which did not move, and they were dropped:

  interruptsAWorkerParkedInABootRatherThanWaitingOutItsBudget
  givesUpAfterTheGraceWindowWhenAWorkerWillNotQuit
  stopsWorkersTakingFurtherCandidates
  tracksEveryWorkerBeforeAnyOfThemCanRun
  neverReportsACleanStopWhileAWorkerIsStillRunning

After that, awaitStop's grace window was executed by no test at all; the
only references left were ShutdownWiringTest's source-greps of main's body,
which assert the call is written rather than that it behaves. The path is
the one this module's CLAUDE.md calls load-bearing -- containers belong to
the docker daemon's cgroup, so the hook is the only thing that can stop
them, and TimeoutStopSec is sized against exactly this window.

The loss was silent: a class with no @Test emits no TEST-*.xml, so the
grinder's total went 344 -> 351 -> 410 across the range with five guards
leaving and no count moving.

Restored verbatim from 6de769d05^, minus the three that legitimately moved,
and minus the dead imports. All five pass unchanged against today's
GrindPool, so the deletion cost coverage but concealed no regression.

Teeth checked by mutation rather than assumed. Mutating requestStop() to a
no-op changes nothing -- awaitStop sets stopRequested itself, so that
mutation is vacuous, not the guards. Mutating awaitStop to neither flag the
stop nor interrupt fails exactly the two that depend on it
(interruptsAWorkerParked..., stopsWorkersTakingFurtherCandidates); the other
three hold distinct mechanisms -- grace-window expiry against a stubborn
worker, worker tracking, and the return value not lying -- which that
mutation does not reach.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Audit iteration 30 filed this as "nothing in the build or CI runs a clean
compile". That was wrong, and checking it mattered: test.yml runs on every
push and builds on a fresh runner, so the gate exists.

It also fired. Forgejo runs #301 (592f1ce21) and #306 (388b6e3a2) both went
failure on develop and stayed that way for about a week. Verified locally
that 388b6e3a2's test tree genuinely does not compile -- two anonymous
ModPlatform implementations -- so the red was real rather than flaky.

The half that is worth writing down is why it stayed invisible here: a plain
build of that same commit reports BUILD SUCCESSFUL in 5s off the build
cache, and only --rerun-tasks makes it fail in 21s. So every local full
build agreed with itself and disagreed with CI.

No workflow change: adding a second clean-compile step would not have helped
when the first one's failure was not read.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
api 376, clientside 228, grinder 415 (was 410 — the five guards audit H1
put back). Read from <module>/build/test-results/test/*.xml after a full
build: 1171 tests, 0 failed, 29 skipped.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
H1 closed and verified: five guards restored, file emits a TEST xml again,
awaitStop executed at five call sites, teeth confirmed by a mutation that
was itself checked for meaning.

M3 recorded as wrong: CI has the gate and it fired -- runs #301 and #306
both failed on develop and went unactioned -- with the local build-cache
masking (5s SUCCESS vs 21s FAILED) as the reason it stayed invisible here.

Iteration 31 finds no HIGH; M2 carried forward as unfixable process
history, one LOW noted and deliberately not fixed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Iteration 30 audited the 108 commits since iteration 29 and found one HIGH:
`6de769d05 refactor(grinder): extract the configuration and the sweep out of
main` deleted five executing shutdown guards and left GrindPoolShutdownTest
an empty class whose KDoc still claimed to pin them. After it,
GrindPool.awaitStop's grace window was executed by no test at all -- only
source-greps of main's text remained -- on a path this module's own docs
call load-bearing. The loss was silent: a class with no @Test emits no
result XML, so the grinder's total moved 344 -> 351 -> 410 with five guards
leaving and no count changing.

Restored verbatim, minus the three that legitimately moved to GrindLoopTest.
All five pass unchanged, so the deletion cost coverage but concealed no
regression. Teeth confirmed by mutation -- and the first mutation was the
wrong one, which is recorded, because "survived a mutation" means nothing
until the mutation is shown to be meaningful.

The audit's own M3 was wrong and correcting it was worth more than the fix
would have been: CI does run a clean compile on every push, it did catch
this, and runs #301 and #306 sat red on develop for about a week. Locally it
stayed invisible because a plain build of the same commit succeeds in 5s off
the build cache while --rerun-tasks fails it in 21s. No workflow change --
a second gate cannot help when the first one's red is not read.

Iteration 31 re-audits the fixes: no HIGH, one carried-forward MEDIUM that
merged history makes unfixable, one LOW deliberately left.

Full build green: 1171 tests, 0 failed, 29 skipped.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Red: Unresolved reference 'BootDecision' / 'decidedBy' -- neither exists.

Sampled from the deployed grinder on 2026-08-31. Four of five published
boot logs were scored CRASHED by the bare exit-code rung -- eligible for
a clientside HIGH -- on no sideness evidence at all, and create_ltab was
already in the served fallback list because of it:

  create_ltab  Fabric/mc1.20.6  mixin @Inject found no target      -> CRASHED
  debugify     Forge/mc1.19.1   @Shadow field f_25782_ not located -> CRASHED
  create_ltab  Quilt/mc1.21.1   Quilt "Unhandled solver error"     -> CRASHED
  damagevignette NeoForge/1.20.4 "Missing language javafml [46,)"  -> CRASHED
  debugify     Fabric/mc26.2    "requires any version of ..."      -> INCONCLUSIVE

The root problem is that CRASHED is reachable two ways -- the
client-only-class marker, which no environment failure can fabricate, and
the exit-code fallback, which means only "exited non-zero, nothing
recognised why" -- and the two were indistinguishable afterwards. So the
guards pin a BootDecision naming the rung, with exactly two members
marked decisive: CLIENT_ONLY_CLASS and OPERATOR_RULE (a rule reaching
CRASHED stated it deliberately; an undecided rule resolves to the ladder
or to INCONCLUSIVE, never to CRASHED).

Three guards carry the most weight:

- anUndecidedRuleRidingAlongDoesNotClaimTheDecision -- a note-only rule
  must not launder a bare exit-code crash into decisive evidence.
- aClientOnlyClassInsideAMixinFailureIsStillDecisive -- a mod reaching a
  client class *through* a mixin is a genuine signal, so the new markers
  must stay below the client-class marker. Getting this backwards
  discards true positives, the expensive direction.
- theFabricDependencyFailureIsStillADependencyFailure -- the control. The
  one log already classified correctly must stay that way, or the fix has
  moved the problem rather than solved it.

FallbackPropertiesPublicationGateTest pins that HIGH alone stops being
sufficient, including that a legacy verdict with no recorded decision does
NOT publish -- which deliberately empties the grinder's 34 contributed
entries until a sweep re-grinds them.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Turns the previous commit's guards green, and closes what the deployed
grinder was doing to the served fallback list.

CRASHED was reachable two ways -- clientOnlyClassMarker, which no broken
harness can fabricate, and the bare exit-code rung, which means only
"exited non-zero and nothing recognised why" -- and afterwards the two
were indistinguishable, so /as-properties published both alike. Sampled
against the live grinder on 2026-08-31, FOUR OF FIVE published boot logs
were the latter, and create_ltab was already in the list because of it.

BootDecision names the rung that settled a boot and marks exactly two as
decisive: CLIENT_ONLY_CLASS, and OPERATOR_RULE because a rule reaching
CRASHED stated it deliberately (an undecided rule resolves to the ladder
or to INCONCLUSIVE, never to CRASHED). Classification.decidedBy carries
it through BootOutcome -> LoaderVerdict -> GrindVerdict, and
FallbackPropertiesRenderer publishes nothing else.

Three marker sets for the four misclassified logs, all BELOW
clientOnlyClassMarker so a mod reaching a client class *through* a mixin
still reads CRASHED -- outranking it there would discard true positives,
the expensive direction:

  mixinApplyFailureMarkers   @Inject/@Shadow found no target, FAILED
                             during APPLY -- the jar and its Minecraft
                             disagree, so the mod never ran
  loaderSolverFailureMarkers Quilt's "Unhandled solver error" and
                             "(0 valid options, 0 invalid options)", a
                             phrasing sharing NOTHING with Fabric's, so
                             dependencyFailureMarkers never reached it
  runtimeMismatchMarkers     "Missing language javafml version [46,)",
                             java.lang.module.ResolutionException -- a
                             Forge jar staged for a NeoForge boot

All five consoles are committed as literal excerpts, log 5 included as a
CONTROL: the one the ladder already classified correctly must stay that
way, or the fix has moved the problem rather than solved it.

A legacy verdict has no recorded decision and therefore does not publish.
That empties the grinder's 34 contributed entries until a sweep re-grinds
them, which is the intended trade -- an empty contribution beats a wrong
one, and grandfathering the old rows in would keep exactly the entries
this gate exists to remove.

Four existing tests failed on the gate, as designed: their fixtures built
HIGH verdicts with no decision. grindVerdict() now defaults to a decisive
one -- a fixture standing for "a HIGH finding" should stand for a
legitimate one -- and the refusal is pinned explicitly in
FallbackPropertiesPublicationGateTest rather than implied by every
fixture.

Also corrected two documentation drifts found while verifying: the module
doc said the ladder was "eight rungs" (it was eleven, is now fourteen) and
theGuardOrderIsPinnedAsAWhole's own KDoc omitted the rule and sandbox
rungs while asserting both. The count is replaced with an instruction to
re-derive it from classify, having been wrong twice.

Clientside 238 tests, grinder 418 tests, 0 failed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Red: Unresolved reference 'minecraftConstraint'.

Every scanner already parses this and throws it away -- Fabric and Quilt
exclude `minecraft` from dependencies as "the platform", the Forge and
NeoForge tomls route the platform entry into the sideness inference -- so
nothing downstream could ask the one question that separates a real
clientside crash from a jar booted on the wrong Minecraft.

That question is not academic. The grinder boots the NEWEST Minecraft a
platform declares for a file without ever asking what the jar was built
for, and Modrinth applies a version node's game_versions to every file of
that version. Measured 2026-08-31, that put create_ltab on 1.20.6 with
1.20.5-era mappings and debugify on 1.19.1 with another 1.19.x's; both
mixin failures were scored CRASHED, and one of the two mods is in the
served fallback list.

forgeReportsTheMinecraftRangeWithoutLosingItsSideness is the one to watch:
the same [[dependencies]] entry carries the versionRange being captured
AND the side= that decides the mod's own sideness, so capturing must not
disturb the inference. theMinecraftEntryIsStillNotADependency pins that
this is additive rather than a change to what dependencies already
returns.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Turns the -api guard green and closes the root cause behind three of the
five real logs.

BootCandidateSelector trusts the platform absolutely. It boots the NEWEST
Minecraft in a file's declared set, and Modrinth applies a version node's
game_versions to EVERY file of that version -- so a mod ticked for
[1.20.4, 1.20.5, 1.20.6] is booted on 1.20.6 whatever it was compiled
against. Where one file claims two loaders (a Modrinth version tagged
[forge, neoforge] with two primary jars, or one CurseForge upload tagged
["1.20.4","Forge","NeoForge"]), the stable sort hands the NeoForge attempt
whichever jar the platform listed first.

Measured 2026-08-31: create_ltab on 1.20.6 with older mappings, debugify
on 1.19.1 with another 1.19.x's, and DamageVignette-2.0.2-forge+mc1.20.jar
under NeoForge 20.4.251. All three died, all three were scored as sideness
evidence, and create_ltab is in the served fallback list because of it.

The jar's own descriptor is the authority the platform metadata is not:

- -api: ScannedMod.minecraftConstraint. Every scanner already parsed this
  and discarded it -- Fabric and Quilt exclude `minecraft` as "the
  platform", the Forge/NeoForge tomls consume the platform entry for its
  `side`. Purely additive; the platform entries still stay out of
  `dependencies`, and the Forge reader is separate from
  getSidenessesAndDependencies precisely so capturing the range cannot
  disturb the sideness that same entry decides.
- -clientside: JarSelfDeclaration compares the descriptors a jar carries,
  and its declared Minecraft range, against what is about to be booted.
  Hooked into stageBootPack before generateServerPack, so a contradiction
  costs no container at all.

FAIL TOWARD ACCEPT is the whole design, not a detail. An unreadable jar,
a missing descriptor, an unparseable constraint, an unrecognised loader, a
scan that throws -- every one boots. Only a positive, readable
contradiction refuses. A gate that refused on doubt would turn a
descriptor gap into a catalog-wide mass-INCONCLUSIVE event, which is the
shape VersionConstraint's own doc warns about and the shape a
LoaderSupportMemory once produced by marking Fabric unusable for 22
Minecraft versions inside minutes. everythingUnreadableIsAccepted sweeps
five broken jars x six loaders and anUnreadableMinecraftConstraintIsAccepted
ten malformed ranges; not one refuses.

The Quilt -> Fabric acceptance mirrors BootCandidateSelector.fallbackLoaders
and stays one-way: Quilt runs Fabric mods, Fabric cannot load a Quilt mod.

Two mistakes of mine, recorded because both are easy to repeat:
- forgeReportsTheMinecraftRangeWithoutLosingItsSideness first declared
  `minecraft` side=CLIENT AND `forge` side=BOTH, then asserted CLIENT.
  sidenessOf is SERVER unless EVERY signal says CLIENT, so the fixture was
  wrong, not the scanner. It now carries one platform entry, with a
  comment saying why.
- The new -api test pointed at src/test/resources/serverpackcreator.properties
  and SPC wrote this machine's absolute paths into the committed file --
  every other test uses the processed build/resources/test copy for
  exactly that reason. TestPropertiesTest.theCommittedTestPropertiesNameNoHost
  caught it. Reverted, switched, and the reason is at the call site.

api 381, clientside 247, grinder 418 tests, 0 failed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Characterization, and they pass as written -- which is the point: they
prove the defect exists rather than asserting it away.

Every pickBootableCandidate test in this class used a single-element
loaders set AND a single-element minecraftVersions set, so neither live
failure shape was representable, let alone covered. Both are shapes the
platforms produce routinely:

- aFileClaimingTwoLoadersIsOfferedForBoth: a Modrinth version tagged
  [forge, neoforge] with two primary jars gives BOTH ModFiles the same
  two-loader set, because loaders are read from the version node and
  applied to every file of it. The selector then offers either jar for
  either loader and, being a stable sort, takes whichever the platform
  listed first. The assertion records that a NeoForge boot receives
  DamageVignette-2.0.2-FORGE+mc1.20.jar -- exactly what happened on
  2026-08-31, dying on "Missing language javafml version [46,)".
- aFileClaimingSeveralMinecraftVersionsIsBootedOnTheNewest: one jar
  ticked for [1.20.4, 1.20.5, 1.20.6] becomes one candidate per version
  and the newest wins, which put create_ltab on 1.20.6 against older
  mappings.

Neither is a complaint about the selector: it cannot tell two jars apart
from metadata that describes them identically, and it has no notion of
what a jar was compiled for. JarSelfDeclaration is what refuses the pick
afterwards, from the jar's own descriptor, and these two tests name that
counterpart so the pair is legible from either side.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
GrinderAuditIT, gated GRINDER_AUDIT_IT=1, asks one question of the whole
store: is every published HIGH decided by a rung BootDecision.decisive
marks? Failure prints the distribution by decision, so a defect shows up
as a bucket rather than as a surprise; the assertion names the tuples.

It exists because sample-and-fix has failed twice. A 200-log census on
2026-08-29 and a merge gate reporting HIGH 8 -> 4 both preceded the
2026-08-31 finding that four of five sampled logs were scored CRASHED by
the bare exit-code rung, with one of the mods already in the served list.
Neither census was committed -- verified, no such script exists in the
repo or its history -- so nothing re-checked the published list against
the consoles behind it and the loop never closed.

Implementation notes worth keeping:

- /boot-logs is HTML-ONLY. There is no machine-readable listing, so names
  are scraped from its ?name= hrefs, and its links point at the /crash-log
  alias rather than /boot-log.
- A stored name is <tuple>~<attempt>~<artifact>, so the tuple is
  everything before the first '~'. That holds because slugs and loaders
  contain '-' but never '~' -- which is why ATTEMPT_SEPARATOR was chosen.
- It assumes a non-zero exit when re-classifying, deliberately. The exit
  status is not published, and every rung a HIGH can legitimately come
  from is decided on the console alone, so assuming non-zero is what keeps
  the exit-code rung reachable and therefore COUNTED. Assuming zero would
  quietly reclassify the very population being audited.
- Politeness is part of the contract: gated, capped by
  SPC_GRINDER_AUDIT_SAMPLE (200), and a missing artifact is skipped rather
  than failing the audit.

GrindVerdict.decidedBy also becomes a report and CSV column via a new
VerdictField.DECISION, filterable as a CHOICE -- `?f.decision=EXIT_CODE`
is how a maintainer lists every verdict reached because a process exited
non-zero and nothing recognised why.

everyColumnRendersTheValueItsHeaderNames caught the new column
immediately, which is what that sentinel guard is for: it counts cells
against headers, so a column added to one and not the other cannot pass.

Grinder suite: 419 tests, 0 failed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
My own harness was wrong on its first live run and would have overstated
the finding. It graded 91 consoles for 43 HIGH verdicts, because a
candidate is booted several times -- first attempt, newest-build re-check,
each other-version re-check -- and every non-survived attempt keeps its
own console. So it counted one verdict repeatedly and, worse, counted a
re-check attempt against a verdict some OTHER attempt decided.

Now grouped by tuple, and a verdict is defensible if ANY of its kept
consoles carries decisive evidence -- the charitable reading, and the only
one that matches what a verdict means.

The difference is not cosmetic. Against the live grinder:

  per console (wrong):  67 of 91  rest on no decisive evidence
  per verdict (right):  27 of 43

Measured 2026-08-31 at grinder.serverpackcreator.de, 43 HIGH verdicts over
91 consoles, no rule file:

    16  CLIENT_ONLY_CLASS          <- provable
    12  EXIT_CODE                  <- not evidence
     8  DEPENDENCY_FAILURE         <- not evidence
     4  MIXIN_APPLY_FAILURE        <- found by the new marker
     1  RUNTIME_MISMATCH           <- found by the new marker
     1  LAUNCH_FAILURE
     1  LOADER_BOOTSTRAP_FAILURE

The audit FAILS today, which is its job. It goes green once the fixes are
deployed and a re-grind replaces those verdicts.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The census is the part worth keeping: 27 of 43 published HIGH verdicts
rest on no decisive evidence, measured against the live grinder on
2026-08-31 with GrinderAuditIT.

Includes the honest cost, because the names make it concrete: sodium-extra
and reeses-sodium-options ARE client-only but crash without decisive
evidence, so the gate drops real findings alongside the false ones. The
rule engine is the recovery path -- a verified signature written as a rule
counts as OPERATOR_RULE, which is decisive.

Also records the three mistakes of mine that existing guards caught, and
the two documentation drifts about the ladder's rung count.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Asked for rules recovering the client-only mods that lack proof. Reading
the actual consoles produced one rule, one refusal to write one, and a
harness defect worth more than either.

THE RULE. sodium-extra, reeses-sodium-options and better-block-entities
all die on `NoClassDefFoundError: org/lwjgl/Version`. LWJGL is the
client's windowing and OpenGL binding and a dedicated server never ships
it, so reaching it is proof the mod cannot run server-side -- decisive in
a way the built-in marker misses, since that one matches only
net/minecraft/client. Shipped in deploy/boot-rules.example.json and
verified against the real excerpts by ShippedBootRulesTest, which also
pins that no shipped rule overturns a ready-line or host trouble.

better-block-entities did not reach LWJGL itself; Sodium, staged as its
required dependency, did. The entry still holds: a mod whose required
dependency cannot run on a server cannot run on one either, and the
evidence is about the pack, which is the candidate plus what it requires.
That reasoning is written into the rule's comment rather than left
implicit.

THE RULE I DID NOT WRITE, and the file says why. better-ping-display,
immersive-ui and certain-questing-additions are all published HIGH and all
die on a missing log4j-core. Writing a rule would have recovered three
entries and been exactly wrong: log4j-core is a LOGGING library the server
is supposed to have.

WHICH LED TO THE REAL FINDING. All three are the same tuple, so I checked
three unrelated mods on it -- bbrb, chisels-bits, corgilib -- and every one
fails identically. corgilib is a library and chisels-bits runs on servers.
The cached loader install for NeoForge 21.11.45 / Minecraft 1.21.11 is
broken, and ALL 90 boots against it are worthless; those reaching a
non-zero exit were published as clientside. One poisoned cache entry
manufacturing false positives across an entire tuple is precisely what a
bare exit-code verdict cannot distinguish from a mod crashing on its own
merits.

So log4j-core joins runtimeMismatchMarkers -- a classifier marker, not a
rule, because it is the absence of evidence rather than evidence. That
converts the whole tuple to INCONCLUSIVE at the classifier rather than
relying on the publication gate to catch it.

ACTION FOR THE OPERATOR, which no code change covers: invalidate that
tuple in the loader cache and re-grind it. Until then those 90 attempts
tell you nothing.

Clientside 250, grinder 425 tests, 0 failed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Three verdicts recovered by the LWJGL rule, measured against the live
store rather than estimated:

  deployed, no rules      16 defensible / 27 not
  + the new markers       16 / 27, redistributed (EXIT_CODE 12->6,
                          RUNTIME_MISMATCH 1->7)
  + the LWJGL rule        19 / 24

The markers recover nothing by design -- they move verdicts to
INCONCLUSIVE, which is the correct answer. Only a verified rule recovers,
and it recovered exactly the three that were verified.

Also records the operator action no code change covers: the cached loader
install for NeoForge 21.11.45 / Minecraft 1.21.11 is broken and all 90
boots against it are worthless, so that tuple needs invalidating and
re-grinding.

And a gotcha the first audit run hit: a Gradle test JVM's working
directory is the MODULE directory, so SPC_GRINDER_BOOT_RULES wants
`deploy/boot-rules.example.json`. Prefixing the module name finds nothing
and the audit reports "0 rule(s) from none" rather than failing, which is
easy to miss in the header line -- I missed it once.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Three that were outstanding:

- API-BEHAVIOUR-CHANGES.md gains the row for ScannedMod.minecraftConstraint.
  Additive: dependencies, provides and sideness are byte-for-byte
  unchanged, and the trailing defaulted parameter under the existing
  @JvmOverloads keeps the old JVM descriptors for pre-compiled pf4j
  plugins. -app compiles and its 149 tests pass against it.
- report/CLAUDE.md documents the publication gate where it lives, with the
  27-of-43 measurement, the deliberate emptying of the legacy
  contribution, and the honest note that the gate is conservative rather
  than precise (a rule bought 3, not 30).
- The root refactor-state table carried stale counts (api 376,
  clientside 228, grinder 415). Now api 381, clientside 250, grinder 425,
  each re-derived from a real run rather than from memory -- the grinder
  results directory had been left holding a single test by an earlier
  filtered run, and Gradle then skipped the task as up-to-date, which is
  exactly the trap that column's own note warns about.

The root file also gains the general lesson, since it outlives this
sprint: a verdict that cannot name its own evidence cannot be audited, and
an environment defect looks exactly like a subject defect unless something
distinguishes them. One poisoned loader-cache entry produced identical
failures across all 90 boots against it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
merge: publish only crashes that are decisive evidence of sideness
All checks were successful
Documentation / Writerside webhelp (push) Successful in 3m8s
Continuous / Build JAR (push) Successful in 17m44s
Qodana / scan (push) Successful in 22m22s
Docker Test / build image (push) Successful in 29m9s
Documentation / Help image (push) Successful in 5m57s
Continuous / Build AppImage (x86_64) (push) Successful in 3m7s
Continuous / Build AppImage (aarch64) (push) Successful in 4m9s
Qodana / notify (push) Successful in 1m15s
Test / build (push) Successful in 19m12s
Continuous / Build Install4J Media (push) Successful in 13m14s
Continuous / Continuous Pre-Release (push) Successful in 7m52s
03047a7c04
Five real boot logs from the deployed grinder, four of which were scored
CRASHED by the bare exit-code rung and published as clientside on no
sideness evidence at all. Reading them found two systematic selection
defects, three classifier gaps, and -- via the audit built to grade the
rest -- a poisoned loader-cache entry producing identical failures across
all 90 boots against it.

What lands:

- BootDecision names which of the classifier's rungs settled a boot and
  marks exactly two as decisive: CLIENT_ONLY_CLASS and OPERATOR_RULE.
  /as-properties publishes nothing else, so a mixin that would not apply,
  a solver that gave up, a jar staged for the wrong loader or a bare
  non-zero exit can no longer reach a user's server pack.
- Three marker sets for the shapes the logs exposed, all BELOW the
  client-class marker so a mod reaching a client class through a mixin
  still reads CRASHED.
- JarSelfDeclaration asks the staged jar what it was built for before a
  container is spent, over the new additive ScannedMod.minecraftConstraint
  -- a value every scanner already parsed and discarded. It FAILS TOWARD
  ACCEPT: only a positive, readable contradiction refuses.
- GrinderAuditIT grades a live daemon's published verdicts against their
  own evidence, gated GRINDER_AUDIT_IT=1, because sample-and-fix had
  already failed twice with no committed instrument to close the loop.

Measured against the live store, 43 published HIGH verdicts:
16 defensible before, 19 after the shipped LWJGL rule, 24 still resting on
nothing. The gate is conservative rather than precise -- sodium-extra and
reeses-sodium-options are genuinely client-only but crash without proof --
and a verified rule recovers single digits, not tens.

Operator actions this merge does NOT perform: invalidate the
NeoForge 21.11.45 / MC 1.21.11 loader-cache tuple and re-grind it, and
requeue the verdicts published before the gate. Until a sweep re-grinds,
the grinder's contribution to the fallback list is deliberately empty --
an empty contribution beats a wrong one.

api 381, clientside 250, grinder 425, app 149 tests; 0 failed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Red, and the failure names the defect exactly:

  expected: <bookshelf> but was: <bookshelf-fabric>

i.e. the scan returned the FILE NAME as the mod id, which is
DescriptorScanner's documented fallback for "no descriptor could be
read" -- so the entire Fabric descriptor was discarded, dependencies and
Minecraft constraint with it.

QuiltPackScanner returns the Quilt scanner's results and substitutes the
Fabric one only where the two disagree about sideness (quilt == SERVER &&
fabric == CLIENT). Quilt deliberately runs Fabric mods and most ship no
quilt.mod.json at all, so the Quilt scan yields a default entry -- filename
id, SERVER, empty dependencies -- the Fabric scan yields the real one, both
read SERVER, they agree, and the empty entry wins.

Reported from the live grinder: bookshelf on Quilt 0.31.0-beta.3 /
Minecraft 1.21.1 died with "Bookshelf requires any version of fabric-api,
which is missing!". The dependency was never resolved because the scan
never reported it -- KnownModIds maps `fabric-api` correctly, it was just
never asked.

This is not only a grinder coverage bug. ModListCompiler's dependency
rescue reads the same scan, so a Quilt pack could have Fabric API stripped
while a kept mod depends on it -- the same class as the exclusion bug
fixed in B0, reached by a different route.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Behaviour-preserving, and separated from the change that follows it because the two are different
concerns: this commit makes the fold reachable, the next one alters what it decides.

`aggregate` folds the platform's declared support, the jar scan and the boot result into a
`Confidence`. It was a private instance method, so it could only be exercised through `report()`,
which needs a platform, a downloader and a boot. It is pure and its only collaborator,
`declaresServerSupport`, already lived in the companion — so it moves there wholesale as
`aggregateFor`, and the two in-class call sites become a rename. Three lines that the extra
indentation pushed past the column limit are wrapped.

Not one branch of the ladder changes: CRASHED → HIGH, metadataClient → MEDIUM, metadataServer → LOW,
everything else INCONCLUSIVE, in that order. Verified by diffing the `when` against its previous form.
No test changes, which is the check the conventions ask for on a `refactor:` label.

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

QuiltPackScanner returned the Quilt scan and preferred the Fabric one only
where the two disagreed about SIDENESS. Quilt deliberately runs Fabric mods
and most ship no quilt.mod.json, so the Quilt scan was DescriptorScanner's
"nothing could be read" fallback -- file name as modID, SERVER, empty
dependencies and provides, null minecraftConstraint -- while the Fabric
scan held the real descriptor. Both read SERVER, they agreed, and the empty
entry won. Everything the jar declared was thrown away.

ScannedMod gains descriptorRead, because the fallback is otherwise
INDISTINGUISHABLE BY VALUE from a real scan of a mod that declares nothing:
every field it sets is a value a genuine descriptor could also produce. It
defaults to false -- the fallback's own answer -- so only a scanner that
actually parsed something reports true, and the merge now prefers whichever
scan read a descriptor before it considers sideness at all.

Found from a live grinder boot: bookshelf on Quilt 0.31.0-beta.3 /
Minecraft 1.21.1 died with "Bookshelf requires any version of fabric-api,
which is missing!". KnownModIds maps that id correctly; nothing ever asked
it, because the scan never reported the dependency. The guard's red state
named it precisely -- expected <bookshelf>, got <bookshelf-fabric>, the
file name.

NOT ONLY A GRINDER BUG. ModListCompiler's dependency rescue reads the same
scan, so a Quilt pack could have Fabric API stripped while a kept mod
depends on it -- the same user-facing outcome as the `fabric` exclusion
bug, reached by a different route.

Recorded in API-BEHAVIOUR-CHANGES.md, landmined in the module CLAUDE.md,
and that file's own description of the merge is corrected: it still stated
the rule this commit replaces.

api 382, clientside 250, grinder 425, app 149 tests; 0 failed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Two INCONCLUSIVE populations the live store surfaced on 2026-09-01, both
throwing away something the engine had actually learned. Red:
Unresolved reference 'aggregateFor' / 'downloadFailureDetail'.

A SURVIVED BOOT COUNTED FOR NOTHING. aggregate falls through to
INCONCLUSIVE whenever the metadata says nothing -- and it says nothing
exactly when the jar scan errored AND the platform declares no sideness,
which is every CurseForge project. So the most expensive signal the engine
produces, a server that reached its ready-line, was discarded. Live:
better-stats, tcdcommons and yacl, all JarSideness=ERROR, all
"SURVIVED (exit 137)", all recorded INCONCLUSIVE -- while 2,318 other
survived boots recorded LOW or MEDIUM.

The guards pin the direction as well as the fix: a survived boot must NOT
overturn a client-only declaration (a client mod can start a server
without being any use on one), must not soften a crash, and an
INCONCLUSIVE boot must not be promoted the way a survived one is. Only the
"no metadata at all" case changes.

A REFUSAL THAT NAMES NOTHING. 21 verdicts say only "Could not download
<file>". Every one is CurseForge, and the names -- bwncr, tombstone,
entityculling, moreoverlays -- are the population this module already
documents as distribution-locked, which can only be fetched through the
headless browser. As written those 21 are indistinguishable from a 404 or
a flaky link, so nobody can tell a broken host from a broken mod.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A live grinder boot -- bookshelf on Quilt, "requires any version of
fabric-api, which is missing!" -- traced to QuiltPackScanner discarding the
Fabric descriptor whenever the jar carried no quilt.mod.json, which is most
of them. The Quilt scan was the "nothing could be read" fallback, the
Fabric scan was real, the merge compared only sideness, both said SERVER,
and the empty entry won.

ScannedMod.descriptorRead makes the fallback distinguishable, since by
value it is not. Also fixes the same loss in ModListCompiler's dependency
rescue, where a Quilt pack could have Fabric API stripped from it.

api 382, clientside 250, grinder 425, app 149 tests; 0 failed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Both come out of the live store's INCONCLUSIVE rows (2772 verdicts, read 2026-09-01).

**A survived boot was being discarded.** `aggregate` folds the platform's declared support, the
jar scan and the boot into a confidence, and it consulted `bootResult` only for CRASHED. So with
the jar scan errored and the platform declaring nothing -- which is *every* CurseForge project,
since CurseForge has no sideness field -- there was no metadata to fall back to and the most
expensive signal this engine produces landed in INCONCLUSIVE, meaning "we learned nothing", when
what it had learned was that the server started. Three live rows say exactly that: Modrinth
better-stats, Modrinth tcdcommons and CurseForge yacl, all JarSideness=ERROR, all decided
READY_LINE, all reading `SURVIVED (exit 137)` in their own detail column.

SURVIVED now yields LOW, placed *below* metadataClient in the same `when`, so the documented
asymmetry is untouched: a clean boot still cannot overturn a client-only declaration, because a
client mod can start a server without being any use on one. Only its absence of standing changed,
not its rank. `aCrashStillOutranksEverything` and
`aSurvivedBootDoesNotOverturnAClientOnlyDeclaration` pin both edges.

Exit 137 on those rows is normal and not a kill to investigate -- `ContainerServerRunner` stops
the container the moment the ready-line appears -- which is why the classifier reads them
SURVIVED and only the fold disagreed.

**A refusal now names its cause.** 21 verdicts said only `Could not download <file>`. Every one
is CurseForge, and the names -- bwncr, tombstone, entityculling, moreoverlays -- are the
population this module documents as distribution-locked: `allowModDistribution=false`, so
`downloadUrl` is null and the fetch can only go through the headless browser. Read as written
those 21 are indistinguishable from a 404 or a flaky link, so a broken *host* and a broken *mod*
produced the same sentence. `downloadFailureDetail` names the lock and the Playwright/Chromium
prerequisite it needs; an ordinary file's failure deliberately does not mention the browser, or
it would send an operator the wrong way.

The fold was moved into the companion as `aggregateFor` by the preceding `refactor(clientside)`
commit, deliberately kept apart: that one makes it reachable without a platform or a boot, this one
changes what it decides.

Suites: clientside 257, api 382 (1 skip), grinder 425 (29 skip), app 149 -- all green.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The confidence-model paragraph said only that a clean boot is not decisive, which was true and
was being read as "not evidence" — the exact conflation the fix removes. It now states both: the
asymmetry is unchanged (SURVIVED ranks below metadataClient, so it cannot overturn a client-only
declaration) and the boot is no longer discarded when there is no metadata to fall back to.

The browser-download landmine gains the refusal-detail half, and the clientside test count in the
root table goes 250 → 257, re-derived from build/test-results after the run.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`install-grinder.sh` detected a missing Chromium and printed the commands; it now runs them. Also
adds `--skip-browser` for a Modrinth-only or offline host, forwarded from `update-grinder.sh` by its
existing `--` passthrough, so the upgrade path inherits this with no change there.

**The browser is not the half that was missing, and the old section said it was.** Playwright's Java
binding downloads browsers itself on the first `Playwright.create()` — `DriverJar.installBrowsers()`,
with `PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD` to suppress it and "Failed to install browsers, exit code:"
when it cannot (read out of driver-1.62.0.jar; the Java docs do not state this and imply the
opposite). So a host that never ran an install still gets a browser. What nothing installs is the OS
libraries Chromium links against, absent by default on a headless server, and without them Chromium
launches and every navigation times out — which reads as CurseForge being slow. That is the shape of
the gap, and `clientside-boot.yml`'s reusable job corroborates it: for the same code path it installs
`install-deps chromium` and nothing else.

Both are now done. Pre-installing the browser is still worth it even though it self-installs: the
lazy download otherwise happens mid-grind, needs network at an arbitrary later moment, and its
failure surfaces as a staging failure on some mod rather than as anything about a browser. Neither
step is fatal — the daemon can still do the browser itself, and Playwright can only install deps on
Debian/Ubuntu, so killing a deployment over either would be wrong.

**Installed with Playwright's own CLI from our installed jars, never `npx playwright install`.**
Playwright pins one Chromium build per release and looks for that exact directory: 1.62.0 wants
`chromium-1234` (Chrome for Testing 151.0.7922.34, from driver-1.62.0.jar's browsers.json). `npx`
fetches whatever the npm package pins, landing beside it. That is not theoretical — this developer's
own machine holds `chromium-1223`, a different revision from another Playwright version, and the
check being replaced globbed `chromium-*`, so it would have reported success on a cache the binding
would then have ignored. Driving `com.microsoft.playwright.CLI` off `$PREFIX/lib/*` makes the version
match by construction, and makes a Playwright bump self-correct on the next upgrade rather than go
stale.

Verified, since no harness covers deploy shell scripts (the ceiling this repo set for buildSrc):
`bash -n` clean; `--help` renders the new flag and `--nonsense` is still rejected; and
`java -cp "lib/*" com.microsoft.playwright.CLI` against the real installed dist prints usage listing
both `install [browser...]` and `install-deps [browser...]`, which is the claim the change rests on.
The install itself was not run here — it would pull ~170 MB onto a machine that did not ask for it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Three commits: the red pin, the fix, the documentation. Both findings came out of reading the
live store's INCONCLUSIVE verdicts rather than from a failing test — a survived boot that had
no metadata to fall back to scored 'we learned nothing', and 21 refused downloads all said the
same sentence whether the host or the mod was broken.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Why the browser install can be skipped without anybody noticing, which is the reported symptom
("installed playwright and chromium, still getting Could not download").

`install-grinder.sh` discovered the service's JVM with

    sed -n 's/^Environment=JAVA_HOME=//p' "$script_dir/$UNIT_NAME"

i.e. from the unit **in the checkout**. Every knob in the shipped unit is commented out, JAVA_HOME
included — verified: that sed returns the empty string against it — and the operator uncomments what
they need in `/etc/systemd/system/spc-grinder.service`, which is the copy systemd reads.
`update-grinder.sh` seals it: it `rm -rf`s its checkout and re-clones on every run, so the shipped
copy is pristine every time and an operator's edit is invisible by construction.

Consequences on a host whose java comes from JAVA_HOME in the installed unit rather than from
systemd's bare PATH (a Temurin tarball under /opt, SDKMAN, asdf — none of which are on
/usr/local/sbin:...:/bin):

  - `service_java` resolved empty, so the headless-browser install added in the previous commit hit
    its no-JVM branch and SKIPPED, having printed one warning into a long transcript;
  - the pre-existing "the service will not find a JVM" warning fired at a service that starts
    perfectly well, which is how an operator learns to ignore it.

`unit_file` is now resolved once: the shipped copy when `--install-unit` will overwrite the installed
one (it is what will be in effect), otherwise the installed copy when there is one, otherwise the
shipped copy as a first-install preview. Executed against all three states, the block picks
INSTALLED / SHIPPED / SHIPPED respectively. The startup banner prints which copy it read, because
every check in the preflight means something different depending on the answer.

JAVA_HOME deliberately does not go through `unit_value`, which keeps the *first* `Environment=` line:
`Environment=SPC_GRINDER_HOME=` sits at line 56 and JAVA_HOME at 158, so that helper would have
returned the wrong variable.

Two related traps closed while here:

  - **The closing summary told the operator to edit the checkout's unit.** With update-grinder.sh that
    directory is deleted at the start of the next run, so a configuration made there disappears with
    no indication why. It now names the installed unit whenever one exists, and says a
    daemon-reload plus restart is what applies an edit.
  - **The browser steps are non-fatal, so their warnings scroll past** and a deployment looks clean
    while missing the one thing locked CurseForge files need. A `headless browser: <status>` line is
    now part of the final summary, and the install lists what the service account can actually see in
    its own `~/.cache/ms-playwright` — which is the question being asked, answered by observation
    rather than by assertion.

Verified by measurement, there being no harness for deploy shell scripts: `bash -n` clean; `--help`
renders and `--nonsense` still rejects; JAVA_HOME extraction returns `/usr/lib/jvm/temurin-21-jdk`
from a unit with it uncommented and empty from the shipped one; the resolution block, lifted verbatim
out of the script so the harness cannot drift from it, picks the expected copy in all three states.
Grinder suite 425, unchanged.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Red: `StatusDashboardRenderer` does not exist yet.

Six guards, and the first is the one worth having. A static HTML page cannot fail a build, so the
failure that actually threatens this feature is not a 404 — it is a field being renamed on the server
and the page quietly rendering nothing while still returning 200. So the page declares what it reads
(`READ_FIELDS`), one guard resolves every one of those paths against the document a real
`ReportServer` serves over a real socket with every optional collaborator wired, and a second stops
the declaration drifting from the markup that consumes it.

Every optional collaborator really is wired — status, cursors, cache root and console rules — and
that is load-bearing rather than thoroughness: a field path that cannot resolve for want of a
collaborator is a red no implementation could ever turn green, so the pin would fail for its own
reasons and prove nothing.

The rest pin the properties that make it worth building this way at all: it polls; it loads nothing
off the network, so a browser reaching a loopback-bound report through a reverse proxy still gets a
working page; and it is a constant carrying no store data, which is how a page on an unauthenticated
server displaying internet-supplied slugs avoids escaping bugs entirely — every value is inserted
client-side as text. That one is pinned by serving it against a slug of `<script>alert(1)</script>`.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`/dashboard` renders the status document for a human and polls itself: current pass, what each worker
holds and for how long, crawl position per platform, loader-cache size, and boot-rule errors — with
durations as `2d 3h 2m` rather than `183742`. Interval selectable (2/5/15/60s) and pausable, and it
says the daemon is unreachable rather than freezing on stale numbers, since a dashboard silently
showing the last good poll is worse than none when the daemon is the thing that stopped.

No new dependency: the JDK's HTTP server it already uses, and vanilla JS. Asserted, not intended —
one guard fails on any `src`/`href` pointing off this server, because the report is documented as
loopback-bound behind a reverse proxy and a browser reaching it may have no route to a CDN at all.

**A second route, not content negotiation on `/status`.** That endpoint is scripted against; handing
a machine reader HTML because an `Accept` header looked browser-shaped would break what it is for.
`/status` is byte-for-byte unchanged.

**The page is a constant, which is a security property rather than a shortcut.** This server has no
authentication and displays internet-supplied mod slugs. Nothing is interpolated server-side, so
there is no escaping to get wrong: every value arrives as JSON and is written with `textContent`.
Pinned by serving it against a slug of `<script>alert(1)</script>` and asserting the bytes match what
the renderer produces with no store in sight.

Two guards exist because a string constant in a Kotlin file has none by default.

`READ_FIELDS` declares every field the page reads, resolved against a document a real `ReportServer`
serves over a real socket. This is not redundant with the compiler: `statusJson()` builds its
document from **string-literal keys nothing type-checks**, so renaming `"loaderCache"` compiles clean
and silently blanks a panel. Verified to have teeth — with that key renamed it is the only failure in
all 434 grinder tests. A Kotlin *property* rename is already caught by `GrinderStatusTest` at compile
time; this covers the untyped half.

A second guard, which executes the page's JavaScript under node, follows in its own commit — it goes
red on arrival, and the fix after it is what turns it green.

Grinder suite 425 → 430, 29 skipped, all green. Not visually verified: the Chrome extension is not
connected here, so the rendering claim rests on the served-page guards.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Red: `theLinkHelperOnlyAcceptsAbsoluteHttpUrls` fails.

The page is a string constant in a Kotlin file, so nothing compiles it and a typo ships a dashboard
that loads, polls, returns 200 and renders nothing. That is the same silent-failure class the shipped
shell templates have, and it gets the same treatment `ScriptTemplateContentTest` gives them: run the
real interpreter when the host has one, skip when it does not, so CI never needs the toolchain.

Two helpers are pure and carry the logic worth pinning — `duration`, which is the whole "human
readable" claim, and `safeHref`, the page's only attribute sink. The harness lifts them out of the
shipped page rather than copying them, because a copy would pass while the page was broken.

`safeHref` goes red immediately, which is why this is its own commit: parsed against
`window.location.origin`, a null or unparseable `projectUrl` resolves to a same-origin link like
`<report>/null` — a row that renders something looking like a project link and 404s on the report
itself. The guard also pins the injection cases (`javascript:`, `data:`, `vbscript:`) that already
pass, so the fix cannot trade one for the other.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Red: four new guards fail.

A descriptor depends on Fabric API by naming one of its ~45 *modules* —
`fabric-resource-loader-v0`, `fabric-block-getter-api-v2`, `fabric-rendering-fluids-v1` — and neither
platform has a project under any of those names. Modrinth's slug guess 404s, CurseForge refuses to
guess at all, so the single most common dependency in the Fabric ecosystem goes unstaged. The mod
then boots without it, its loader refuses the pack, and the candidate wears an INCONCLUSIVE for a
dependency the harness never supplied. Same shape as the Quilt solver failure already on record:
`fabric-resource-loader-v0 versions [*] (0 valid options, 0 invalid options)`.

The guards also pin why this has to be a rule rather than a table. The version suffix moves: the
current source tree ships `fabric-resource-loader-v1` and `fabric-block-getter-api-v2`, while the
corpus is full of older mods declaring `-v0` — ids that exist in no current tree — so a snapshot of
today's module list would be wrong for precisely the historical mods this grinder spends its time on.

A third guard pins that the many modules of one project collapse to a **single** staged dependency.
Not hypothetical bookkeeping: a typical mod names five or eight of them, and one jar reachable under
several names is the B6 shape that double-counted toward MAX_INJECTED_DEPENDENCIES and refused packs
which were within the cap, scoring them INCONCLUSIVE.

And the collision that stops a bare pattern being right: lucko's `fabric-permissions-api-v0` (plural)
matches the module shape exactly while being a separate project, verified against its own
`fabric.mod.json`, whereas Fabric API's `fabric-permission-api-v1` (singular) is one character away
and must still resolve. `fabric-language-kotlin` covers the other direction — a `fabric-` prefix with
no version suffix is its own project and keeps the plain slug guess.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`safeHref` parsed with `new URL(url, window.location.origin)`, and a base turns every unparseable
value into a same-origin link: `safeHref(null)` returned `http://<report>/null` and `safeHref("::::")`
returned `http://<report>/::::`. A worker row then rendered what looks like a project link and leads
to a 404 on the report server itself.

Parsed with **no base**, anything that is not an absolute URL throws and yields no link at all, which
is correct here — a platform's `projectUrl` is always absolute, so a relative value is bad data rather
than a link. The `javascript:`/`data:` refusals are unchanged; they were never the broken half.

Found by the guard in the preceding commit, which is the reason that guard exists: nothing compiles a
page held as a string constant.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Reported live 2026-09-01: `fabric-resource-loader-v*`, `fabric-block-getter-api-v*` and
`fabric-rendering-fluids-v*` all going unresolved. They are not projects — they are modules of Fabric
API, which ships as ~45 nested jars, and a descriptor depends on the modules rather than on the
project. Neither platform publishes them separately, so Modrinth's slug guess 404s and CurseForge
refuses to guess: the single most common dependency in the Fabric ecosystem was never staged. The mod
then booted without it, its loader refused the pack, and the *candidate* wore the INCONCLUSIVE for a
dependency the harness never supplied. It is the same shape as the Quilt solver failure already on
record — `fabric-resource-loader-v0 versions [*] (0 valid options, 0 invalid options)`.

`KnownModIds` now resolves them to `fabric-api` / `306612` on the two platforms.

**A rule rather than a table, which is the whole design decision.** The API-version suffix moves
between releases: `FabricMC/fabric` today ships `fabric-resource-loader-v1` and
`fabric-block-getter-api-v2`, while the corpus is full of older mods declaring `-v0` — ids that exist
in no source tree now. A list snapshotted from the repository would therefore be wrong for precisely
the historical mods this is meant to fix, which is a sharper version of the "un-pinned data that goes
stale in silence" the class doc already warns about. The stable thing is the shape.

Measured against the 46 `fabric-*` directories of `FabricMC/fabric`: the rule matches 44. The two it
does not are `fabric-api-bom` and `fabric-api-catalog`, a Gradle BOM and a version catalog — build
artifacts no mod can depend on, so excluding them is correct. `fabric-api-base` and
`fabric-renderer-indigo` carry no version suffix and are named explicitly.

**One exclusion, and it is why a bare pattern would be wrong.** lucko's `fabric-permissions-api-v0`
(plural) matches the module shape exactly and is a separate project, while Fabric API's own
`fabric-permission-api-v1` (singular) is one character away and must still resolve — verified against
lucko's `fabric.mod.json`. Claiming it would stage Fabric API in place of the library the mod asked
for and report a dependency it never declared. `notFabricApi` stays limited to ids observed
colliding; guessing at more would rebuild the table this class exists to avoid.

Suites: clientside 257 → 263, api 382 (1 skip), grinder 434 (29 skip), app 149 — all green.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Red: `qslModulesResolveToQslOnBothPlatforms` and `theFabricAndQuiltFamiliesStaySeparate` fail.
`theQuiltLoaderItselfIsNotAQslModule` passes already and is here to stay passing.

Closes the observation audit iteration 32 recorded as unverified. It is now verified: reading all 47
`quilt.mod.json` files in `QuiltMC/quilt-standard-libraries` (branch 1.21.5) and collecting their
`depends` entries yields **33 distinct `quilt_*` module ids** — `quilt_resource_loader`,
`quilt_networking`, `quilt_registry` and so on, with `quilt_resource_loader_testmod` declaring
`["quilt_loader", "quilt_resource_loader"]`. A Quilt descriptor depends on the modules, never on the
project, and neither platform publishes them, so each one fell through to a Modrinth slug guess that
404s and to nothing at all on CurseForge — the same unstaged-dependency failure
`fabric-resource-loader-v0` was producing before the Fabric fix.

**The shape is not Fabric's, so the rule cannot be copied.** QSL ids are underscored and carry no
API-version suffix, so `fabric-<x>-v<digits>` matches none of them — which is also why the Fabric fix
left this open rather than closing it by accident.

Ran before committing, per the pin-first lesson audit iteration 32 recorded against itself: both
failures are the missing mapping, not a defect in the guards.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Closes the open observation from audit iteration 32, now verified rather than suspected.

A Quilt descriptor depends on QSL's **modules** — `quilt_resource_loader`, `quilt_networking`,
`quilt_registry` — and neither platform publishes them, so each fell through to a Modrinth slug guess
that 404s and to nothing at all on CurseForge. Identical to the Fabric API failure: the dependency
goes unstaged, the loader refuses the pack, and the candidate wears the INCONCLUSIVE.

Evidence: all 47 `quilt.mod.json` files in `QuiltMC/quilt-standard-libraries` (branch 1.21.5) were
read and their `depends` entries collected — 33 distinct `quilt_*` ids, every one lowercase words
separated by underscores, none carrying an API-version suffix. `fabric-<x>-v<digits>` matches none of
them, which is why the Fabric rule left this open instead of closing it by coincidence, and why the
QSL rule is `^quilt_[a-z0-9_]+$` rather than a copy.

`notQsl` holds `quilt_loader`: the loader itself, already dropped before staging by
`environmentProvidedIds`, so mapping it would change nothing observable today — which is the reason
to exclude it rather than a reason not to bother. A table other code is entitled to trust must not
record a false fact just because the falsehood is currently unreachable.

**Noted, deliberately not changed:** `quilt_base` *is* a QSL module (`library/core/qsl_base`, and
`quilt_base_testmod` declares `["quilt_loader", "quilt_base"]`), yet `QuiltScanner.dependencyExclusions`
in `-api` strips it at scan time as "the platform", and `BootVerifier.environmentProvidedIds` repeats
that. So it never reaches staging. Correcting it would mean changing existing assertions in the
published module — the conventions' stop-and-flag signal — for a case limited to a mod whose *only*
QSL dependency is `quilt_base`, since any other module now pulls QSL in anyway. Raised rather than
silently changed.

clientside 263 → 266, all green.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Audits `03047a7c0..HEAD` — the 17 commits of this session's live-defect work across -api, -clientside,
-grinder and the deploy scripts.

No HIGH. The published-API behaviour change (`21c912bd4`) did get its API-BEHAVIOUR-CHANGES row,
module boundaries hold, and a `--rerun-tasks` clean compile of all three changed modules produces no
new warnings — checked with `--rerun-tasks` specifically because incremental compilation has hidden a
broken test tree in this repository before.

Three MEDIUM. `cbc615edb` bundled a pure refactor (moving the fold into the companion) with the
five-line behaviour change it was labelled for, turning it into a 90-line diff. Two red-committed
pins carried bugs of their own that the implementation commit then fixed, so the committed red state
is not the clean "implementation missing" signal the pin-first rule exists to leave behind. And the
root CLAUDE.md api count had gone stale inside this very range — 381 against an actual 382, drifted
by `20a4e02af` adding a test — which is the fourth consecutive audit to find an instance of the
"suite counts left behind by the tests that were just added" class. That one is **fixed here**;
re-derived from build/test-results, with clientside 263 and grinder 434 confirmed already correct.

Four LOW, all in this session's own code: two guards added inside implementation commits rather than
pinned first, three `!!` in one test class, an undocumented `PAGE` constant, and a dashboard error
message that claims to be showing the last successful poll when the first one fails.

One open observation: the Fabric API module rule has no Quilt mirror. QSL ships as modules too, and
only `quilted_fabric_api`/`qsl` are mapped while `environmentProvidedIds` excuses only `quilt_loader`
and `quilt_base` — so any other QSL module id goes unmapped exactly as `fabric-resource-loader-v0`
did. Recorded as unverified: the QSL repository groups modules by category rather than published id,
so the id shape needs confirming against a real `quilt.mod.json` before a rule is written.

Also fixes the garbled `step "Checking preresudo nanoquisites"` in update-grinder.sh's preflight — an
accidental editor edit, back to "Checking prerequisites"; `bash -n` clean.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
LOW-2 — `StatusDashboardScriptTest` carried `val nodeBinary = node!!` three times, because
`Assumptions.assumeTrue(node != null, …)` aborts correctly but tells the compiler nothing. `requireNode()`
now makes the assumption and returns a non-null `String`, so all three call sites are one line and the
convention against new `!!` holds again.

LOW-3 — `StatusDashboardRenderer.PAGE` had no doc comment, the only member of that object without one.

LOW-4 — the dashboard said "showing the last successful poll" on a failure even when the *first* poll
failed, at which point every panel is empty and there is no such poll; `everLoaded` now picks the honest
wording. Verified by `theDashboardScriptParses`, which runs the edited script under node.

MED-2 is closed as a **rule**, since its two instances are already merged: root `CLAUDE.md` gains "Run the
pin before you commit it red, and read why it failed", carrying both 2026-09-01 cases as evidence. It sits
above the existing pin-first rule because it is the gap that rule leaves — a red commit proves nothing when
the red is the guard's own bug.

MED-1 and LOW-1 are **accepted rather than rewritten**. Both are commit-shape defects in history already
merged into `develop`, and this repository has decided that trade before: the `358675fbf` entry records that
the honest remedy for a commit found mis-shaped after merging is the audit entry, not a rebase of shared
history.

Docs: clientside 263 → 266 re-derived from the test XML; the clientside module file gains the QSL rule
beside the Fabric one, including the `quilt_base` sub-gap raised for Griefed rather than changed, since
correcting it means editing existing assertions in the published `-api` module.

api 382 (1 skip), clientside 266, grinder 434 (29 skip), app 149 — all green.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Red: four guards fail, each on the absent `quilt_base` and nothing else — checked against the
messages, per the pin-first rule added yesterday.

`QuiltScanner.dependencyExclusions` drops `(quilt_loader|quilt_base|java|minecraft)`, describing both
quilt ids as "the platform", and `BootVerifier.environmentProvidedIds` repeats it. `quilt_base` is not
the platform: it is QSL's base module, shipped by QFAPI. `library/core/qsl_base` exists in
`QuiltMC/quilt-standard-libraries` and its own `quilt_base_testmod` declares
`["quilt_loader", "quilt_base"]` — read 2026-09-01 on branch 1.21.5, alongside the 47-descriptor
harvest that produced the QSL module rule.

This is the same bug the *Fabric* half of these tests already exists to prevent. `FabricScanner`
excludes only `fabricloader` and deliberately keeps `fabric`, because Fabric API is a mod the server
needs and excluding it meant it "could never be reported as the dependency it is, nor rescued back
into a pack that had disabled it". Quilt drew the line one id too far, and QSL is exactly as much a
mod as Fabric API.

Both halves are pinned here rather than only the scanner, because they are one behaviour split across
two modules: `-api` decides what is *reported* as a dependency, `-clientside` decides what is
*staged*, and fixing one without the other leaves the mod unbootable for the same reason as before.

Two existing expectations change, which the conventions call a stop-and-flag: flagged in audit
iteration 32, put to Griefed, and changed on their explicit instruction. The label is `fix:` on the
following commit rather than `refactor:`, which is what the rule asks when behaviour genuinely moves.
Two further guards — `onlyTheQuiltRuntimeIsExcludedFromDependencies` and
`quiltBaseIsStagedWhileTheQuiltLoaderIsNot` — state the line on its own so it cannot be inferred from
a fixture that happens to list one of each.

`MinecraftConstraintTest.quiltReportsTheMinecraftItDeclares` also names `quilt_base` but asserts only
the Minecraft constraint, so it is unaffected and untouched.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Closes the sub-gap audit iteration 32 raised and left for Griefed, changed on their instruction.

`QuiltScanner.dependencyExclusions` dropped `(quilt_loader|quilt_base|java|minecraft)` and
`BootVerifier.environmentProvidedIds` repeated `quilt_base`, both calling it the runtime. It is not:
it is QSL's base module, shipped by QFAPI. `library/core/qsl_base` exists in
`QuiltMC/quilt-standard-libraries`, and its own `quilt_base_testmod` declares
`["quilt_loader", "quilt_base"]` — read on branch 1.21.5 during the 47-descriptor harvest that
produced the QSL module rule.

Consequences of the old behaviour, one per layer. In `-api` the dependency was never *reported*, so
nothing could name QFAPI/QSL as a dependency and dependency rescue could not pull it back into a pack
that had disabled it. In `-clientside` it was never *staged*, so a mod whose only QSL dependency is
`quilt_base` booted without it and failed on the very dependency the harness declined to supply. Both
layers are fixed together because they are one behaviour: fixing either alone leaves the mod
unbootable for the same reason as before.

This restores the line `FabricScanner` already draws and documents — exclude `fabricloader`, never
`fabric`, "because a dependency you refuse to record can neither be reported nor rescued back into a
pack that disabled it". Quilt had drawn it one id too far. `quilt_loader` stays excluded.

Two existing `-api` expectations changed, which the conventions treat as a stop-and-flag. That is the
flag working rather than being bypassed: it was raised in the audit, put to Griefed, and acted on when
they decided. The label is `fix:`, not `refactor:`, because behaviour genuinely moved.

One `API-BEHAVIOUR-CHANGES.md` row: no signature changes, but a Quilt jar's scan now returns one more
`ModDependency`, and a QFAPI/QSL jar becomes rescuable into a server pack that had disabled it — the
intended fix, though an embedder asserting a fixed dependency count will see it rise by one.

api 382 → 383, clientside 266 → 267, grinder 434 (29 skip), app 149 — all green.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
docs(audit): record the history rebuild that closed MED-1, LOW-1 and MED-2's instances
All checks were successful
Documentation / Writerside webhelp (push) Successful in 6m5s
Continuous / Build JAR (push) Successful in 17m49s
Test / build (push) Successful in 21m16s
Qodana / scan (push) Successful in 26m33s
Continuous / Build AppImage (x86_64) (push) Successful in 4m44s
Continuous / Build AppImage (aarch64) (push) Successful in 3m44s
Documentation / Help image (push) Successful in 20m37s
Qodana / notify (push) Successful in 1m37s
Continuous / Build Install4J Media (push) Successful in 16m18s
Continuous / Continuous Pre-Release (push) Successful in 13m12s
Docker Test / build image (push) Successful in 1h23m35s
0930519156
git rebase -i is unavailable here, so the range was replayed explicitly: every feature branch
re-created and every --no-ff merge restored with its original message. All nine merges survive and
the tree is byte-identical to the pre-rebase tip; 26 commits became 29.

The three splits were each verified by checking out the intermediate commits and running the suites,
rather than asserted: the new refactor commit is green with the confidence ladder diffed to confirm no
branch changed, the dashboard feat commit is green and the node guard after it is red on safeHref, and
the Fabric collapse guard is red in the pin commit it moved to.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Two corrections to the record, both consequences of yesterday's history rewrite.

**The rebase orphaned 13 commit hashes this file cited, across 26 occurrences.** Eight were killed by
that rebase; the other five had already been orphaned by an earlier one and are now reachable from no
ref at all — `git for-each-ref --contains` finds nothing for them, so they survive only in the object
store and would stop resolving entirely once gc runs. All 26 now name the **commit subject**, which is
what the *Cite names, not snapshots* convention asks for and what survives rebase, cherry-pick and
squash. Verified: every remaining hash in the file passes
`git merge-base --is-ancestor <hash> develop`.

The root `CLAUDE.md` convention gains the recurrence as evidence. It already cited "54 commit hashes
killed by a rebase"; that this happened again, in the same file, while the convention was in force,
is the part worth recording — `REFACTOR-AUDIT.md` is the one place in the repository that cites hashes
at volume, so it is the guaranteed casualty of every history rewrite.

**The rebuild was proposed on a premise nobody checked.** The iteration-32 entry stated that nothing
had been pushed and the history could therefore still be re-cut. `origin/develop` already held all 26
commits, so `358675fbf`'s precedent — a mis-shaped commit found after it reached a shared branch is
remedied by the audit entry, precisely because the alternative is force-pushing a shared branch —
applied in full rather than being inapplicable. The rewrite consequently required a force-push, which
Griefed performed on 2026-09-01 after being shown the divergence. Nothing was lost, since the tip
trees are byte-identical, but the entry now says so plainly and states the rule: check
`origin/<branch>` before proposing a history rewrite, because the cost of being wrong falls on
everyone who has already pulled.

Docs only; no code touched.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The previous commit fixed 26 citations and then, in the paragraph explaining the fix, listed the five
now-unreachable hashes verbatim — citations that die with gc exactly like the ones being replaced.
They are named by subject now (the CPU-cap series and the deployment-gaps docs commit), which is the
only form that still means anything once the objects are collected.

The verification is also widened and stated: every tracked `.md` was swept for hex tokens that
`git cat-file -t` resolves to a commit, each tested against
`git merge-base --is-ancestor <hash> develop`. Two files legitimately carry non-develop citations and
are left alone:

  - `.claude/rules/ci-workflows.md` names `50fd50f37`, a real release commit on `origin/alpha` —
    outside `develop` by design rather than orphaned, and it still resolves.
  - `CHANGELOG.md` names ~31 hashes that resolve to no branch, but it is generated by
    semantic-release and rewritten on every release, so editing it by hand would be futile.

Docs only.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Griefed reports locked CurseForge downloads still failing after the browser install landed. The
install cannot answer why, and that is the defect: it checks that a *download command exited zero*,
which is not the same as the browser working. The failure being seen — every locked file timing out —
is Chromium starting and then getting nowhere, and an install exit code is blind to it.

So the section now ends by launching it, as the service account, the way `BrowserDownloader` does:
`com.microsoft.playwright.CLI screenshot --browser chromium about:blank`. `about:blank` needs no
network, so it probes the browser and nothing else — which also makes the result diagnostic in the
other direction: a locked file still failing after this passes is a CurseForge or network problem,
not a missing prerequisite.

**Found while writing it, and it is the sharper half.** Since 1.49 Playwright serves
`setHeadless(true)` from a *separate* binary, so the path the daemon needs is
`chromium_headless_shell-1234`, not `chromium-1234` — read off the real error by running the CLI here
with `PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD=1`. `install chromium` does fetch both (verified against a
cache holding `chromium-1223` *and* `chromium_headless_shell-1223`), but a cache holding only the full
browser would satisfy every check the script previously made and still serve zero downloads. The probe
is the only thing here that would notice.

It covers two further causes nothing else could: a HOME the service account cannot write, since
Chromium needs a cache directory of its own, and a sandbox the kernel refuses — the unit sets
`NoNewPrivileges=true`, and a host with unprivileged user namespaces disabled leaves Chromium no
sandbox it can use. On failure the probe prints Playwright's own output, which is where the
host-validation package list appears, and names those three causes in order.

`mktemp` for the probe file is guarded with `|| true` and a fallback: a service account that cannot
mktemp is a finding to report, not a reason for `set -e` to abort a deployment that has already
installed everything else.

Verified by measurement, there being no harness for deploy scripts: `bash -n` clean, `--help` renders,
`--bogus` still rejected, and the probe invocation exercised against the real installed dist — it
fails with `Executable doesn't exist at .../chromium_headless_shell-1234/...`, which is exactly the
actionable shape an operator needs.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
merge: prove chromium launches for the service account
All checks were successful
Documentation / Writerside webhelp (push) Successful in 6m52s
Continuous / Build JAR (push) Successful in 14m6s
Docker Test / build image (push) Successful in 19m19s
Documentation / Help image (push) Successful in 2m28s
Qodana / scan (push) Successful in 16m12s
Continuous / Build AppImage (x86_64) (push) Successful in 2m39s
Continuous / Build AppImage (aarch64) (push) Successful in 3m23s
Qodana / notify (push) Successful in 24s
Test / build (push) Successful in 17m3s
Continuous / Build Install4J Media (push) Successful in 9m26s
Continuous / Continuous Pre-Release (push) Successful in 7m41s
1eaa85b043
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Red: `aLockedFileIsRecognisedAndHasNoBrowserFallback` and `noRoutingHelperSurvives` fail — the browser
downloader and the router are still here.

Griefed's decision, and the reasoning is theirs: Playwright was implemented to circumvent CurseForge's
third-party distribution block, while regular downloads for non-blocked content and all of Modrinth
never needed it, so it is extra weight with little to no benefit.

The guards state the end state rather than the deletion: a downloadable file is still fetched over
HTTP, a locked file is still *recognised* as locked — the flag is what lets a refusal explain itself —
and both `BrowserDownloader` and `selectDownloader` are asserted **absent from the classpath**, so the
removal cannot be half-done.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A distribution-locked CurseForge file (`allowModDistribution=false`) is now reported rather than worked
around: `HttpJarDownloader` returns null, `ClientsideVerifier` records `JarScan.DEFERRED`, and the
staging refusal names the lock and points at Modrinth, where the same project's files carry a URL. The
author opted out of third-party distribution, and driving the website was only ever a way to ignore
that.

It had also stopped working. CurseForge is behind a Cloudflare challenge the headless browser does not
clear, so every locked-file attempt died on `Timeout 60000ms exceeded` *after* Chromium had launched,
while a plain HTTPS fetch of the same file page returned 403 with challenge markers on any user agent.
The host was exonerated first — the installer's launch probe rendered a page as the service account and
the cache held both `chromium-1234` and `chromium_headless_shell-1234` — so this is not working around
a misconfiguration.

Gone: `BrowserDownloader`, its test, and `selectDownloader`. Removing the router left
`JarDownloader.kt` with no top-level function at all, so Kotlin now emits no `JarDownloaderKt` facade —
a stronger result than the guard assumed, which is why it accepts both an absent facade and one without
the method.

Two existing expectations changed, which for a deliberate removal is correct rather than the
stop-and-flag signal. `aLockedFileSaysWhyItCouldNotBeDownloaded` asserted the message named a
"browser"; it now asserts it names Modrinth and mentions **neither** browser nor Playwright, so a
refusal cannot send an operator looking for a mechanism that no longer exists.
`BootVerifierSelectionTest` lost one constructor argument with no assertion touched — the
reference-only carve-out.

**Behaviour change for app users:** `-verifyclientside` can no longer verify a distribution-locked
CurseForge project; it reports why. `-clientside` is not published to Maven, so there is no
API-BEHAVIOUR-CHANGES row, and the CLI surface is unchanged in shape.

The Playwright dependency itself goes in the next commit, where its cost can be measured.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The code stopped using the headless browser in the previous commit; this removes what it cost.

**Measured on this commit, with the code already gone: `serverpackcreator-app-dev.jar` 274.7 MB →
77.8 MB.** Re-runnable as `./gradlew :serverpackcreator-app:bootJar` either side of it.

The weight was `driver-bundle-1.62.0.jar`: **192.9 MB** of bundled node runtimes for five platforms
(`linux`, `linux-arm64`, `mac`, `mac-arm64`, `win32_x64`). Playwright's own driver code is the 3.0 MB
`driver` jar and the API is 0.6 MB — the rest was runtimes. It reached every artifact because
`-clientside` declared `api(libs.playwright)`, so ~72% of what each user downloaded existed for one CLI
verb most never run.

Also removed, being prerequisites for a route that no longer exists: the `libs.versions.toml` version
and library entries, the `playwright install-deps chromium` step in `clientside-report-reusable.yml`,
and the installer's whole headless-browser stage — its `--skip-browser` flag and the `service_java`
hoist that existed only to run the browser install, now assigned-but-unused. `bash -n` clean, `--help`
renders, and `--skip-browser` is correctly rejected as unknown.

Verified: `playwright` appears in **0** runtime-classpath entries for `-clientside`, `-grinder` and
`-app`.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Covers all seven documents, including the one the first attempt at this removal missed.

`serverpackcreator-clientside/README.md` is the important one: it is **operator-facing** and it listed
"Browser system libraries" as a prerequisite, described the headless-browser fallback across a dozen
lines, told readers to run `npx --yes playwright install-deps chromium`, and offered that same command
in two troubleshooting rows. All of it was instruction to install a capability that no longer exists.
It now states the limit honestly — a locked file publishes no URL, so it cannot be scanned or
boot-tested, and the project should be verified from Modrinth — with a short historical note so anyone
who read the old version knows why the prerequisite vanished.

**Why it was missed the first time, recorded because the lesson is mechanical:** the completeness sweep
grepped `"Playwright\|BrowserDownloader"` **case-sensitively**, and this file writes the tool lowercase
inside `npx --yes playwright install-deps` — 0 matches where `grep -i` finds 3. An earlier sweep had
listed the file and it was dropped on the strength of the case-sensitive re-check. Verify a removal with
`grep -i`, or the check confirms only what it can see.

The rest: `-clientside`'s `CLAUDE.md` (the landmine becomes a HISTORY entry carrying the measurements
and a do-not-reintroduce), its `module.md`, `-grinder`'s `CLAUDE.md` and `README.md` (host prerequisite
and troubleshooting row), `-app`'s `CLAUDE.md`, and the root suite count 273 → 262.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
docs(audit): close iteration 33, and add the convention MED-3 earns
All checks were successful
Documentation / Writerside webhelp (push) Successful in 2m42s
Docker Test / build image (push) Successful in 15m15s
Continuous / Build JAR (push) Successful in 17m41s
Qodana / scan (push) Successful in 15m11s
Continuous / Build AppImage (x86_64) (push) Successful in 2m13s
Documentation / Help image (push) Successful in 3m11s
Continuous / Build AppImage (aarch64) (push) Successful in 2m19s
Qodana / notify (push) Successful in 10s
Continuous / Build Install4J Media (push) Successful in 6m14s
Test / build (push) Successful in 15m20s
Continuous / Continuous Pre-Release (push) Successful in 3m49s
f397a11995
MED-1 fixed (the stale operator README), MED-2 fixed (the 23-file removal re-cut into four commits, its
measurement relocated to the build commit that causes it), MED-3's artifact removed from history by the
same re-cut, LOW-1 dissolved with the commit it described, LOW-2 correct as-is.

The report is rewritten as report-and-resolution together, because acting on it changed the history it
described — and cited by **subject rather than hash**, since the re-cut would have orphaned every hash
in it. That is the defect iteration 32 found twice; written correctly the first time here.

Root `CLAUDE.md` gains **"Question the requirement before you optimise the cost of meeting it"**, which
is MED-3 generalised: a circuit breaker was designed, pinned with 189 lines of guards, implemented,
wired through two modules and documented, then deleted 34 minutes later when Griefed asked whether the
route it protected was needed at all. Every commit in that sequence was correctly shaped, which is why
the shaping did not save it. Knuth one level up — "measure before optimising" presumes the thing should
exist.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
install-grinder.sh and update-grinder.sh were two halves of one procedure. They
cross-referenced each other about fifteen times and duplicated, verbatim: the
three-call docker preflight, the nologin system-account creation, the "absolute
and at least two components deep" guard protecting every rm -rf, and the whole
root-equivalent docker-group policy including its prose.

They also had inverse root requirements, and that is what makes them one script
rather than two. install refused root because a Gradle build as root leaves
root-owned files in build/ that the next ordinary build cannot overwrite; update
required root because its only job was dropping to an unprivileged build account.
The uid already decided which half could run, so it is now the mode switch --
and there is deliberately no --mode flag, because a flag could only ever agree
with the uid or lie.

  ./install-grinder.sh                   build this checkout and install it
  sudo ./install-grinder.sh --bootstrap  first install on a fresh host
  sudo ./install-grinder.sh              update from a fresh clone

BREAKING: update-grinder.sh is gone. Its flags moved onto install-grinder.sh
unchanged, and `--` is still accepted as a no-op, so `sudo ./install-grinder.sh
-- --skip-image` keeps working. There is nothing left to pass through: one script,
one flag namespace.

The name was kept rather than moving to deploy-grinder.sh, on purpose. The copy of
update-grinder.sh already deployed on the grinder host invokes
$SRC/repo/serverpackcreator-grinder/deploy/install-grinder.sh BY PATH, so that
filename leaving develop would have broken the next unattended update. It still
resolves, and hands off to the build half as the build user exactly as before.

Two things fixed while merging rather than carried across:

- Arguments handed to the clone's copy are %q-quoted per argument instead of
  interpolated as ${installer_args[*]}. `bash -lc` takes one string, so an argument
  containing a space would have been re-split by the child's parser into something
  the caller never wrote. No current flag can trigger it; the next one taking a
  value would have.
- Both modes now use the more helpful of the two docker-preflight messages, the
  one that names the apt line, rather than the terser "docker not found on PATH".

Verified by running it, since shell deploy scripts have no harness here and never
had one:

- shellcheck clean at -S style, its strictest level, as the old pair was.
- The deploy half end-to-end in a debian:stable container against a local git
  remote whose checked-out installer is a recorder. The hand-off runs the CLONE's
  copy, as the unprivileged build account, in the right cwd with the right HOME;
  --bootstrap forwards exactly --skip-image --clear --install-unit, no duplicate.
  Refusals confirmed: missing account without --bootstrap, SRC inside PREFIX, all
  three SRC shape guards, and a pre-existing account outside the docker group
  refused even under --bootstrap. No sudoers drop-in leaked in any case, including
  the runs that died at the clone.
- The EXIT trap driven directly, both halves. A failed install that had stopped the
  service restarts it; a successful one does not; one that never stopped it does not
  touch it; and the temporary sudo grant is removed even when the run dies. The two
  old traps are one handler now, and it needs no mode branch because each half is
  already a no-op in the other mode.
- The build half's guards executed on this host with the docker preflight stubbed:
  the PREFIX and SPC_GRINDER_HOME shape guards, and both --clear refusals (the
  account's whole home, the install prefix). The same bad SPC_GRINDER_HOME without
  --clear is correctly not rejected, since nothing is deleted.
- Operative-line diff of the old pair against the combined script: every dropped
  line is a rename, a helper extraction or a message unification. No behaviour is
  missing.
- ReadmeConfigurationTest and SystemdUnitConfigurationTest green after the README
  rewrite.

README section 4 gains a one-command deploy; section 8 now describes one script.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Deploy mode's first act is `rm -rf $SRC`, and $SRC/repo is exactly where the
previous run left a checkout -- so it is the copy of the script an operator
reaches for, and running it means deleting the file bash is still reading. Bash
reads a script incrementally, by offset, so the symptom is a syntax error part
way through a run that has already begun deleting things, with nothing naming
the cause.

The hazard predates the merge (update-grinder.sh sat in the same directory), but
the merge makes it likelier: the checkout's copy is now the same script you would
deploy with, and the README points at $SRC/repo as the place the tree is left for
debugging.

Guarded on $SRC rather than a hardcoded path, so overriding SRC moves the guard
with it. Skipped when the script has no resolvable path on disk, which is how a
script fed to bash on stdin arrives -- that case cannot be inside $SRC anyway.

Executed rather than assumed, in a debian:stable container:

- a copy at /opt/spc-grinder-src/repo/... with the default SRC is refused, and the
  error carries the curl line to re-fetch it
- the same copy with SRC=/opt/elsewhere is allowed through to the clone, proving
  the guard is SRC-relative
- a copy at /root is allowed through
- the script piped into `bash -s --` is allowed through

The test run also demonstrated the hazard by accident: the /root case wiped
/opt/spc-grinder-src out from under the copy sitting there, which is precisely
what the guard now prevents.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
merge: one deploy script for the grinder, with the mode decided by uid
All checks were successful
Documentation / Writerside webhelp (push) Successful in 1m47s
Continuous / Build JAR (push) Successful in 10m21s
Qodana / scan (push) Successful in 8m45s
Docker Test / build image (push) Successful in 12m42s
Documentation / Help image (push) Successful in 3m13s
Continuous / Build AppImage (x86_64) (push) Successful in 1m59s
Continuous / Build AppImage (aarch64) (push) Successful in 2m2s
Qodana / notify (push) Successful in 21s
Continuous / Build Install4J Media (push) Successful in 5m59s
Test / build (push) Successful in 11m30s
Continuous / Continuous Pre-Release (push) Successful in 4m7s
5b5eb04b73
install-grinder.sh and update-grinder.sh become one script. The two had inverse
root requirements -- install refused root because a Gradle build as root leaves
root-owned files in build/, update required it to drop to a build account -- so
the uid already decided which half could run, and is now the mode switch. No
--mode flag, because it could only agree with `id -u` or lie.

The name was kept so the copy of update-grinder.sh already on the grinder host,
which invokes install-grinder.sh by path, keeps working through the transition.

Also fixes the arg-quoting into `bash -lc` and refuses to deploy from a copy of
the script inside the directory deploy mode wipes.

Grinder suite green at 434 (29 skipped), 0 failures.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Red on purpose, and it does not compile: `RuntimeImagePreflight` and `ContainerEngine.hasImage` do not
exist yet, so the failure is `Unresolved reference 'RuntimeImagePreflight'` — the missing implementation
and nothing else. Verified by running it before committing, per the convention that a guard committed red
has to be red for the right reason.

What it pins, measured on the live daemon 2026-09-03: `spc-grinder-runtime:latest` was gone from Docker
(nothing in install-grinder.sh removes it; `docker system prune -a` does, since the image is only in use
during a boot). Every install threw `Status 404: No such image`, every tuple went on install cooldown, and
every candidate wanting one was scored INCONCLUSIVE with a sentence about a loader tuple. `record()`
replaces by identity and the re-verify TTL is 30 days, so projects that held a decisive HIGH lost it — and
with it their line in /as-properties.

Which is the loader-cache-poisoning lesson one level up: an environment defect looks exactly like a subject
defect unless something distinguishes them. The per-tuple cooldown disguised it, because one host-wide
failure is bookkept as one independent failure per tuple.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`ContainerEngine.hasImage` (default `true`, so no fake is affected) asks the daemon; the docker-java engine
answers it with `inspectImageCmd` and treats any failure as "no", logging which it was — a missing image and
an unreachable daemon are different causes with one consequence, and the refusal names both. `main` consults
`RuntimeImagePreflight.refusalFor` immediately after building the engine and exits non-zero, so the unit's
`Restart=on-failure` retries every 30s and `systemctl status` shows `failed` in between.

Exiting is the point rather than a warning: without the image nothing can boot, so continuing publishes an
INCONCLUSIVE verdict about every candidate it touches, replaces the decisive ones already in the store, and
the 30-day re-verify TTL leaves those wrong until somebody notices. That is not degraded service, it is the
daemon destroying the record it exists to keep.

Turns the pin of the previous commit green.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Red on purpose: `LoaderCache.installThrewMessage` does not exist, so the three failures are all
`Unresolved reference 'installThrewMessage'` and nothing else. Run before committing.

The line it pins, from the live daemon 2026-09-03:

    Loader install threw for NeoForge 21.1.23 / Minecraft 1.21.1: null

`${it.message}` on a throwable that carries none prints exactly that, so the operator learns that a tuple
failed and nothing about why -- not even the exception's type, which is free and is the difference between
"the daemon refused us" and "an NPE in our own staging". The tuple two lines above it in the same journal
named its cause (`Status 404: No such image`); this one could not.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`installThrewMessage` names the tuple, the exception's type, and its message where there is one — "(no
message)" where there is not — and the throwable itself now reaches the logger, so the stack trace is in the
journal instead of being dropped. Turns the previous commit's pin green.

Also removes a doc comment that sat above `companion object` describing its two constants; adding a third
member made it untrue, and the members carry their own docs.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Red on purpose: `ContainerCandidateVerifier.installedBase` does not exist. Run before committing — every
error is `Unresolved reference 'installedBase'` or a type-inference cascade from it, nothing else.

The defect it pins: `overlayLoaderInstall` asked `isInstallOnCooldown` *after* `ensureInstalled`, and
`ensureInstalled` records the cooldown on its way out of a failure. So the candidate whose boot actually
paid for the failed install was told the install "failed recently and is on cooldown, so it was not
retried" — and the failure branch was unreachable in production. During the 2026-09-03 outage that made
every one of thousands of identical verdicts claim to be a cheap skip, hiding how many installs were really
being attempted and failing.

The state before the attempt is the only moment the two cases differ, so that is when it is now asked.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`installedBase` reads the cooldown *before* asking the cache, so the candidate whose boot paid for the
attempt is told the install failed, and only the candidates behind it are told the retry was suppressed.
`overlayLoaderInstall` calls it; the two-branch message it has always carried finally reaches both branches.

Turns the previous commit's pin green, and makes `installUnavailableMessage`'s failure branch reachable in
production for the first time.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Red on purpose: `RequeueSelection.verifiedSince` does not exist, so every error is that reference or a
type-inference cascade from it. Run before committing.

`verifiedBefore` selects the complement of an outage window. On 2026-09-03 the runtime image was gone from
the daemon, so for the hours until anyone noticed, every candidate was published INCONCLUSIVE about a boot
that never happened -- replacing whatever the store held, with the 30-day re-verify TTL to keep it that way.
The population to re-grind is "everything verified SINCE it broke"; asking for it with
`--requeue-before <the fix>` queues the whole store instead, most of which the outage never touched.

Also pins that the two selectors partition the store, so an operator can say which population they queued.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`RequeueSelection.verifiedSince` selects every project verified at or after an instant, one candidate per
project, and `--requeue-since <ISO-8601 instant>` queues it. Inclusive of the instant, so it and
`--requeue-before` partition the store rather than overlapping.

The existing selector answers "a defect was found in the engine, so the past is suspect". It cannot answer
"the host was broken between 18:00 and now", which is the 2026-09-03 outage: with the runtime image absent
every candidate ground was published INCONCLUSIVE about a boot that never happened, and
`--requeue-before <the fix>` selects the exact complement of that damage.

`oneCandidatePerProject` is the grouping both selectors share, extracted rather than duplicated; the
rationale moves with it and `verifiedBefore` points at it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
README §9 gains the runtime-image refusal and loses a troubleshooting row for `No cached loader install
for …`, a message that no longer exists; the two install rows now separate the failure from its echo and
name the journal grep and the `install.log` path that find the cause. §6 documents `--requeue-since` beside
`--requeue-before` and says plainly that picking the wrong mirror queues what you did not mean.

The module files gain the incident as a landmine (grinder), the preflight seam (container) and the two
diagnosis fixes (loader). Root CLAUDE.md: grinder row 434 → 446 tests, plus the incident in one paragraph.
The blow-by-blow, including the host state that ruled out the two usual explanations, is in REFACTOR-LOG.md.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
merge: refuse to grind without a runtime image, and say why an install failed
All checks were successful
Documentation / Writerside webhelp (push) Successful in 1m23s
Qodana / scan (push) Successful in 7m51s
Continuous / Build JAR (push) Successful in 11m56s
Docker Test / build image (push) Successful in 13m35s
Qodana / notify (push) Successful in 17s
Continuous / Build AppImage (x86_64) (push) Successful in 2m10s
Documentation / Help image (push) Successful in 4m39s
Continuous / Build AppImage (aarch64) (push) Successful in 2m39s
Test / build (push) Successful in 12m54s
Continuous / Build Install4J Media (push) Successful in 8m22s
Continuous / Continuous Pre-Release (push) Successful in 3m47s
4fba2b8470
The live daemon published thousands of INCONCLUSIVE verdicts reading "Loader install for <tuple> failed
recently and is on cooldown, so it was not retried". The sentence is an echo: spc-grinder-runtime:latest had
been removed from the Docker daemon, so every install threw `Status 404: No such image`, every tuple went on
the 60-minute cooldown, and every candidate wanting one spoke about a boot that never happened. Since
record() replaces by identity and the re-verify TTL is 30 days, projects holding a decisive HIGH lost it, and
with it their line in /as-properties.

An environment defect looks exactly like a subject defect unless something distinguishes them -- the same
lesson as the poisoned loader-cache entry, one level up, and the per-tuple cooldown was disguising it by
bookkeeping a host-wide failure as one failure per tuple.

Four changes, each pinned red first:

1. ContainerEngine.hasImage + RuntimeImagePreflight; main refuses and exits 1 before taking a candidate.
2. LoaderCache.installThrewMessage names the exception's type, and the throwable reaches the logger -- one
   tuple's line had read "... / Minecraft 1.21.1: null".
3. The cooldown is read BEFORE ensureInstalled, so the candidate that paid for the failed install is no
   longer told it was skipped. The failure branch was unreachable in production until now.
4. --requeue-since <instant>, because --requeue-before selects the exact complement of an outage window.

Deploy order matters: rebuild the image first, or the daemon will now correctly refuse to start.

Grinder suite green at 446 (29 skipped), 0 failures. develop's unmodified test tree against this branch's
production code: 434 pre-existing guards, 0 failures, 0 compile errors.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`jei-1.21.1-forge-19.52.0.422.jar` is tagged on both platforms for Minecraft 1.21 *and*
1.21.1, while its own `META-INF/mods.toml` declares

    versionRange="[1.21, 1.21.1)"

whose `)` excludes the very version the file is named after. Upstream-wrong, not misread:
JEI's gradle.properties on its 1.21.1 branch carries `minecraftVersion=1.21.1` beside
`minecraftVersionRange=[1.21, 1.21.1)` — the range is built as `[start, thisVersion)` where
it should be `[start, nextVersion)`.

`ForgeTomlScanner.getVersionRange` reads it verbatim and `VersionConstraint.mavenRangeHolds`
trims its bounds exactly like Maven's `parseRestriction`, so both halves are correct. What is
wrong is that the descriptor check is a **post-selection veto rather than a selection filter**:
the newest tagged version is picked, contradicted, and staging gives up — while 1.21, which
the platform tags and the jar accepts, is never tried. The refusal publishes
`BootResult.INCONCLUSIVE`, which overwrites a decisive verdict.

Run before committing; it fails behaviourally, not by compile error, reproducing the live
message against real manifest versions:

    Refusing to boot Forge on Minecraft 26.2: testmod.jar declares Minecraft
    '[26.1.2, 26.2)', but the pack is 26.2.

The other four tests in the class stay green, so the fixture breaks nothing. Versions are
derived from the cached manifest rather than hardcoded, so it does not rot as the snapshot
moves. The jar it writes carries a real `META-INF/mods.toml` read by the actual
`ForgeTomlScanner` — nothing is faked past the network boundary.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Three pins for the pure decision the fix needs: the newest version of a file that both the
host can boot and the jar's own declared range accepts — JEI's literal shape (tagged 1.21
and 1.21.1, declaring `[1.21, 1.21.1)`, answer 1.21), the host gate still applying, and
"no agreeable version" yielding null rather than quietly handing back the excluded one.

That last case is the one worth pinning: returning the version just rejected would turn a
refusal into an identical second refusal, and the caller must instead keep its original.

Run before committing; all three fail with `Unresolved reference 'newestVersionSatisfying'`
— the missing implementation, which is the accepted red for a pin whose subject does not
exist yet.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Answers the two pins. Selection sees only platform metadata — the jar is not downloaded yet
— so the newest tagged Minecraft version is picked and the descriptor gate may contradict it.
Instead of giving up, re-stage on the newest version the jar's own range does accept.

  - `BootCandidateSelector.newestVersionSatisfying` — pure: newest version of a file that the
    host can boot and the jar accepts; `null` when there is none, so the caller keeps its
    original refusal rather than re-selecting the version just rejected.
  - `Prepared.Failed.declaredMinecraftConstraint` — set only when the jar's range is *why*
    staging stopped. Deliberately narrower than "the refusal reason": a jar carrying the wrong
    loader's descriptor has no second version to try, so it must not trigger a retry. The
    predicate is re-asked rather than inferred from `contradiction` being non-null, because
    that same string also reports a loader mismatch.
  - `reselectOnMinecraftContradiction` — exactly one retry, via `stageBootPack` rather than
    `prepareBootPack`: the re-selected version satisfies the constraint that caused the
    refusal, so a second contradiction is a different fault and must surface, not loop.

Unchanged by design: fail-toward-accept. An unreadable or unparseable range still accepts, so
it never reaches a refusal and never reaches this path.

Suites read from build/test-results rather than inferred from BUILD SUCCESSFUL — this repo has
had a green build that executed nothing: clientside 266 tests / 0 failures (262 before, +4
pins), grinder 446 / 0 (29 skipped), app green.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
docs: record why a jar's own range now re-selects instead of refusing
All checks were successful
Documentation / Writerside webhelp (push) Successful in 3m18s
Continuous / Build JAR (push) Successful in 15m11s
Qodana / scan (push) Successful in 15m50s
Docker Test / build image (push) Successful in 26m12s
Documentation / Help image (push) Successful in 8m7s
Continuous / Build AppImage (x86_64) (push) Successful in 3m20s
Test / build (push) Successful in 15m50s
Continuous / Build AppImage (aarch64) (push) Successful in 3m57s
Qodana / notify (push) Successful in 1m8s
Continuous / Build Install4J Media (push) Successful in 10m25s
Continuous / Continuous Pre-Release (push) Successful in 5m7s
b8a6081218
Root CLAUDE.md (clientside row, 262 → 266), the module's landmine list, and the refactor log.

The part worth having written down is the negative: `ForgeTomlScanner.getVersionRange` is verbatim
and `VersionConstraint.mavenRangeHolds` trims exactly like Maven's `parseRestriction`, so a
self-excluding range like JEI's `[1.21, 1.21.1)` is read correctly and the parser must not be
"fixed" by the next person who meets one. Evidence is cited by name — the jar's own mods.toml and
JEI's gradle.properties pairing `minecraftVersion=1.21.1` with `minecraftVersionRange=[1.21, 1.21.1)`
— rather than by a line number that moves.

Also records the one thing deliberately left open: whether Forge fatally enforces that range at
runtime is circumstantially unlikely but was not demonstrated, and the fix stands either way.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`advancement-plaques` 1.7.2 for Forge / Minecraft 26.2 was refused with "Required dependency
unavailable for Forge / Minecraft 26.2: prism", spending a `BootResult.INCONCLUSIVE` on a mod
that never required prism. Both sources say optional:

  - its own `META-INF/mods.toml` declares `iceberg` `mandatory=true`, and `prism` and
    `toastcontrol` `mandatory=false`
  - Modrinth lists prism (`1OE8wbN0`) `optional` against iceberg (`5faXoLqX`) `required`

The platform half was already right — `ModrinthPlatform` keeps only `dependency_type ==
"required"` and `CurseForgePlatform` only `relationType == 3`. The manifest half never existed:
neither `mandatory` nor `type` appears anywhere in `-api`'s main source, so `ModDependency` has
no field to carry the distinction and `stageableRequirements` cannot filter on one. Every
declared entry is a hard requirement, whatever the author wrote.

The two loader families spell it differently and both are pinned: Forge's `mods.toml` uses
`mandatory = true|false`; NeoForge's `neoforge.mods.toml` dropped that for `type`, a string
defaulting to `"required"` and also taking `"optional"`, `"incompatible"` and `"discouraged"`
(verified against NeoForged's own mod-files documentation, not assumed from Forge's shape).
`NeoForgeTomlScanner` only overrides the file name, so one implementation must serve both —
including NeoForge on 1.20.2-1.20.4, which still uses `mods.toml` and `mandatory`.

Absent-means-required is pinned deliberately. It is NeoForge's documented default and the safe
direction: wrongly treating a required dependency as optional boots a mod without something it
needs, which fails as a crash and can publish a *wrong* verdict, whereas wrongly treating an
optional one as required only refuses the boot and learns nothing.

Run before committing; red for the missing field only — `Unresolved reference 'optional'` in the
api pins, `No parameter with name 'optional' found` in the clientside one.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Answers the pins. Three parts:

  - `ModDependency.optional` — new, defaulted `false`, so every existing positional construction
    keeps compiling. The model had no way to express optionality at all, which is why no consumer
    could respect it.
  - `ForgeTomlScanner.isOptional` — reads **both** loader spellings: Forge's `mandatory = false`
    and NeoForge's `type` being `optional`, `incompatible` or `discouraged`. One reader serves
    both because `NeoForgeTomlScanner` overrides only the descriptor file name, and NeoForge on
    Minecraft 1.20.2-1.20.4 still ships `mods.toml` with `mandatory`. `incompatible` is in that
    set deliberately: it means the mod must *not* be present, which is the opposite of something
    to fetch.
  - `stageableRequirements` drops optional entries, so they neither get staged nor refuse a boot.

Absent-or-unreadable means required, which is NeoForge's documented default and the safe
direction: reading a required dependency as optional boots a mod without something it needs and
fails as a crash, which can publish a *wrong* verdict; reading an optional one as required only
refuses the boot and learns nothing.

Not changed, deliberately: optional dependencies are still *recorded* on `ScannedMod`, only
flagged. Stripping them from the scan would also remove them from `ModListCompiler`'s dependency
rescue, which keeps a mod on the server because something depends on it — and this module's
stated rule is that dropping a mod that does belong on the server breaks the pack while keeping
a superfluous one costs a few megabytes. Filtering at the boot-staging consumer fixes the grinder
without touching what lands in a user's server pack.

Suites read from build/test-results after `--rerun-tasks` with the previous results wiped, not
inferred from BUILD SUCCESSFUL: api 387/0 (383 before, +4 pins), clientside 267/0 (266 before,
+1 pin).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Root CLAUDE.md (api 383 → 387, clientside 266 → 267), the api module's landmine list, the
published-API behaviour record, and the refactor log.

The landmine worth having written down is that optionality has **two** spellings — Forge's
`mandatory` and NeoForge's `type`, the latter defaulting to `"required"` — and that one reader
must serve both, because `NeoForgeTomlScanner` overrides only the file name and NeoForge on
Minecraft 1.20.2-1.20.4 still ships `mods.toml`.

The API row records what is deliberately *not* a behaviour change: `ScannedMod.dependencies`
still lists every declared dependency in the same order, so an embedder's existing reads are
unaffected. Only the ability to tell optional from required is new.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Stage 1 of the result-system redesign. Pins `Verdict { CONFIRMED, CLEAR, ERROR, INCONCLUSIVE }`
and the pure policy that decides it, before any consumer is rewired.

The distinction the old scheme could not make is the point: **a grind that was prevented is not a
grind that learned nothing**, and reporting both as INCONCLUSIVE has cost this project twice. When
`spc-grinder-runtime:latest` vanished from the Docker daemon, every candidate published
INCONCLUSIVE about a boot that never happened, overwriting decisive verdicts the TTL would have
left alone. The JEI and `advancement-plaques` refusals did the same, one candidate at a time. The
engine always knew nothing had run; it had no verdict that could say so. ERROR is that verdict.

CLEAR is the other half: a boot that ran clean and matched nothing is *proven server-safe*, which
a single INCONCLUSIVE bucket destroys — "we proved it is fine" and "we learned nothing" are not
the same claim.

Two decisions recorded in the pins rather than left implicit:

  - **A crash with no confirming rule is INCONCLUSIVE, never CONFIRMED.** A flat reading of
    "matches a rule means exclusion-worthy" would invert the existing ladder, where excuse-markers
    (missing dependency, sandboxed network) sit *below* decisive client-only evidence precisely so
    host trouble cannot become a clientside verdict. Only a rule confirms.
  - **Everything but CLEAR keeps its logs.** ERROR and INCONCLUSIVE because that was asked for;
    CONFIRMED additionally, which was not. A confirmation publishes a mod to the fallback list, the
    highest-stakes output here, and a verdict that cannot name its own evidence cannot be audited:
    the rule id says which rule fired, only the console says what it fired on.

Run before committing; red only for the missing types (`Unresolved reference 'Verdict'`,
`'VerdictPolicy'`, `'StagingOutcome'`, `'BootObservation'`).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Stage 1 of the result-system redesign, and additive only: nothing consumes these yet, so
`BootResult` and `Confidence` are untouched and every existing guard stays green. Strangler-Fig,
so the migration of consumers can land in reviewable steps rather than one sweep.

`Verdict` carries `keepsLogs` on the enum itself, so the reaper asks the verdict instead of
re-deriving retention from a result plus a confidence — the arrangement that let artifacts and
the outcome that justified them drift apart.

`StagingOutcome` and `BootObservation` are separate types on purpose. Staging is the engine's own
work and its failure is an operator problem; a boot's failure is a statement about the mod. Fusing
them is precisely what produced the INCONCLUSIVE-for-a-boot-that-never-happened class of bug.

`VerdictPolicy.decide` encodes the ladder in its ordering: prevented outranks any rule (nothing
ran, so no console existed to match, and a confirmation arriving with a prevented grind is a
caller bug rather than evidence); only then may a rule confirm; a crash no rule explained is
INCONCLUSIVE, never CONFIRMED.

`BootObservation` is deliberately coarse — Survived, Crashed(exitCode), TimedOut. Reading a
console finely is a rule's job, and stage 2 moves the eleven hardcoded marker groups into the
rules file where they can be edited.

clientside 274/0, read from build/test-results (266 before, +8 pins).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Stage 2 of the result-system redesign. The eleven hardcoded marker groups in `BootLogClassifier`
become ordinary, editable rules — "no hardcoded rules" — and this pins that the move is
behaviour-preserving, because green tests written by the same pass that moves code prove nothing
on their own.

File order has to reproduce the ladder exactly, and the pins are shaped around the two ways that
goes wrong rather than around the happy path:

  - **The inversion guard.** A console carrying an excuse *and* the decisive client-only marker
    must still confirm. Excuses sit below the evidence because a clientside mod may phone home and
    die on a client class both, and the marker must win — the rule this repo has held since
    2026-08-29. Ordering the file the other way silently converts true positives to INCONCLUSIVE,
    and no single-line sample would notice.
  - **The fair-run guard.** A console carrying a "never got a fair run" signal *and* the decisive
    marker must NOT confirm: if the loader never bootstrapped, the client-class line did not come
    from this mod being exercised. Getting this wrong publishes host trouble as a mod's fault,
    which is the missing-runtime-image and poisoned-loader-cache failure both.

One sample per extracted group, each taken from the evidence that group's own documentation cites,
so a pattern that stops matching its founding case fails here rather than silently going quiet.

The ready-line, the timeout and the exit codes are deliberately NOT rules: they are structural
readings of how the process ended rather than of what it said, which is what `BootObservation`
models. A rules file is for the console.

Run before committing; red only for the missing type (`Unresolved reference 'DefaultBootRules'`).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Stage 2a. The eleven hardcoded marker groups are now `boot-rules.default.json`, bundled in the
jar, in precedence order, each carrying the evidence its former KDoc cited — the measured counts
included, because those are what justify a pattern's existence and are the first thing anyone
editing one needs. `BootRule`/`BootRuleSet` parse and evaluate them; order is precedence.

Additive so far: `BootLogClassifier` still holds its own copies, and 2b swaps it over to these.
Splitting there is deliberate — the existing `BootLogClassifierTest` and
`RealBootLogClassificationTest` are the equivalence guard for that swap, and they are only
meaningful if the rules being swapped in already exist and are themselves pinned.

`BootRuleSet.parse` drops what it cannot use — no id, no pattern, an uncompilable pattern, a
duplicate id — and reports each in `errors` rather than throwing. This runs per boot on a live
service: one bad edit must cost the operator that rule and a message, not every verdict. A missing
bundled resource degrades the same way, to "no console rules" rather than to no verdicts, because
the structural readings (ready-line, timeout, exit code) are not rules and still stand.

Those three stay structural on purpose. They are readings of how the process *ended*, not of what
it *said*, which is what `BootObservation` models; a rules file is for the console.

clientside 288/0, read from build/test-results (266 before, +8 stage-1 pins, +14 here).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Stage 2b, and behaviour-preserving: the ten hardcoded `Regex` literals are gone and each rung now
compiles its pattern out of `boot-rules.default.json` by rule id. One source of truth, so an
operator's edit reaches the engine and the two copies can no longer drift — the failure mode this
repo already paid for once, when `MetadataScanner` and `ModListCompiler` held the same Forge-era
version bug in duplicate.

**The ladder's order stays in code; only its content moved.** That is the conservative half of the
split: re-ordering rungs would change judgment, and the interleaved non-console readings (the
killed exit codes sit between the bootstrap guard and the operator rules) cannot be expressed by
file order alone. Stage 4 revisits that once the verdict vocabulary is migrated.

Each field keeps its KDoc. The file's `note` mirrors the substance for whoever is editing a
pattern, but the rationale and the measured evidence belong beside the rung that uses them.

`crashMarkers` deliberately stays a literal: it chooses where a crash *excerpt* begins and decides
no verdict, so it is not a rule.

**The equivalence evidence, which is the whole point of doing this as its own commit:** the 46
pre-existing guards across `BootLogClassifierTest` (29), `ConsoleRuleLadderTest` (10) and
`RealBootLogClassificationTest` (7) were not touched and all stay green — including the real
captured consoles, which are the ones that would notice a pattern that quietly stopped matching.
Re-run with `--rerun-tasks` after wiping build/test-results, since a green build off the cache has
executed nothing here before.

clientside 288/0, unchanged by this commit — a pure refactor adds no tests.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Stage 3. The platform's `server_side` and the jar's own descriptor are the two signals that decide
a mod without ever booting it, and they were the last hardcoded clientside determination left —
buried in `aggregateFor`'s `when`, where no operator could reach them.

**One canonical fact line, not a stream per source, and that is the whole design decision here.**
The old fold does not read the two signals independently: its most careful branch reads them
*together*, to notice that the platform marks the server unsupported while the jar declares
server/both. That is a contradiction and the case where confidence should fall rather than rise. A
regex matches one line at a time, so facts spread across separate lines could never express it;
rendering them into a single line makes conjunction ordinary, because a pattern naming two fields
is an AND. Dropping that would leave the rules *more* confident than the code they replace, which
is the wrong direction for a redesign premised on the old verdicts being unreliable.

Pinned, and each is a way this goes quietly wrong:

  - the fact line's field names, because they are an interface operators write patterns against
    and a rename would look like "no mod is clientside any more" rather than like a break
  - a contradiction yields INCONCLUSIVE, never CONFIRMED
  - silent metadata confirms nothing — only a boot may speak for a mod nothing declares
  - a deferred scan confirms nothing: a distribution-locked CurseForge file could be neither
    scanned nor booted, so there is no evidence about it at all
  - the two streams do not leak into each other

Run before committing; red for the missing types (`MetadataFacts`, `RuleSource`, `BootRule.source`).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Stage 3. `RuleSource` splits the rule engine into two streams, `MetadataFacts` renders what is
declared about a mod into one line, and four metadata rules join the bundled file. The platform's
`server_side` and the jar's own descriptor were the last hardcoded clientside determination, buried
in `aggregateFor`'s `when` where no operator could reach them.

**One fact line rather than a stream per source.** The old fold's most careful branch reads the two
signals *together*, to notice that the platform marks the server unsupported while the jar declares
server/both — a contradiction, and the case where confidence must fall rather than rise. A regex
matches one line at a time, so facts on separate lines could not express it; with all of them on
one line a pattern naming two fields is an AND. Both contradiction rules sit above both confirming
rules, so file order carries the caution the old code carried in a branch.

`firstMatch` is now scoped by source, defaulting to CONSOLE. The streams must not decide each
other's questions, and the default keeps every pre-stage-3 caller meaning what it meant.

**Not yet wired: `aggregateFor` still holds its own copy, so nothing changes in production.** That
is stage 4, and it carries a consequence worth deciding before it lands rather than discovering
after — see below.

**A CONFIRMED metadata rule is a widening, deliberately surfaced.** Today a Modrinth
`server_side: unsupported` yields MEDIUM confidence and is *not* published: `/as-properties` gates
on decisive boot evidence. Under "if a mod matches a rule, it is exclusion-worthy" the same signal
becomes CONFIRMED and would be published without any boot. That is what was asked for and the rules
are editable precisely so it can be tuned — set `enabled: false` on `platform-server-unsupported`
and `manifest-client-only` to keep publication boot-only. Flagging it because it changes what
reaches users' fallback lists, which is the highest-stakes output here.

One existing assertion changed: `onlyTheClientOnlyRuleConfirms` became
`onlyTheClientOnlyRuleConfirmsFromAConsole`, scoped to `RuleSource.CONSOLE`. Its subject was always
the console ladder; it could simply not say so while that was the only stream.

clientside 295/0 (288 before, +7), the 46 pre-existing classifier guards among them, re-run with
--rerun-tasks after wiping build/test-results.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Corrects stage 3 before stage 4 consumes it. As shipped, a metadata rule could reach CONFIRMED on
its own — a short-circuit that would have published mods on their own say-so, without a boot, and
would have let the self-report outrank the very evidence it is unreliable about. It is the widening
flagged in stage 3's message; the answer is that it must not happen at all.

**Precedence is now explicit: console over metadata, absolutely.**

  - A declaration — client, both or server — never stands in for a boot. Every mod is still booted
    and the console decides.
  - A mod declaring **server** whose console reaches a client-only class is CONFIRMED **client**.
    Not an edge case: it is the target. A mod honestly declared client-only is already excludable
    from its metadata and costs nothing to find, so the ones worth a container are those coded
    unclean — claiming the server while calling the client. The console rules are the instrument
    for catching exactly that, which is why they are the ones worth crafting delicately.

`Declaration { CLIENT, SERVER, CONTRADICTORY }` is a separate vocabulary from `Verdict` on purpose.
A metadata rule sets `declares` and may not set `verdict`; giving the two streams one codomain is
precisely what would let a self-report be published as a finding, and
`ConsoleOutranksMetadataTest.noMetadataRuleCarriesAVerdict` fails the build if one ever does —
because that regression would otherwise be silent.

`VerdictPolicy.decide` takes `declared` and never consults it. Accepting it makes the decision
honest about what it was given rather than about what it used, and the pins sweep all four
declaration values through both the confirmation and the unexplained-crash paths to prove the
declaration changes neither.

Two rules added that the old fold had no use for but this one does: `platform-server-required` and
`manifest-server-or-both`, both declaring SERVER. They are what make the money case identifiable —
a SERVER declaration contradicted by the console is the finding, and it cannot be reported as such
if nothing records the claim.

Existing expectations changed, deliberately and flagged: `MetadataRuleTest`'s three
confirmation assertions now assert declarations. That is the stop-and-flag signal working — this is
labelled `fix:` rather than `refactor:` because the behaviour is what changed.

clientside 301/0 (295 before, +6), the 46 pre-existing classifier guards among them, re-run with
--rerun-tasks after wiping build/test-results.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Stage 4a. What one loader's evidence adds up to under the four-state verdict, with the console
deciding and the metadata only declaring. This is where the redesign becomes visible in the report.

The cases pinned are the ones the old fold got wrong or could not express:

  - **the target** — both sources declare the server supported, the boot dies on a client-only
    class: CONFIRMED, with the SERVER declaration recorded, because a contradicted claim *is* the
    finding and cannot be reported if nothing kept the claim
  - a prevented grind is ERROR, not a boot that learned nothing
  - a clean boot is CLEAR, not folded in with doubt
  - a crash decided by the bare exit-code rung is INCONCLUSIVE: it means only "exited non-zero,
    nothing recognised why", which is the rung that had 27 of 43 published HIGH verdicts resting on
    no decisive evidence
  - a client declaration with no boot stays INCONCLUSIVE — a self-report may not publish a mod
  - **not booting on purpose is not an ERROR.** The `-clientsidereport` verb asks for metadata only;
    nothing was prevented. ERROR has to stay reserved for a grind that could not be performed or it
    stops meaning anything an operator can act on, which is the whole reason it exists.
  - every confirmation names the rule that produced it, whether that is an operator's rule or the
    built-in marker reporting its own id — a verdict that cannot name its evidence cannot be audited
    or revoked

Run before committing; red for the missing pieces only (`Unresolved reference 'verdictOf'`, and
`No parameter with name 'stagingPrevented'` — `BootOutcome` cannot yet say a grind never started,
which is precisely the gap ERROR exists to close).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Stage 4a. `ClientsideVerifier.verdictOf` is the replacement for `aggregateFor`: console decides,
metadata declares, and the two are returned together as a `VerdictAssessment` because a verdict is
only auditable alongside the rule that produced it and the claim it contradicts.

Three supporting pieces, each closing a gap the old model could not express:

  - **`BootOutcome.stagingPrevented`.** Without it a refusal and a boot that learned nothing were
    the same INCONCLUSIVE, which is how a host-wide defect came to be published as one verdict per
    candidate and overwrote decisive ones the TTL would have left alone. Set at the staging-refusal
    sites; it is what `Verdict.ERROR` is derived from.
  - **`BootObservation.Unclear`.** A boot that ran and ended with nothing recognised. Distinct from
    `TimedOut` only in how it arrived; both mean the grind happened and taught us nothing.
  - **`BootDecision.ruleId`**, derived from the enum name so the two cannot drift — `CLIENT_ONLY_CLASS`
    is `client-only-class`, exactly the id the bundled file ships. A confirmation therefore always
    names a rule an operator can find and edit, whether it came from their rule or a built-in rung.

**Only a decisive rung may confirm**, reusing `BootDecision.decisive`: the built-in client-class
marker, which no broken harness can fabricate, or an operator rule that stated CRASHED deliberately.
The bare exit-code rung means "exited non-zero, nothing recognised why" and now yields INCONCLUSIVE
— it is the rung that had 27 of 43 published HIGH verdicts resting on no decisive evidence.

`aggregateFor` is untouched and still wired; nothing in production changes yet. 4b moves the grinder
onto `verdictOf` and retires it, which is also where the store starts clean.

clientside 309/0 (301 before, +8), grinder 446/0 (29 skipped) unchanged, both re-run with
--rerun-tasks after wiping build/test-results.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Stage 4b-i. `/as-properties` feeds an SPC instance's `fallback.updateurl`, so a row reaching it
becomes a mod excluded from real server packs — the highest-stakes output this engine has. The gate
is now exactly one condition: `Verdict.CONFIRMED`.

The old gate needed two, `Confidence.HIGH` *and* a separate decisive-rung check, because HIGH was
also reachable from the bare exit-code rung — "exited non-zero, nothing recognised why". Measured
against the live daemon, 27 of 43 published HIGH verdicts rested on no decisive evidence. Under the
redesign that second condition is structural: `verdictOf` only reaches CONFIRMED from a decisive
rung, so CONFIRMED *means* decisive and the gate asks once.

  - **An ERROR never publishes, whatever the volume.** During the missing-runtime-image outage every
    candidate produced exactly that shape, and a gate leaking it would exclude mods from users' packs
    on the strength of a broken Docker host. Pinned across 20 rows, not one, because the failure mode
    is a flood rather than a single row.
  - A metadata declaration publishes nothing at all — a mod is excluded because a console proved it,
    never because the mod said so about itself. The deliberate narrowing, confirmed as intended.
  - Retention is asked of the verdict (`keepsLogs`) rather than re-derived, so artifacts and the
    outcome justifying them cannot drift apart.
  - **A row from the old schema loads as INCONCLUSIVE**, publishing nothing until re-ground. That is
    "start clean" without deleting anything: the old `Confidence` scale has no honest mapping onto
    the new verdicts, so no old row is treated as evidence and each is re-earned by a real boot
    rather than translated. The re-verify TTL does the rest.

Red for the missing field only.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Stage 4b-i. `/as-properties` now gates on `Verdict.CONFIRMED` alone, `GrindVerdict` carries the
verdict and the declaration, and `verdictOf` is wired through `LoaderVerdict` into the store.

**One gate condition where there were two.** `Confidence.HIGH` was also reachable from the bare
exit-code rung — "exited non-zero, nothing recognised why" — so a separate decisive-rung check had to
sit beside it; on the live daemon 27 of 43 published HIGHs rested on no decisive evidence. That check
now lives upstream in `verdictOf`, which only reaches CONFIRMED from a decisive rung, so CONFIRMED
*means* decisive and asking twice would only let the two drift.

**Old rows load as INCONCLUSIVE and publish nothing.** That is "start clean" without deleting: the
`Confidence` scale has no honest mapping onto the four verdicts, so no stored row is treated as
evidence and each is re-earned by a real boot. The re-verify TTL does the rest, and the store keeps
its history meanwhile.

`verdict` and `declared` are appended at the *end* of both constructors. Inserting them mid-list
broke two positional call sites, which is the cheap version of the lesson: an optional field added
anywhere but the tail is a source-breaking change to every positional construction.

**Five existing tests changed, each by judgment rather than by rename** — this is the stop-and-flag
signal, and the label is `feat:` because publication behaviour is what changed:

  - `onlyAVerdictDecidedByDecisiveEvidenceIsPublished` keeps every assertion byte-identical; only its
    local helper changed, to model the fold that now happens upstream. Its concern — a bare non-zero
    exit must never publish — is unchanged and still pinned here end-to-end, as well as at its new
    home in `VerdictAggregationTest.aCrashNoRuleExplainedIsInconclusive`.
  - four fixtures that stood for "a finding" now say `verdict = Verdict.CONFIRMED` explicitly rather
    than relying on a confidence that no longer decides anything.

The fixture default is deliberately INCONCLUSIVE, not CONFIRMED: a fixture written before the
redesign should stand for an unmigrated row, never silently for a published finding.

clientside 309/0, grinder 450/0 (29 skipped), both re-run with --rerun-tasks after wiping
build/test-results.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Stage 4b-ii. The publication gate moved to `Verdict` in 4b-i while the table and CSV still showed
`HIGH/MEDIUM/LOW` — coherent internally, unreadable for an operator, who would see a row ranked LOW
being published and a row ranked HIGH withheld.

Two columns, pinned together because they are useless apart. A CONFIRMED row says a console proved
the mod reaches client-only code; the `Declared` column beside it says whether the mod had *claimed
the server*. That is the difference between an honestly-labelled client mod and one coded unclean,
and the second is the only one worth attention — the reason the boot is paid for at all.

  - the default order leads with CONFIRMED (the findings), then INCONCLUSIVE (whose consoles are the
    raw material the next rule is written from), then ERROR (an operator's problem, not a mod's),
    then CLEAR (nothing to do). The fixture slugs are deliberately alphabetical in the *same* order
    the verdict rank produces, so the assertion would pass on a slug sort too — and the test says so,
    rather than quietly proving less than it looks like.
  - an absent declaration renders blank, never "UNKNOWN" or "null": every CurseForge project is in
    that state, since the platform publishes no sideness at all, and a word there would tell a reader
    we asked and were told.
  - **a drift guard between the two retention rules.** `BootArtifacts.worthKeeping` decides per
    *attempt*, long before a verdict exists; `Verdict.keepsLogs` states the same policy for the
    published row. They are independent expressions of one rule and nothing makes them agree, so this
    asserts they do.

Run before committing; fails behaviourally on the real CSV header, which still reads `Confidence`.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Stage 4b-ii. `VerdictField.CONFIDENCE` becomes `VERDICT`, a `DECLARED` column joins it, and the rank
table follows. Because that enum is the single source for the table header, the CSV header, the query
key, the filter kind and the sort key, one declaration moves all five together — which is exactly why
the CSV and the table cannot drift into disagreeing about what "first" means.

The two columns belong side by side. A CONFIRMED row says a console proved the mod reaches
client-only code; `Declared` says whether the mod had claimed the server. That pairing is the finding
this engine exists to produce, and neither column states it alone.

Rank: CONFIRMED, INCONCLUSIVE, ERROR, CLEAR — the findings, then the consoles a new rule gets written
from, then the host's own problems, then the rows with nothing left to do.

An absent declaration renders blank rather than "UNKNOWN": every CurseForge project is in that state,
since the platform publishes no sideness at all.

**Ten existing tests changed, and the rename was not mechanical.** The ordering tests map by *rank
position* (HIGH→CONFIRMED, MEDIUM→INCONCLUSIVE, LOW→ERROR, INCONCLUSIVE→CLEAR) because what they
actually assert is the ordering, not the words. `ConfidenceSortRankTest` is renamed
`VerdictSortRankTest` after its subject, and its CSV cross-check now matches the Verdict column's own
*cell* rather than anywhere in the line: INCONCLUSIVE belongs to both the old and the new vocabulary,
so a loose `contains` would still find a stale value and quietly agree with itself.

Two self-inflicted detours worth recording, both caught by the compiler rather than by review: a
regex that double-applied and passed `verdict` twice, and a first pass that crashed part-way and left
one file half-migrated. Both were reverted with `git checkout --` and redone in a single pass. A bulk
rename across 19 files is exactly where a silent half-edit hides, which is why every step here ran the
suite rather than trusting the substitution.

grinder 455/0 (29 skipped), re-run with --rerun-tasks after wiping build/test-results.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Stage 5a, removing duplication I introduced myself. `BootObservation` was written in stage 1 before
the classifier's shape was in view, and `BootResult` already models exactly the same thing — so
`verdictOf` had been bridging them with a `when`, and passing a **fabricated `exitCode = 1`** because
`BootObservation.Crashed` demanded one that nothing had.

A fabricated value in a decision path is worth removing on its own: it reads as data and is not. The
exit code was never consulted, so nothing is lost by having no field to invent it into.

`VerdictPolicy.decide` now takes the classifier's own `BootResult`. CRASHED and INCONCLUSIVE share a
branch, which states the thing plainly: a crash no rule explained and a boot that ended for no
recognised reason say the same thing — the grind happened and taught us nothing — and neither is an
ERROR, because the container ran.

Behaviour-preserving: the collapsed mapping was 1:1, and no assertion's expected value changed. The
two policy tests changed only in the type they pass, which is the reference-only carve-out the
conventions describe, so this stays `refactor:`. One test was renamed to match what it now says —
`aTimeoutIsInconclusiveBecauseTheGrindDidRun` became `aBootThatRanButProvedNothingIsInconclusive`,
since the timeout is no longer a distinct case.

clientside 309/0.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
First of the re-cut that replaces one 33-file deletion (audit iteration 34, MED-1). The grinder drops
`GrindVerdict.confidence` and every read of it; `-clientside` still declares the type, so this commit
compiles and is green on its own.

Behaviour-preserving: nothing consulted `confidence` after `/as-properties` moved to
`Verdict.CONFIRMED`, so removing the field changes no decision. The stored JSON loses a key that was
already ignored on read.

**Two bridge sites are deliberate and temporary.** `LoaderVerdict.confidence` is a `-clientside` field
and still required here, so `GrindTestFixtures.loaderVerdict` and `ContainerCandidateVerifierReapTest`
keep supplying one, marked as such. The next commit deletes the field and both bridges with it. That
is the cost of a compiling intermediate, and it is the point: the alternative is the single sweep this
re-cut exists to undo.

Test fixtures that used two confidences to distinguish rows now use two verdicts; the distinguishing
values were recovered from the original diff rather than re-invented, since a sweep that flattened
them would leave those store tests green while proving nothing.

clientside 309/0, grinder 455/0 (29 skipped).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Second of the re-cut. With the grinder off the type, `-clientside` can delete it: the `Confidence`
enum, `LoaderVerdict.confidence`, and the `aggregateFor` fold that produced it. Breaking for anything
reading those, hence `!`.

`aggregateFor` also produced the report's **note**, so that moved rather than vanished. `verdictOf`
returns it now, carrying exactly the two observations the verdict alone cannot make: that a
*contradicted* server claim is what makes a confirmation interesting rather than routine, and that a
distribution-locked file was never readable at all — otherwise indistinguishable from a mod nobody
has got round to.

`supersededByLoader` takes a `VerdictAssessment` instead of a `Pair<Confidence, String?>`, so a
cross-loader disproof lands on INCONCLUSIVE: the crash is disproven, which means we learned nothing
about that loader, not that we learned something mild.

The two bridge sites the previous commit introduced are gone with the field they existed for.

`ClientsideVerifierServerSupportTest`'s five assertions were migrated by concern rather than by
rename, and one of them **inverted**: "a clean boot must not overturn a client-only declaration" was
true under the old precedence and is deliberately false under the new one, where the console decides.
It is kept as a test of the *new* rule, with the reversal stated in its doc rather than deleted
quietly — the claim is still recorded on the verdict, it simply no longer overrides the boot.

clientside 309/0, grinder 455/0 (29 skipped).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Third of the re-cut, and separated because it is a **behaviour fix, not part of deleting a type**
(audit iteration 34, MED-2). `GrinderAuditIT` parses `/export.csv` for a `Confidence` column and a
`HIGH` value, neither of which the export carries any more. It compiles perfectly and would have
failed against a live daemon — the class of defect the conventions ask to be surfaced in its own
commit rather than folded into a sweep, precisely because a sweep is where it disappears.

Now reads `Verdict` and grades `CONFIRMED` rows. `highConfidenceTuples` is renamed `confirmedTuples`
after what it returns.

Gated on `GRINDER_AUDIT_IT=1` and a live grinder, so nothing here exercises it — which is exactly why
it needed to be noticed by reading rather than by a red suite.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Last of the re-cut, and its own commit because it is documentation, not code.

`BootLogStore`, `FallbackPropertiesRenderer` and `VerdictQuery` carried `[Confidence.HIGH]` and
`[Confidence]` links that now resolve to nothing — dokka would have broken on them. Two of the three
also *stated* the old gate ("only HIGH is ever published", "confidence ordering"), which would have
told the next reader something false about how publication works.

The mentions left are deliberate history in backticks, explaining why the scale is gone rather than
pointing at it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Root CLAUDE.md (clientside 266 → 309, grinder 446 → 455), both module files, and the refactor log.

The entries are written around what a reader will get wrong rather than around what changed:

  - the ladder's *order* is still in code while its *content* is in the file, so "finishing the job"
    by turning `classify` into a bare loop would lose the killed-exit-code rung that sits between
    rungs
  - a metadata rule may declare but never decide, and the guard that enforces it exists because the
    regression is silent — the file would simply start publishing mods that were never booted
  - the metadata fact line's field names are an interface operators write patterns against; a rename
    presents as "nothing is clientside any more" rather than as a break
  - `/as-properties` will serve a visibly shorter list after deploy, because nothing is translated
    from the old scale

Stale current-state prose was corrected and historical prose left alone: "iron-chests published HIGH"
records what was measured and stays; "combine signals into a per-loader Confidence" described a type
that no longer exists and did not.

Also records an open item rather than hiding it: `ConsoleRule` and `BootRule` are two implementations
of one idea, left uncollapsed because merging them breaks a documented operator-facing file format.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Audit iteration 34, HIGH-1 and HIGH-2. `Verdict.ERROR` exists to separate "the grind could not be
performed" from "the grind ran and taught us nothing". Staging refusals got `stagingPrevented` when
the verdict was introduced; two other never-ran paths did not, so they still publish INCONCLUSIVE —
the exact conflation the redesign was built to remove.

  - **A thrown pack post-processor.** That hook is the grinder's `overlayLoaderInstall`, a
    *loader-cache* operation, so it fails precisely when the host is broken. The worst possible site
    for this bug: it is the missing-runtime-image shape, a host defect published as a verdict about a
    mod.
  - **`RunResult.NotStarted`** — the runner reporting it never started the server at all
    ("No start.sh in the generated server pack.").

**`aStagedGrindWithNoObservationIsAnError` looks like it already covers the second, and does not.** It
asserts on `boot == null`, while `NotStarted` yields a *non-null* outcome carrying INCONCLUSIVE, so
`verdictOf` never reaches that branch. A guard that appears to cover a case it cannot reach is worse
than an absent one, because it stops anyone looking — so these are pinned on the outcome itself, not
only through the policy.

`aRealBootThatFailedIsNotMarkedPrevented` is the counterweight and **passes already**: a container
that ran and crashed on a client-only class must stay evidence, or this fix would trade a false
INCONCLUSIVE for a lost true positive.

Run before committing. Three fail behaviourally (`expected: <true> but was: <false>`, and
`expected: <ERROR> but was: <INCONCLUSIVE>`); the counterweight passes. An earlier draft failed on a
non-null `logFile` parameter instead — a fixture bug, fixed before committing so the red is the
missing behaviour and nothing else.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Answers audit iteration 34, HIGH-1 and HIGH-2. Two never-ran paths still published INCONCLUSIVE
because `stagingPrevented` was added to the staging refusals and nowhere else:

  - `runPrepared`'s thrown post-processor. In the grinder that hook *is* `overlayLoaderInstall`, so
    it fails when the loader cache is broken — meaning a broken host was being published as a verdict
    about every mod that wanted that tuple. The missing-runtime-image outage in miniature, and the
    single most likely site for it to fire.
  - `outcomeFor`'s `RunResult.NotStarted`, which is the runner saying it never started the server.

Both now set `stagingPrevented`, so `verdictOf` returns `Verdict.ERROR` and neither can reach
`/as-properties`, whose gate is CONFIRMED alone.

The stale KDoc on `outcomeFor` said `NotStarted` is INCONCLUSIVE and has been corrected rather than
left to mislead the next reader into thinking the old behaviour was intended.

**Scope held deliberately.** `aRealBootThatFailedIsNotMarkedPrevented` passed before this change and
still passes: a container that ran and crashed on a client-only class stays evidence. Marking that
prevented would have traded a false INCONCLUSIVE for a lost true positive, which is the worse trade —
this engine exists to find those crashes.

Also fixes LOW-1: `DefaultBootRules` declared `private val bundled` beside `fun bundled()`. Legal
Kotlin, but a property and function sharing a name read as a typo at the call site; the property is
now `cached`.

clientside 309 → 313; full tree 1303/0 across five modules, re-run with --rerun-tasks after wiping
build/test-results.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Answers audit iteration 35, MED-1 — two stale references the previous fix left behind, both of which
it introduced.

The module doc said `stagingPrevented` "is set at the staging-refusal sites". True before the fix,
and wrong afterwards in the direction that matters: a reader adding a new never-ran path would
conclude the field was not their concern, which is precisely how the thrown-post-processor and
`RunResult.NotStarted` paths came to ship as INCONCLUSIVE. It now names all four, says which two were
missed and why that one mattered most, and points at where to pin a fifth — including the
counterweight that stops the flag swallowing real crashes.

`aThrownPostProcessorIsInconclusiveAndSkipsTheBoot` is renamed
`aThrownPostProcessorSkipsTheBootAndSurfacesTheCause`. Its assertions are unchanged and were always
correct — the `BootResult` really is INCONCLUSIVE — but the name described the old verdict semantics,
so anyone searching for "does a thrown hook produce an error?" would have found it and concluded the
opposite of the truth.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Answers audit iteration 36. The root CLAUDE.md's clientside row stated two contradictory rules in one
table cell: the 2026-09-01 "a clean boot cannot overturn a client-only declaration", and the
2026-09-04 "the console decides and the metadata only declares" that deliberately reversed it. The
file loads into every session and both sentences read as current, so whichever a reader met first won.

Marked as history rather than deleted, because **the finding that produced it survives the change in
what it produced**: a boot reaching its ready-line is the most expensive signal this engine makes and
must never read as "we learned nothing". That is now why `CLEAR` is its own verdict instead of
folding into INCONCLUSIVE, and `better-stats`/`tcdcommons`/`yacl` remain the measured rows behind it.

Count corrected 309 → 313, the four `PreventedGrindTest` guards. Caught by the instruction the count
carries — re-derive from `build/test-results`, do not trust the sentence — which it needed within one
commit of being written.

Also appends audit iterations 34, 35 and 36.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`advancement-plaques` was refused with "Required dependency unavailable for Forge / Minecraft 26.2:
prism", spending a `BootResult.INCONCLUSIVE` on a mod that never required prism. Its own
`META-INF/mods.toml` declares `prism` and `toastcontrol` `mandatory=false`, and Modrinth agrees.

The platform half was already right — Modrinth keeps only `dependency_type == "required"`, CurseForge
only `relationType == 3`. The manifest half never existed: neither `mandatory` nor `type` appeared
anywhere in `-api`, so `ModDependency` had no field to carry optionality and `stageableRequirements`
had nothing to filter on.

Both loader spellings are read by one reader, because `NeoForgeTomlScanner` overrides only the
descriptor's file name and NeoForge on Minecraft 1.20.2-1.20.4 still ships `mods.toml`. Absent means
required — NeoForge's own default, and the safe direction: a required dependency read as optional
boots a mod without what it needs and can publish a wrong verdict, while the reverse only refuses a
boot.

Optional dependencies are **flagged, not dropped**. Removing them from the scan would also remove
them from `ModListCompiler`'s dependency rescue and could strip mods from users' server packs,
against this module's own rule that dropping a needed mod breaks the pack while keeping a superfluous
one costs megabytes. The filter lives at the boot-staging consumer instead.

api 383 → 387, clientside 266 → 267.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
merge: the four-verdict result system
All checks were successful
Documentation / Writerside webhelp (push) Successful in 2m7s
Continuous / Build JAR (push) Successful in 17m55s
Docker Test / build image (push) Successful in 19m39s
Qodana / scan (push) Successful in 18m8s
Continuous / Build AppImage (x86_64) (push) Successful in 2m2s
Documentation / Help image (push) Successful in 3m34s
Continuous / Build AppImage (aarch64) (push) Successful in 2m38s
Qodana / notify (push) Successful in 19s
Continuous / Build Install4J Media (push) Successful in 7m21s
Test / build (push) Successful in 15m28s
Continuous / Continuous Pre-Release (push) Successful in 3m20s
aba2342143
Replaces `BootResult` × `Confidence` with `Verdict { CONFIRMED, CLEAR, ERROR, INCONCLUSIVE }` and
moves every clientside-determining rule into an editable file.

The old pairing conflated *what happened* with *how sure are we*, and could not say the thing an
operator most needed: **whether the grind ran at all**. `ERROR` is that missing verdict, and its
absence is what let the missing-runtime-image outage publish a host-wide defect as one INCONCLUSIVE
per candidate, overwriting decisive verdicts the TTL would have left alone. `CLEAR` is the other
half — a clean boot that matched nothing is *proven server-safe*, which a single INCONCLUSIVE bucket
destroys.

**Only a rule reaches CONFIRMED**, and only from a rung `BootDecision.decisive` marks, so the bare
exit-code rung — "exited non-zero, nothing recognised why", which carried 27 of 43 published HIGHs —
can no longer publish anything. `/as-properties` gates on CONFIRMED alone; expect a visibly shorter
list until boots accumulate, since no stored row is translated from the old scale.

**The console decides and the metadata only declares.** A metadata rule sets `declares` and may not
set `verdict`, with a guard failing the build if one does. The target case is a mod claiming *server*
whose console reaches a client-only class: an honestly-declared client mod is already excludable from
its metadata and costs nothing to find, so the container is paid for the dishonest one.

Three conflict resolutions worth recording:

  - `CLAUDE.md`'s clientside cell was edited by both branches from the same base. Resolved by keeping
    **both** notes rather than taking a side, and the count re-derived from `build/test-results`
    rather than trusted: 266 + 1 + 47 = **314**, where both branches' own figures (267 and 313) were
    each correct alone and wrong merged. That is exactly what the "re-derive the count" instruction
    in that column exists to catch.
  - `REFACTOR-LOG.md` had two appended sections; neither supersedes the other, so both are kept.
  - `BootVerifier.kt` auto-merged. Verified rather than assumed: `ManifestDependencyTest` (17),
    `OptionalDependencyTest` (4), `PreventedGrindTest` (4), `ConsoleOutranksMetadataTest` (6) and
    `VerdictPublicationTest` (4) all pass, so the optional-dependency filter and the verdict work
    still hold in the same file.

Suites: api 387 (1 skip), clientside 314, grinder 455 (29 skip), app 149, plugin-example 3 — **1308
total, zero failures**, re-run with --rerun-tasks after wiping build/test-results.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Reported from the live grinder: `iris` scored INCONCLUSIVE with
`java.lang.NoClassDefFoundError: org/lwjgl/Version` in its console. LWJGL is the client's windowing
and OpenGL binding, which a dedicated server never ships, so reaching it while the server starts is
as decisive as `net/minecraft/client` — arguably more so, since no environment failure can fabricate
it either.

**The cause was not rule ordering, and adding one would not have helped.** `boot-rules.example.json`
— the operator *template*, which the daemon never loads unless somebody copies it — carried
`lwjgl-on-a-dedicated-server` and `fml-invalid-dist`, while the bundled defaults carried neither.
Nothing matched the line at all, so there was nothing to order: it fell through every rung to the
bare exit code, which means "exited non-zero, nothing recognised why" and cannot confirm. Out of the
box, the engine had never caught either signature — this predates the four-verdict redesign rather
than being caused by it.

`fml-invalid-dist` earns its place for a second reason: FML prints "for invalid dist
DEDICATED_SERVER" when it refuses a client-only class, and NeoForge's ServerStarterJar can print that
crash in full and still **exit 0**, which the exit-code rung reads as inconclusive. A rule is what
makes the console outrank the status.

Pinned alongside the two orderings that must survive: a fair-run guard still outranks both (a mod
that never loaded cannot have been proven clientside), and both still outrank the excuses (a
clientside mod may also be missing a dependency).

`merelyNamingLwjglIsNotEvidence` is the counterweight — a server-side mod logging the word must not
be caught, so the pattern is anchored to the two loader-failure spellings rather than to the string.

`third-party-screen-class` stays an example deliberately: its own note says it is "often a dependency
problem, not sideness", it states no verdict, and a default firing on a dependency's GUI class would
publish mods on someone else's crash.

Red for the missing `BootDecision` constant only.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Answers the `iris` report: `NoClassDefFoundError: org/lwjgl/Version` scored INCONCLUSIVE. Both
signatures move from `boot-rules.example.json` — a template the daemon never loads — into the bundled
defaults, and get rungs in the decisive band beside `client-only-class`.

**Not an ordering fix.** No rule matched that line at all, so there was nothing to order: it fell
through to the bare exit code, which means "exited non-zero, nothing recognised why" and cannot
confirm. The gap predates the four-verdict redesign.

Placed in the decisive band, which is where they belong on the same reasoning `client-only-class`
sits there: a dedicated server ships no LWJGL, and FML printing "for invalid dist DEDICATED_SERVER"
is the loader itself refusing a client-only class. Neither can be fabricated by a broken harness —
that is the bar for this set, and it is why they outrank every excuse while still yielding to every
fair-run guard. Both orderings are pinned.

`fml-invalid-dist` also stops a zero exit hiding a crash: NeoForge's ServerStarterJar prints the
refusal in full and exits 0.

**Three existing guards changed, each by concern, and one of them is a consequence worth naming:**

  - `onlyTwoDecisionsAreDecisiveEvidence` → `theDecisiveSetIsSmallAndExplicit`. The set legitimately
    grew from two to four; the assertion now says what qualifies rather than how many there are.
  - `ConsoleRuleLadderTest.aRuleCrashesAConsoleThatAZeroExitWouldHaveExcused` used FML's invalid-dist
    as its example of a gap operator rules exist to close — **and this commit closes that gap**, so
    the test was demonstrating something no longer true. It now uses a deliberately *synthetic*
    signature, because the mechanism is what it pins and a real one can be promoted out from under it
    again. That is the second time a real example in that test has been consumed by a default.
  - `onlyTheClientOnlyRuleConfirmsFromAConsole` → `onlyDecisiveClientEvidenceConfirmsFromAConsole`,
    listing all three.

`third-party-screen-class` stays an example deliberately: its own note calls it "often a dependency
problem, not sideness", it states no verdict, and a default firing on a dependency's GUI class would
publish mods on someone else's crash.

clientside 314 → 321, grinder 455 (29 skipped), both re-run with --rerun-tasks.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`iris` scored INCONCLUSIVE with `java.lang.NoClassDefFoundError: org/lwjgl/Version` in its console.
LWJGL is the client's windowing and OpenGL binding, which a dedicated server never ships, so reaching
it while the server starts is as decisive as `net/minecraft/client`.

**Not an ordering problem.** No rule matched that line at all, so there was nothing to order: both
signatures shipped only in `boot-rules.example.json`, an operator template the daemon never loads.
The gap predates the four-verdict redesign — `iris` would have scored the same before it.

Both are now bundled defaults with rungs in the decisive band: above every excuse, below every
fair-run guard, and both orderings pinned. `fml-invalid-dist` also stops a zero exit hiding a crash,
since NeoForge's ServerStarterJar prints the refusal in full and exits 0.

clientside 314 → 321.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`xaeros-world-map` was refused with "Required dependency unavailable for Quilt / Minecraft 26.2:
xaerolib". Its Quilt/26.2 jar declares `depends: { "xaerolib": ">=1.0" }` **and ships it** —
`"jars": [{"file": "META-INF/jars/xaerolib-fabric-26.2-1.7.1.jar"}]`, whose own descriptor reads
`id: xaerolib, version: 1.7.1`. Fabric and Quilt Loader load nested jars, so the requirement was
already satisfied when we went looking for it.

**The near-miss is what made it fatal.** A Modrinth project `xaerolib` exists, so the manifest id
*mapped* — but it publishes 13 versions, none tagged Quilt and none tagged 26.2, so nothing could be
staged. A mapped-then-unstageable id lands in `unsatisfied`, which refuses; had the project not
existed at all it would have landed in `unmapped`, which does not. The mod was refused for a library
it was carrying.

**Not one mod's quirk.** Sampled the same day: `sodium` bundles 9 nested jars, `modmenu` 1. Any
bundled library that also exists as a thinly-tagged standalone project reproduces this, and each
occurrence costs an INCONCLUSIVE that overwrites whatever the store held.

The pins are shaped around the ways this goes wrong rather than the happy path:

  - ids come from the **nested descriptors**, not from guessing at file names
  - a nested jar's `provides` aliases count, since a dependant may name any of them
  - a dependency that is *not* bundled is still required — or this hides real failures
  - the Quilt `quilt_loader.jars` shape is read as well as Fabric's, since a Quilt candidate is
    exactly what reported it
  - **an undeclared jar in `META-INF/jars/` is NOT bundled.** Fabric loads the declared list; treating
    a stray file as satisfied would skip staging something genuinely needed and produce a failure to
    blame on the mod. This is the one direction where being generous is dangerous.
  - an unreadable jar yields nothing rather than throwing

Bundled wins unconditionally: the author shipped that exact build, and fetching a different version
of the same id is how a conflict is manufactured and then blamed on the mod.

Red for the missing `BundledJars` type and the missing `bundledIds` parameter.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`xaeros-world-map` was refused with "Required dependency unavailable for Quilt / Minecraft 26.2:
xaerolib" while shipping `xaerolib` inside its own jar. `BundledJars.idsIn` reads a candidate's
nested jars and `stageableRequirements` drops anything they provide.

**Why a refusal rather than a harmless miss.** A Modrinth project `xaerolib` exists, so the manifest
id *mapped*, but it publishes 13 versions with none tagged Quilt and none tagged 26.2, so nothing
could be staged. A mapped-then-unstageable id goes to `unsatisfied`, which refuses; had the project
not existed at all it would have gone to `unmapped`, which does not. The near-miss is the whole
mechanism — being *almost* resolvable is worse here than being unknown.

**A class, not a quirk.** Jar-in-jar is ordinary: `sodium` bundles nine nested jars, `modmenu` one.
Any bundled library that also exists as a thinly-tagged standalone project reproduces this, and each
occurrence spends an INCONCLUSIVE that overwrites whatever the store held. It also relieves
`MAX_INJECTED_DEPENDENCIES`, which bundled libraries were counting against.

Bundled wins **unconditionally** (Griefed's call): the author shipped that exact build, so fetching
another version of the same id is how a conflict is manufactured and then blamed on the mod.

**Only declared nested jars count, and that restraint is load-bearing.** Fabric loads the jars its
descriptor lists; a stray file under `META-INF/jars/` is not on the classpath, and treating one as
satisfied would skip staging something genuinely needed — the one direction in which being generous
here produces a failure to blame on the mod. Unreadable input yields no ids for the same reason:
"we could not look" has to mean "assume nothing is bundled".

Ids come from each nested descriptor's own `id` and `provides`, never from its file name — a name
like `xaerolib-fabric-26.2-1.7.1.jar` carries a version and a loader the id does not. Both loader
spellings are read, Fabric's `jars: [{file}]` and Quilt's `quilt_loader.jars: [string]`, since a
Quilt candidate is what reported this.

**Verified against the real artifact, not only the fixtures:** run over the actual
`xaeroworldmap-fabric-26.2-1.45.0.jar`, `idsIn` returns `[xaerolib]` and the stageable set narrows
from `[xaerolib, fabric-api]` to `[fabric-api]`. The probe was temporary and is not committed — the
suite stays offline.

clientside 321 → 329; full tree 1323/0 across five modules.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
merge: a dependency the candidate ships is never fetched or missing
All checks were successful
Documentation / Writerside webhelp (push) Successful in 1m54s
Continuous / Build JAR (push) Successful in 12m43s
Docker Test / build image (push) Successful in 18m18s
Qodana / scan (push) Successful in 17m28s
Continuous / Build AppImage (x86_64) (push) Successful in 2m14s
Documentation / Help image (push) Successful in 3m30s
Continuous / Build AppImage (aarch64) (push) Successful in 2m20s
Qodana / notify (push) Successful in 11s
Test / build (push) Successful in 14m16s
Continuous / Build Install4J Media (push) Successful in 8m10s
Continuous / Continuous Pre-Release (push) Successful in 3m2s
43330a9ca6
`xaeros-world-map` was refused with "Required dependency unavailable for Quilt / Minecraft 26.2:
xaerolib" while shipping `xaerolib` inside its own jar as `META-INF/jars/xaerolib-fabric-26.2-1.7.1.jar`.
Fabric and Quilt Loader load nested jars, so the requirement was satisfied before staging went looking.

**The near-miss is the mechanism.** A Modrinth project `xaerolib` exists, so the manifest id mapped —
but it publishes nothing tagged Quilt or 26.2, so nothing could be staged, and a mapped-then-unstageable
id refuses where an unmappable one would not have. Being almost resolvable was worse than being unknown.

**A class, not a quirk:** `sodium` bundles nine nested jars, `modmenu` one. Any bundled library that
also exists as a thinly-tagged standalone project reproduces this, each occurrence spending an
INCONCLUSIVE that overwrites whatever the store held.

Bundled wins unconditionally; only *declared* nested jars count, because a stray file under
`META-INF/jars/` is not on the loader's classpath and claiming it would skip staging something
genuinely needed.

Verified against the real `xaeroworldmap-fabric-26.2-1.45.0.jar`, not only fixtures.

clientside 321 → 329.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`sodium` — Modrinth `client_side: required, server_side: unsupported`, a client renderer nobody
disputes — was published INCONCLUSIVE. Its NeoForge 26.2.0.76 boot crashed reaching LWJGL, which is
decisive evidence no harness can fabricate, and the other-version re-check then sampled
`sodium-fabric-0.9.2-beta.1+mc26.1.2.jar`, which booted cleanly. `reconcileOtherVersionRecheck`
replaces a crash outright with any survivor, so the proof was discarded.

**A clean boot elsewhere is not a counter-argument to this particular evidence.** The re-checks exist
to tell "this build crashed" from "this mod cannot run on a server" — a real distinction that stopped
`iron-chests` publishing off one bad build. But client-only evidence has already answered it: the
server loaded the mod and the mod reached for the client. Another build merely *starting* proves
nothing, because a client mod can start a server without being any use on one — the asymmetry this
module has documented since the boot-test existed.

**And it crosses loaders** (Griefed's call): a mod's features are the same on Fabric and NeoForge,
only the implementation differs, so one loader's proof makes every loader's entry exclusion-worthy.

Pinned in both directions, because the guard being weakened here is load-bearing:

  - a client-only-proven crash is neither re-checked, nor cleared by a survivor, nor superseded by
    another loader's clean boot
  - an **unexplained** crash still is — `anUnexplainedCrashIsStillDisprovedByAnotherLoader` keeps the
    `iron-chests` protection intact, which is the whole reason cross-loader reconciliation exists
  - propagation does not rewrite what each loader actually did: Fabric's row still reads SURVIVED,
    and the inheriting row must name the loader that proved it or the verdict cannot be audited
  - with no proof anywhere, nothing propagates

Deliberately **not** propagated from `OPERATOR_RULE`, though it is `decisive`: an operator's rule
reaching CRASHED says *this console* is a crash, which is not necessarily a statement about sideness.
Only the three rungs that are client-only evidence by construction propagate.

Red for the missing `provesClientOnly` and `propagateClientOnlyProof` only; the other unresolved
references cascade from them.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Answers the `sodium` report. Its NeoForge 26.2.0.76 boot crashed reaching LWJGL — decisive evidence —
and the other-version re-check then sampled a Fabric build that booted cleanly, which
`reconcileOtherVersionRecheck` treats as replacing the crash outright. A client renderer Modrinth
itself marks `server_side: unsupported` came out INCONCLUSIVE.

`BootDecision.provesClientOnly` marks the three rungs that are client-only evidence **by
construction** — the client-class marker, LWJGL, and FML's invalid-dist. For those:

  - the other-version re-check is not run at all: the question it answers is already answered, so the
    boots would buy nothing and a survivor among them would actively discard the proof
  - a survivor cannot clear it if a re-check is somehow reconciled anyway (defence in depth, since
    that is exactly where sodium's proof was lost)
  - another loader's clean boot cannot supersede it
  - **every other loader of the project inherits CONFIRMED**

The last one is the substantive change and it is Griefed's call: a mod's *features* are the same on
Fabric and NeoForge, only the implementation differs, so a build reaching client-only code proves the
**mod** is client-only. It matters concretely because the loaders carry different stems —
`sodium-neoforge-` and `sodium-fabric-` — so excluding only the proving loader would leave the other
half of the project shipping into every server pack.

**What is deliberately not weakened.** An *unexplained* crash is still disprovable by another loader,
which is the `iron-chests` guard and the reason cross-loader reconciliation exists;
`anUnexplainedCrashIsStillDisprovedByAnotherLoader` pins it. `OPERATOR_RULE` does not propagate
despite being `decisive`: a rule reaching CRASHED says *this console* is a crash, not that the mod is
client-only. And an inheriting verdict keeps its own `bootResult` — Fabric's row still reads SURVIVED
— with a note naming the loader and rung that proved it, because a verdict that cannot say where its
evidence came from cannot be audited.

Suites: clientside 329 → **337**; full tree **1331/0**, confirmed on two consecutive `--rerun-tasks`
runs after wiping `build/test-results`.

**A flake was observed and is recorded rather than dismissed.** One earlier full-tree run failed two
`BootVerifierSelectionTest` cases — `rejectsAProjectThatTargetsOnlyNonReleaseVersions` with a
`java.util.ConcurrentModificationException`, and `acceptsARealReleaseAndAdvancesToDownload` with "No
bootable file/Minecraft/loader combination for Forge", i.e. a momentarily empty release set. Both
drive a real `ApiWrapper` whose `ManifestUpdater` refreshes concurrently; neither touches the
reconciliation this commit changes. It did not reproduce in three runs on `develop`, three on this
branch, or the two full-tree runs above. A `ConcurrentModificationException` is never acceptable, so
this is a latent defect in the manifest-refresh path worth its own investigation — noted here because
the evidence is otherwise lost, not because this commit causes it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`sodium` — a client renderer Modrinth marks `server_side: unsupported` — was published INCONCLUSIVE.
Its NeoForge boot crashed reaching LWJGL, and the other-version re-check then sampled a Fabric build
that booted cleanly, which `reconcileOtherVersionRecheck` treats as replacing the crash outright.

`BootDecision.provesClientOnly` marks the three rungs that are client-only evidence by construction.
Such a crash is not re-checked, not cleared by a survivor, not superseded by another loader — and
every loader of the project inherits CONFIRMED, because a mod's features do not change with the
loader. That last part matters concretely: the loaders carry different stems (`sodium-neoforge-` and
`sodium-fabric-`), so excluding only the proving loader would leave the other half shipping into
every server pack.

An *unexplained* crash is still disprovable by another loader — the `iron-chests` guard is untouched
and pinned. `OPERATOR_RULE` does not propagate despite being decisive.

clientside 329 → 337.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`VersionMeta` refreshes manifests on a background coroutine
(`refreshScope.launch { refreshManifests() }`, `Dispatchers.IO`) — B31's ~392 ms startup win — while
every `update()` in `versionmeta` does `clear()` then re-`add()`s on a plain collection, and
`MinecraftMeta.serverReleases()` returns **the live list**. A reader gets one of two failures:

  - `ConcurrentModificationException` while iterating
  - an **empty or partially-filled list**, read between the `clear()` and the `add()`s

The second is the dangerous one because it does not throw. `BootVerifier.bootableCombination()`
rebuilds its release set from `serverReleases()` on every staging call, so an empty read fails every
candidate against the gate and the boot is refused with "No bootable file/Minecraft/loader
combination for <loader>" — a verdict about the engine's own timing wearing the shape of a statement
about the mod. Both were observed on 2026-09-04 in `BootVerifierSelectionTest`, one as the CME and one
as exactly that message.

**Reproduced, with the production path in its own stack trace:**
`VersionMeta$1.invokeSuspend → refreshManifests → MinecraftMeta.update` throwing
`ConcurrentModificationException` on the refresh coroutine.

**The pin is deterministic, and getting there took two false starts worth recording.** A 200-round
timing test reproduced the CME; trimmed to 60 rounds it passed, which makes it a coin toss rather
than a guard. Worse, when it did "pass" at 4000 rounds the exception was thrown on the *refresher's*
thread while the assertions lived on the reader's — a test that goes green while the very defect it
targets is printing a stack trace beside it. So the pin asserts the invariant that *makes* the
concurrent case safe: the accessor must hand out a snapshot, not the collection the refresh mutates.
Deterministic, and 2.8s instead of 110s.

The stress loop is kept as a bounded net and is documented as unable to prove safety — a torn read is
something the reader genuinely can see, and it is the symptom that costs a candidate.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`MinecraftClientMeta` and `MinecraftServerMeta` now build fresh collections and publish each in one
assignment to a `@Volatile` field holding an **unmodifiable** view. A reader sees the whole previous
state or the whole next one.

**This is a production defect, not a test artifact.** `VersionMeta` refreshes manifests on a
background coroutine — B31's ~392 ms startup win — while callers read, and both classes cleared and
refilled plain collections that `MinecraftMeta.serverReleases()` handed out directly. Reproduced with
the production path in its own stack trace: `VersionMeta$1.invokeSuspend → refreshManifests →
MinecraftMeta.update` throwing `ConcurrentModificationException`.

The silent half is the dangerous one. `BootVerifier.bootableCombination()` rebuilds its release set
from `serverReleases()` on **every staging call**, so a read landing between `clear()` and the
`add()`s yields an empty set, every candidate fails the gate, and the boot is refused with "No
bootable file/Minecraft/loader combination for <loader>". A verdict about the engine's own timing,
wearing the shape of a statement about the mod — the failure mode this project keeps paying for.

**Unmodifiable views, not merely `List`-typed fields.** A `List` field still holds an `ArrayList` at
runtime, so a caller could cast and mutate the metadata's own state; the first attempt at this fix did
exactly that and the pin stayed red until the wrapper went in. The snapshot has to be a snapshot in
fact, not in the type.

**A second bug fixed as a side effect, and worth naming.** `MinecraftClientMeta.update()` cleared
`releases`, `snapshots` and `meta` but never `allVersions`, so every refresh appended the entire
manifest again — an unbounded, duplicate-filled list on any long-running process, which the grinder
is. Building fresh collections removes it without a separate change.

**Scope, stated rather than implied:** eight further classes in `versionmeta` share the
clear-then-refill shape (`ForgeLoader`, `NeoForgeLoader`, `FabricLoader`, `FabricInstaller`,
`QuiltLoader`, `QuiltInstaller`, `LegacyFabricInstaller`, `LegacyFabricVersioning`). They are read by
`LoaderVersionResolver` and carry the same race. This commit fixes the two that were demonstrated to
fail and that `serverReleases()` exposes; the rest are the same mechanical change and follow next,
recorded here so the gap is visible rather than forgotten.

api 387 → 389; full tree 1333/0 across five modules.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Extends the Minecraft pin to every loader meta. Eleven accessors go red across the eight classes that
still share the clear-then-refill shape — `ForgeLoader`, `NeoForgeLoader`, `FabricLoader`,
`FabricInstaller`, `QuiltLoader`, `QuiltInstaller`, `LegacyFabricInstaller`, `LegacyFabricVersioning`.

`LoaderVersionResolver` reads these on the same threads that read the Minecraft metas and the same
background coroutine refreshes them, so a fix covering only Minecraft would leave the identical race
behind a different accessor — which is precisely how it would come back.

`legacyFabric.supportedMinecraftVersions()` is the starkest: it is declared as returning a
`MutableList<String>`, so it does not merely leak the metadata's own state, it advertises it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Completes the fix across the eight remaining `versionmeta` classes — `ForgeLoader`, `NeoForgeLoader`,
`FabricLoader`, `FabricInstaller`, `QuiltLoader`, `QuiltInstaller`, `LegacyFabricInstaller`,
`LegacyFabricVersioning`. Each builds fresh collections and publishes them in one assignment to a
`@Volatile` field holding an unmodifiable view.

All eight shared the shape the Minecraft metas had: `clear()` then re-`add()` on a collection handed
straight to callers, mutated by `VersionMeta`'s background refresh coroutine while
`LoaderVersionResolver` reads it. Eleven accessors were red against the pin.

**Three published signatures narrowed, and `!` is for these** —
`LegacyFabricMeta.supportedMinecraftVersions()` from `MutableList<String>` to `List<String>`, and
`ForgeMeta.getForgeMeta()` / `NeoForgeMeta.getNeoForgeMeta()` from `HashMap` to `Map`. The old types
did not merely leak internal state, they advertised it as mutable. A caller that only reads is
unaffected; one that mutated was corrupting metadata another thread was reading. Recorded in
`claude-docs/API-BEHAVIOUR-CHANGES.md`. `-app`'s `VersionsController` and `VersionMetaResponse`
follow the narrowed types.

**A third latent bug found while rewriting `NeoForgeLoader`.** Its `update()` ended with

    for ((key, value) in versionMeta.entries) { versionMeta[key] = value.reversed() }

— walking the *published* map's entries while writing back into it, so a concurrent reader could
observe the reversal half-applied and get some Minecraft versions' NeoForge builds newest-first and
others oldest-first. It now runs on the builder, before publication.

Suites: api 389 → **403** (the pin's dynamic cases), clientside 337, grinder 455 (29 skipped), app
149, plugin-example 3 — **1347 total, zero failures**, `--rerun-tasks` after wiping
`build/test-results`.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Fixes a data race that had been producing verdicts about the engine's own timing. `VersionMeta`
refreshes manifests on a background coroutine — B31's ~392 ms startup win — while every `update()` in
`versionmeta` cleared and refilled plain collections that the accessors handed out directly.

A reader got either a `ConcurrentModificationException` or, silently, the empty window between the
`clear()` and the `add()`s. The silent half is the costly one:
`BootVerifier.bootableCombination()` rebuilds its release set from `serverReleases()` on every staging
call, so an empty read fails every candidate and refuses the boot with "No bootable
file/Minecraft/loader combination for <loader>" — a statement about the mod that was never about the
mod. Reproduced with the refresh coroutine in its own stack trace.

All ten classes now build fresh collections and publish each in one assignment to a `@Volatile` field
holding an unmodifiable view. Unmodifiable rather than merely `List`-typed: a `List` field still holds
an `ArrayList` at runtime, and the first attempt at the fix left the pin red for exactly that reason.

**Two further latent bugs fell out of the rewrite**, both recorded in their commits:
`MinecraftClientMeta.update()` never cleared `allVersions`, so every refresh appended the whole
manifest again — unbounded growth on any long-running process, which the grinder is; and
`NeoForgeLoader.update()` reversed the *published* map while iterating it, so a reader could see the
reversal half-applied.

Three published signatures narrowed (`MutableList`→`List`, `HashMap`→`Map`), hence the `!` on the
implementing commit; recorded in `claude-docs/API-BEHAVIOUR-CHANGES.md`.

api 387 → 403; full tree 1347/0.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Answers audit iteration 37.

**HIGH-1 — `BundledJars` no longer spools nested jars to disk.** It wrote each declared nested jar to
`File.createTempFile(...).apply { deleteOnExit() }` and deleted it in a `finally`. The `finally` freed
the disk; nothing freed the *registration* — `deleteOnExit` adds the path to
`java.io.DeleteOnExitHook`'s static set, which never shrinks. This runs per staged jar, per boot
attempt, for every candidate of a catalog sweep, and `sodium` declares nine nested jars, so a daemon
running for weeks accumulated a dead entry per nested jar and a shutdown hook that would eventually
walk tens of thousands of already-deleted paths.

Fixed by removing the spool rather than the `deleteOnExit`: only the nested descriptor is ever read,
and a `ZipInputStream` over the entry's stream gets it with no file at all. The leak and the I/O go
together.

**MED-1 — a superseded `ERROR` keeps its reason.** `propagateClientOnlyProof` overwrote every
non-proof verdict with CONFIRMED, including a loader whose grind was *prevented*. Publishing that
entry is right — the mod is client-only and the entry comes from platform metadata, not from a boot —
but the ERROR vanished from the report, so a host defect stopped being visible on exactly the projects
where a proof happened to exist. The note now says the grind did not run.

**MED-2 — `VersionMeta.update()` is `@Synchronized`.** Each meta publishes a consistent snapshot now,
but nothing serialised `update()` itself, and it is called both from the refresh coroutine and by
callers. Two overlapping runs could leave one meta on generation A beside another on generation B, so
a lookup could miss a version its own release list contained. Uncontended in the normal case, and a
manifest refresh is far too coarse to sit on any hot path.

**LOW-1 — the immutability assertions can no longer pass vacuously.** Both were written as
`if (asMutable != null) { assertThrows(...) }`, so an accessor that stopped presenting as `MutableList`
would have made the test report success while asserting nothing — the defect class iteration 34 found.
The cast is now asserted before it is used.

Full tree 1347/0 across five modules, `--rerun-tasks` after wiping `build/test-results`.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Answers audit iteration 38.

**HIGH-1 — the previous commit's `@Synchronized` did nothing for the case it was written for.**
`VersionMeta.update()` was locked, but the background refresh never calls it: `refreshManifests()`
invokes `minecraft.update()`, `fabric.update()`, `forge.update()` and the rest **directly**. So the
lock guarded the public caller and left the coroutine — the entire reason the finding existed —
unguarded, while the commit message claimed the race was serialised.

That is iteration 34's HIGH-2 shape repeated by me: a guard that looks like it covers a case and
cannot reach it. Both are instance methods of `VersionMeta`, so `@Synchronized` on `refreshManifests`
puts them on the same monitor and actually serialises them.

**MED-1 — the last four fixes are documented where a session will read them.** `BundledJars`,
`provesClientOnly`, the LWJGL/invalid-dist defaults and the snapshot rule existed only in commit
messages. Four landmines a reader is expected to respect — *only declared nested jars count*,
*an unexplained crash is still disprovable*, *ordering was not the problem*, *never hand out live
metadata* — now sit in the module files, with the near-miss mechanisms that make each of them subtle.

**LOW-1 — the two `!!` introduced while removing a vacuous-pass guard are gone**, replaced with
`requireNotNull`, which narrows in one step.

Recorded in the module docs because it is the kind of thing a future fix will get wrong the same way:
locking a public entry point proves nothing about the path a background job actually takes.

Full tree 1347/0.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Answers audit iteration 39, LOW-1. `minecraft.clientSnapshots()` and `minecraft.serverSnapshots()`
return the same snapshots as the three accessors already pinned and were simply not listed. Nothing
was broken; the set was arbitrary rather than reasoned, and an accessor added beside them would have
inherited the gap.

The set is now "every list accessor on `MinecraftMeta`", which is a rule a reader can apply, rather
than a list they have to trust was complete.

api 403 → 405; full tree 1349/0.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
merge: three audit passes over the post-redesign fixes
All checks were successful
Documentation / Writerside webhelp (push) Successful in 1m33s
Continuous / Build JAR (push) Successful in 12m18s
Qodana / scan (push) Successful in 13m1s
Docker Test / build image (push) Successful in 15m49s
Continuous / Build AppImage (x86_64) (push) Successful in 1m56s
Documentation / Help image (push) Successful in 3m10s
Continuous / Build AppImage (aarch64) (push) Successful in 2m25s
Qodana / notify (push) Successful in 10s
Continuous / Build Install4J Media (push) Successful in 8m12s
Continuous / Continuous Pre-Release (push) Successful in 3m37s
Test / build (push) Successful in 26m43s
afb512e00b
Iterations 37-39, each finding fixed before the next ran.

**37** — `BundledJars` spooled every nested jar to a temp file with `deleteOnExit()`, whose static
registry never shrinks: per staged jar, per boot attempt, per candidate, on a daemon that runs for
weeks. Fixed by removing the spool entirely — a `ZipInputStream` reads the nested descriptor with no
file at all. Also: a superseded `ERROR` kept its reason, `update()` took a lock, and two assertions
that could pass vacuously were made unconditional.

**38** — that lock did nothing. `refreshManifests()` calls each meta's `update()` **directly** and
never goes through `VersionMeta.update()`, so the background coroutine — the entire reason for the
finding — was still unguarded while the commit claimed otherwise. Iteration 34's HIGH-2 shape,
repeated. Both now share one monitor. The four preceding fixes were also documented in the module
files, where a session actually reads them.

**39** — no HIGH or MEDIUM. The lock was verified to reach both paths by reading both declarations,
and the race confirmed reachable in production rather than in theory: `VersionRefreshSchedule` is a
Spring cron job calling `update()`, so a scheduled refresh could overlap the startup coroutine. One
completeness nit fixed — the metadata pin now covers every Minecraft list accessor.

Verified against real artifacts, not only fixtures: the streamed reader extracts all nine nested ids
from the live `sodium` jar, and they are exactly the Fabric API modules this repo documents as the
most-commonly-missing dependency class.

Full tree 1349/0 across five modules.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`architectury-api`, `enchantment-descriptions` and `waystones` refused with "weird strings" as the
missing dependency. They are **platform refs**: `unsatisfied.add(dependencyRef)` records whatever
`ModFile.requiredDependencies` holds, which is Modrinth's opaque base62 `project_id` or CurseForge's
bare numeric id.

Measured against the live API:

  - `enchantment-descriptions` requires `uy4Cnpcm` and `aaRl8GiW` — **bookshelf-lib** and **prickle**
  - `waystones` requires `bi4iCmsw` and `MBAkmtvl` — **shogi** and **balm**

**`waystones` is the sharpest demonstration.** Its own `neoforge.mods.toml` declares `balm` and
`shogi` in plain words, and the manifest half of staging reports them that way — while the platform
half reports the very same two mods as `MBAkmtvl` and `bi4iCmsw`. One refusal, two vocabularies, one
unreadable.

Not merely cosmetic: `unsatisfied` is a `Set<String>`, so a mod missing by both routes is **two**
entries today and one once both halves speak slugs.

The unresolved case keeps the ref, because it is all we have, but must say what it is — otherwise a
reader cannot tell an opaque id from a mod whose name simply looks strange, which is the confusion
that produced this report.

Red for the missing `unsatisfiedLabel` only.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`BootVerifier.unsatisfiedLabel` resolves a platform ref to the resolved project's slug, and keeps the
ref plus the platform name only when nothing resolved. Answers the report that `architectury-api`,
`enchantment-descriptions` and `waystones` refused with "weird strings".

The strings were platform refs. `unsatisfied.add(dependencyRef)` recorded whatever
`ModFile.requiredDependencies` held — Modrinth's opaque base62 `project_id`, CurseForge's bare number
— so an operator was handed `uy4Cnpcm` where the mod is called **bookshelf-lib**.

**`waystones` is the case that makes it a defect rather than a cosmetic gripe.** Its own
`neoforge.mods.toml` declares `balm` and `shogi` in plain words, so the *manifest* half of staging
already reported them readably while the *platform* half reported the same two mods as `MBAkmtvl` and
`bi4iCmsw`. One refusal, two vocabularies for one dependency.

**The deduplication is the substantive part.** `unsatisfied` is a `Set<String>`: a mod missing by both
routes was two entries and is now one, so the refusal stops overstating how much is missing.

The unresolved case keeps the ref — it is genuinely all we have — but names the platform, so a reader
can look it up rather than mistake an id for a mod whose name merely looks strange. That confusion is
what produced the report.

The `no <loader> file for Minecraft <version>` warning now logs the slug *and* the ref, since the log
is where someone goes to check the platform page.

Evidence is the live API, resolved on 2026-09-04: `uy4Cnpcm`→bookshelf-lib, `aaRl8GiW`→prickle,
`bi4iCmsw`→shogi, `MBAkmtvl`→balm. Landmine recorded in the module file.

clientside 337 → 341; full tree 1353/0.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
merge: a missing dependency is named, not identified
All checks were successful
Documentation / Writerside webhelp (push) Successful in 1m59s
Continuous / Build JAR (push) Successful in 12m51s
Docker Test / build image (push) Successful in 17m43s
Qodana / scan (push) Successful in 15m59s
Continuous / Build AppImage (x86_64) (push) Successful in 1m44s
Documentation / Help image (push) Successful in 3m30s
Continuous / Build AppImage (aarch64) (push) Successful in 1m49s
Qodana / notify (push) Successful in 12s
Test / build (push) Successful in 15m39s
Continuous / Build Install4J Media (push) Successful in 8m11s
Continuous / Continuous Pre-Release (push) Successful in 3m9s
bd014aec82
Refusals reported Modrinth's opaque `project_id` (or CurseForge's numeric id) instead of the mod's
name — `uy4Cnpcm` for what everyone calls **bookshelf-lib**. `waystones` showed the shape best: its
manifest declares `balm` and `shogi` in words, so one half of staging reported them readably while the
other reported the same two mods as `MBAkmtvl` and `bi4iCmsw`.

Resolved refs are now named by slug, which also collapses the duplicate — `unsatisfied` is a set, so a
mod missing by both routes was two entries and is now one. An unresolved ref keeps the ref and names
its platform, so it reads as a lookup key rather than as a strange mod name.

clientside 337 → 341; full tree 1353/0.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Reported live: *"Required dependency unavailable for Quilt / Minecraft 1.20.4: 306612"* — still a raw
platform ref, where `306612` is CurseForge's id for **Fabric API**, the ref this module already
documents as the most-dropped one.

**The previous fix covered two of three branches.** `downloadWithDependencies` records an unmet
dependency in three places: the ref did not resolve, it resolved but published no usable file, and —
the one missed — it resolved, a file *was* picked, and the download then failed. That third branch
still added the bare ref, and `dependencyProject` is in scope there the whole time.

A second defect sits on the same line. **A distribution-locked dependency is not a download failure.**
CurseForge publishes no `downloadUrl` when an author opts out of third-party distribution, so
`JarDownloader` returns `null` and the dependency reads as "could not be downloaded" — the sentence a
404, a flaky link and a deliberate opt-out all produce. The *candidate* half of staging learned that
distinction when `downloadFailureDetail` was written; the dependency half never did, so an
unobtainable dependency looks like a transient failure worth retrying.

Red for the extended signature only.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Answers the follow-up report, *"Required dependency unavailable for Quilt / Minecraft 1.20.4:
306612"* — CurseForge's id for Fabric API.

**The previous fix caught two of three branches.** `downloadWithDependencies` records an unmet
dependency when the ref does not resolve, when it resolves but publishes no usable file, and when it
resolves, a file *is* picked, and the download then fails. The third still added the bare ref, with
`dependencyProject` in scope the whole time. All three now go through `unsatisfiedLabel`.

**And a locked dependency now says so.** CurseForge publishes no `downloadUrl` when an author opts
out of third-party distribution, so `JarDownloader` returns `null` and the dependency reported as
"could not be downloaded" — the sentence a 404, a flaky link and a deliberate opt-out produce
identically. That is the conflation `downloadFailureDetail` fixed for the *candidate*; the dependency
half never learned it, so an unobtainable dependency looked like something worth retrying. The label
reads `fabric-api (distribution-locked on CurseForge)` and the warning says the same.

Worth stating plainly: I fixed two branches last time and asserted the problem was solved, when a
third was sitting four lines below the two I edited. The pin now covers all three, and the module doc
says how many there are so a fourth gets labelled rather than discovered in a report.

clientside 341 → 344; full tree 1356/0.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
merge: the third refusal branch names its mod, and locked means locked
All checks were successful
Documentation / Writerside webhelp (push) Successful in 2m11s
Continuous / Build JAR (push) Successful in 12m9s
Qodana / scan (push) Successful in 10m19s
Docker Test / build image (push) Successful in 16m28s
Documentation / Help image (push) Successful in 4m59s
Continuous / Build AppImage (x86_64) (push) Successful in 2m24s
Continuous / Build AppImage (aarch64) (push) Successful in 1m44s
Qodana / notify (push) Successful in 14s
Continuous / Build Install4J Media (push) Successful in 7m55s
Test / build (push) Successful in 15m30s
Continuous / Continuous Pre-Release (push) Successful in 4m23s
5b18655579
`306612` still reached a refusal because `downloadWithDependencies` has three places that record an
unmet dependency and the previous fix taught two of them to say the slug. The third — resolved, file
picked, download failed — kept the raw ref.

A distribution-locked dependency also now says it is locked rather than reading as a failed download,
the distinction `downloadFailureDetail` already draws for the candidate. Retrying an author's opt-out
never succeeds, and the report should not imply it might.

clientside 341 → 344; full tree 1356/0.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Chasing the `306612` report to its cause. `BootCandidateSelector.pickForLoader` is
`files.firstOrNull { loader in it.loaders && mc in it.minecraftVersions }` — it never asks whether the
file can actually be downloaded. A distribution-locked file (`downloadUrl == null`, the author's
opt-out) is picked like any other, `JarDownloader` returns `null`, and the dependency is reported
unmet while an obtainable file sits directly behind it.

Two failures, and the second is the one that explains a *Quilt* report specifically:

  - a locked **newer** build beats an obtainable older one
  - a locked **exact-loader** build beats an obtainable Fabric one, so the Quilt-to-Fabric fallback —
    which exists precisely because libraries publish Fabric-only files — never gets reached

Both reproduce; the three guards that protect existing behaviour pass unchanged (exact loader still
beats an obtainable fallback, the version constraint still narrows, and an all-locked project still
yields a file).

That last one matters: when everything is locked the pick must still return something, so the refusal
reads "distribution-locked" — true and actionable — rather than "publishes no Quilt file for Minecraft
1.20.4", which is false. Preference, never filter: the rule this function already follows for version
constraints, and for the same reason — returning `null` where a file exists turns a diagnosable
refusal into a misleading one.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`pickDependencyFile` now prefers obtainable files, and obtainability outranks the loader match.

`pickForLoader` took the first file matching loader and Minecraft version and never asked whether it
had a `downloadUrl`. A distribution-locked build — the author's opt-out, for which CurseForge
publishes no URL — was therefore picked over an obtainable one, `JarDownloader` returned `null`, and
the dependency was reported unmet with an obtainable file sitting directly behind it. That is the
cause behind "Required dependency unavailable for Quilt / Minecraft 1.20.4: 306612".

**Obtainability outranks the exact-loader preference, which is the part worth arguing.** Quilt runs
Fabric mods, so an obtainable Fabric build is a working dependency while a locked Quilt build is
nothing at all. Leaving the loader preference on top let a locked exact match shadow the Quilt-to-Fabric
fallback — a fallback that exists precisely because libraries like Fabric API publish Fabric-only
files. The loader preference still applies among obtainable files, which the pins hold.

**Preference, never filter.** When every candidate is locked the final arm still returns one, so the
refusal reads `fabric-api (distribution-locked on CurseForge)` rather than "publishes no Quilt file
for Minecraft 1.20.4" — the first is true and tells an operator retrying is pointless, the second is
simply false. Returning `null` where a file exists trades a diagnosable refusal for a misleading one,
the same reason the version constraint is a preference in this function.

This is the third layer of one report: the label named the mod, the branch that recorded it was
missed, and this is why the download failed in the first place.

clientside 344 → 349; full tree 1361/0.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
merge: prefer a dependency file that can actually be downloaded
All checks were successful
Documentation / Writerside webhelp (push) Successful in 1m55s
Continuous / Build JAR (push) Successful in 11m57s
Docker Test / build image (push) Successful in 17m34s
Qodana / scan (push) Successful in 16m59s
Continuous / Build AppImage (x86_64) (push) Successful in 2m16s
Continuous / Build AppImage (aarch64) (push) Successful in 1m56s
Documentation / Help image (push) Successful in 5m44s
Qodana / notify (push) Successful in 14s
Test / build (push) Successful in 14m40s
Continuous / Build Install4J Media (push) Successful in 7m16s
Continuous / Continuous Pre-Release (push) Successful in 3m37s
867c0d9ebd
`pickDependencyFile` never asked whether a file had a `downloadUrl`, so a distribution-locked build
was picked over an obtainable one and the dependency was reported unmet — the cause behind
"Required dependency unavailable for Quilt / Minecraft 1.20.4: 306612".

Obtainability now outranks even the exact-loader preference: Quilt runs Fabric mods, so an obtainable
Fabric build is a working dependency where a locked Quilt build is nothing, and a locked exact match
was shadowing the fallback that exists for exactly this. Still a preference — an all-locked project
still yields a file, so the refusal can say "distribution-locked" instead of the false "publishes no
Quilt file".

clientside 344 → 349; full tree 1361/0.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Reported live: "Loader install for Quilt 0.31.0-beta.3 / Minecraft 1.20.6 failed, so the pack could
not be completed." `CachedLoaderVersions.firstInstallableVersion` already walks `availableVersions`
for the first build not on install cooldown — but the supplier returned `emptyList()` for Fabric,
Quilt and LegacyFabric, so there was nothing to walk and the tuple stayed dead for every candidate
wanting it.

**The premise behind that empty list was wrong.** It read: they "ship a single Minecraft-independent
loader line, so there is no sibling build to fall back to". True of *per-Minecraft* builds — Quilt
publishes no 1.20.6-specific loader the way Forge does — but the loader **line** is versioned. Measured
against the live metadata: Quilt publishes **306** builds and Fabric **253**, and Quilt's
`/v3/versions/loader/1.20.6` lists all 306 as valid for that Minecraft. There are 305 siblings.

**Prevention was ruled out before writing this, which is why the fix is recovery.** Every published
source says the failing combination is fine: it is in the per-Minecraft list, the intermediary exists,
and `.../loader/1.20.6/0.31.0-beta.3/server/json` answers 200. The start scripts' own checks are the
same signal `LoaderVersionResolver` already gates on — and Fabric's `server/json` 400 tracks *Minecraft
support*, not the pairing, since an ancient loader with the newest Minecraft still answers 200
(measured: 0.12.12 + 1.21.1 → 200; newest 0.19.5 + 1.12.2 → 400). Nothing in metadata predicts an
installer that fails to run.

Pinned with a **real `LoaderCache` driven through a real failing installer**, so the cooldown under
test is the production one rather than a fake that merely agrees with it. `latestVersion` staying
truthful is pinned too: the support gate and the crash re-check must keep measuring against the real
newest, or a crash on a stepped-down build would be "re-checked" against itself.

Red for the missing `LoaderStepDown` only.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`knownLoaderVersionsNewestFirst` now returns the Fabric, Quilt and LegacyFabric loader lines, so
`CachedLoaderVersions` can fall back when the newest build refuses to install. It returned
`emptyList()` for those three, so "Loader install for Quilt 0.31.0-beta.3 / Minecraft 1.20.6 failed"
had nothing to fall back to and every candidate wanting that tuple took the failure.

**The premise was wrong, not the mechanism.** The empty list read "a single Minecraft-independent
loader line, so there is no sibling build" — which conflates *per-Minecraft builds* with the *loader
line*. Quilt publishes 306 builds, Fabric 253, and Quilt's `/v3/versions/loader/1.20.6` lists all 306
as valid for that Minecraft. There were 305 siblings the whole time.

**Recovery rather than prevention, and that order was established by measurement.** Every published
source calls the failing combination valid — it is in the per-Minecraft list, the intermediary exists,
and `.../loader/1.20.6/0.31.0-beta.3/server/json` answers 200. The start scripts' own checks are the
same signal `LoaderVersionResolver` already gates on, and Fabric's 400 tracks Minecraft support rather
than the pairing (`0.12.12` + 1.21.1 → 200; newest `0.19.5` + 1.12.2 → 400). Nothing in metadata
predicts an installer that fails to run, so there is no pre-check to add.

`LoaderStepDown.newestFirst` filters nothing on purpose. The head of Quilt's line is four consecutive
betas and SPC cannot tell: its manifest reports `release: 0.31.0-beta.3` as well as `latest:` — upstream
marks the beta as the release — so "prefer stable" is not derivable here, and a pre-release filter
could empty the line exactly when the fallback is needed. The caller stops at the first build not on
cooldown, so a longer list costs nothing; that tuple converges on `0.30.1` after the dead betas each
take one cooldown.

Pinned with a real `LoaderCache` driven through a real failing installer, so the cooldown under test is
the production one. `latestVersion` stays truthful, which the pins hold — the support gate and the
crash re-check must keep measuring against the real newest.

grinder 455 → 460; full tree 1366/0.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The step-down that rescues Forge/NeoForge from an uninstallable build was disabled for
Fabric/Quilt/LegacyFabric because they "ship a single Minecraft-independent loader line, so there is
no sibling build". The loader *line* is versioned: Quilt publishes 306 builds, Fabric 253, all listed
as valid for a given Minecraft. Quilt 0.31.0-beta.3 / 1.20.6 failed to install with nothing to fall
back to.

Prevention was ruled out first by measurement — every published source calls that combination valid,
and the start scripts' checks are the same Minecraft-support signal the resolver already applies — so
recovery is the available lever.

grinder 455 → 460; full tree 1366/0.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Answers audit iteration 40, HIGH-1. `FabricQuiltStepDownTest` injected `availableVersions` straight
into `CachedLoaderVersions`, so it never called `knownLoaderVersionsNewestFirst` — the only production
function the fix changed. A grep found **zero** tests reaching it, and every assertion in that file
would have passed before the fix, because it proves `CachedLoaderVersions` steps down when handed a
list and then hands it one itself.

The pin's red was `Unresolved reference 'LoaderStepDown'` — a *compile* error — so the behavioural
assertions were never observed failing, which is what hid it. Third time in this audit series that a
compile-red pin has masked a guard that could not reach its subject.

`knownLoaderVersionsNewestFirst` needs an `ApiWrapper` and cannot be executed in a unit test, which is
the same situation as the joins inside `main` that `GrinderSpcEnvironmentTest` and `ReportBindWiringTest`
assert against the source text. This uses that established pattern rather than inventing a seam.

**Verified by mutation, not by assertion.** Deleting the Quilt branch from the production `when` turns
this red, naming the exact line that went missing; restoring it turns it green. Forge and NeoForge are
held too, so the refactor that routed them through `LoaderStepDown` cannot be silently unpicked either.

Two escaping slips were fixed before this landed: `${'$'}` survived into the Kotlin source in both the
search string and the failure message, so the guard first searched for a literal `$perMinecraft` and
then reported a literal `$wiring`. Both were caught by reading the failure rather than the intent.

grinder 460 → 461.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
merge: hold the loader-line wiring with a guard that can reach it
All checks were successful
Documentation / Writerside webhelp (push) Successful in 2m0s
Continuous / Build JAR (push) Successful in 12m37s
Qodana / scan (push) Successful in 10m59s
Docker Test / build image (push) Successful in 15m27s
Documentation / Help image (push) Successful in 4m0s
Continuous / Build AppImage (x86_64) (push) Successful in 1m38s
Continuous / Build AppImage (aarch64) (push) Successful in 2m33s
Qodana / notify (push) Successful in 14s
Continuous / Build Install4J Media (push) Successful in 7m53s
Test / build (push) Successful in 15m11s
Continuous / Continuous Pre-Release (push) Successful in 4m31s
fa52c0cd01
The step-down pin injected its own `availableVersions`, so it never touched
`knownLoaderVersionsNewestFirst` — the one function the fix changed — and would have passed before the
fix. Zero tests reached it.

Now held by a source-level wiring assertion, the pattern this module already uses for joins that need
an `ApiWrapper` and cannot be executed. Proven by mutation: removing the Quilt branch turns it red and
names the missing line.

grinder 460 → 461; full tree 1367/0.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
**Green from the start, and deliberately so** — this is a characterization test, not a red pin. The
report needs a second, narrower entry than `suggestedEntry`, and `FilenameStemDeriver.deriveStem`
already produces it when handed a single file. Pinning that before building a column on top is the
conventions' "pin current behaviour before restructuring", not a TDD pin with the red omitted.

`iris` is the reported case and shows why one column was not enough. Measured against the live API:

  | loader   | files | published stem   |
  |----------|-------|------------------|
  | Fabric   | 191   | `iris-`          |
  | NeoForge | 42    | `iris-neoforge-` |
  | Quilt    | 143   | `iris-`          |

`suggestedEntry` is the longest common prefix over a project's **whole history**, which is correct for
a `startsWith` fallback list — it has to match every build ever published. iris's oldest Fabric files
are `iris-mc1.16.5-1.0.0.jar`, from before the loader went into the name, so that prefix collapses to
`iris-`. NeoForge kept `iris-neoforge-` only because it has no such history: all 42 of its files carry
the loader.

Sampling one file keeps whatever that file is called, which is the whole mechanism: `iris-fabric-`,
`iris-neoforge-`. And a Quilt row shows `iris-fabric-`, because Quilt boots Fabric builds — the
pattern describes the file, not the label on the row.

**One assumption of mine was wrong and the test corrected it**: I expected
`iris-mc1.16.5-1.0.0.jar` to yield `iris-mc`. It yields `iris-`, because `mc` is stripped as the
Minecraft marker it is. Documented behaviour, now asserted.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`iris` published `iris-` (Fabric), `iris-neoforge-` (NeoForge) and `iris-` (Quilt) — three rows where
two say nothing about which artifact was looked at.

The two columns answer different questions and both are needed. `NamePattern` is the common prefix
over a project's whole history and must stay broad, because `/as-properties` matches it with
`startsWith` and it has to cover every build ever published. `Filename` is derived from the sampled
file alone, so it keeps the loader token history erases — and on a Quilt row it reads `iris-fabric-`,
because Quilt boots Fabric builds and the pattern describes the file rather than the row's label.

Pinned including the two ways this could go wrong:

  - a row with no sampled file renders **blank**, not the historical stem repeated, so the column
    cannot imply an artifact was examined when none was
  - **the published entry is unchanged.** `/as-properties` must keep serving the broad
    `suggestedEntry`; if the narrow pattern leaked into it, a mod would stop being excluded for every
    build the narrow form misses — which for iris is its entire pre-2022 history

Red for the missing `filenamePattern` only.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Reported: iris returns `iris-` (Fabric), `iris-neoforge-` (NeoForge), `iris-` (Quilt) — three rows
where two do not say which artifact was examined, and one names a loader the row does not.

`suggestedEntry` is the longest common prefix over a project's **whole history**, and has to be: it is
what `/as-properties` publishes and the fallback list matches it with `startsWith`, so it must cover
every build ever released. iris's oldest Fabric files are `iris-mc1.16.5-1.0.0.jar`, from before the
loader went into the name, so that prefix collapses to `iris-`. NeoForge kept its token only for want
of such history — all 42 of its files carry it.

So the fix is a second column rather than a change to the first: `filenamePattern` runs
`FilenameStemDeriver.deriveStem` over the **sampled file alone**, where there is no older naming
convention to erode the loader out. `FilenameStemDeriver` needed no change; this is plumbing from
`ClientsideVerifier`'s existing `sample` through `LoaderVerdict` and `GrindVerdict` to one new
`VerdictField`, which both the HTML table and the CSV derive their columns from.

A Quilt row now reads `iris-fabric-`, and that is correct rather than a leak: Quilt boots Fabric
builds, and the column describes the file, not the row's label. It is exactly what a maintainer needs
to check a finding against the platform page, which the broad stem cannot do.

**What is deliberately unchanged:** `/as-properties` still serves `suggestedEntry`. Publishing the
narrow pattern would stop excluding every build the narrow form misses — for iris, its whole pre-2022
history. `theFilenamePatternIsNotWhatGetsPublished` fails the build if that ever swaps.

Four existing assertions name the column set and had to change — two CSV header literals, the
`ReportServer` header prefix, and the renderer's per-column sentinel list. That is the stop-and-flag
signal behaving correctly, and why this is `feat:` and not `refactor:`. The renderer guard got its own
`SENTINELFILENAME` rather than a bumped count, since counting is precisely what it exists not to do.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The `Filename` column is easy to mistake for a better `Name-pattern`, and acting on that would break the
published fallback list quietly — a mod would simply stop being excluded for the builds the narrow
pattern misses, with nothing failing. So the distinction is landmined in the clientside module file
where `LoaderVerdict` lives, summarised in the grinder's report section, and the measurement that
produced it (iris: 191 Fabric files against 42 NeoForge, and why only the latter kept its loader token)
is in the refactor log.

Status-table counts re-derived from this run's `build/test-results/test/*.xml`, not incremented:
clientside 314 → 354, grinder 455 → 465.

No `API-BEHAVIOUR-CHANGES.md` row: `-clientside` is not published to Maven, so `LoaderVerdict` gaining a
defaulted field is not an embedder-visible contract change.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
merge: name the artifact a verdict sampled, not just the pattern it publishes
All checks were successful
Documentation / Writerside webhelp (push) Successful in 1m47s
Continuous / Build JAR (push) Successful in 12m37s
Qodana / scan (push) Successful in 13m9s
Docker Test / build image (push) Successful in 16m11s
Continuous / Build AppImage (x86_64) (push) Successful in 1m43s
Documentation / Help image (push) Successful in 2m58s
Continuous / Build AppImage (aarch64) (push) Successful in 2m34s
Qodana / notify (push) Successful in 14s
Continuous / Build Install4J Media (push) Successful in 8m59s
Test / build (push) Successful in 14m30s
Continuous / Continuous Pre-Release (push) Successful in 3m36s
e3cd87eaa3
iris rendered `iris-` (Fabric), `iris-neoforge-` (NeoForge) and `iris-` (Quilt) — three rows where two
named no loader. The deriver was right: `suggestedEntry` is the common prefix over a project's whole
history because that is what `/as-properties` publishes and matches with `startsWith`, and iris's oldest
Fabric jars pre-date the loader token. So this adds a second column derived from the sampled file alone,
and guards that the broad one is still what gets published.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Red on three of four, with the live values verbatim: `306612` and `P7dR8mSH` where `fabric-api` was
expected. The fourth (an unresolvable ref) passes, which is what proves the fixture is sound rather than
the guard being broken.

Reported twice, and the second report is the interesting one. `unsatisfiedLabel` was written to name a
resolved project by its slug, and `DependencyLabelTest` proves it does — by handing it a `ProjectFiles`
the test built with the slug already correct. Production never builds one of those: **both** platforms'
`resolveDependency` pass `nativeRef` into the `slug` parameter positionally, so the label resolves the
project, reads back the ref it started from, and prints it. The earlier fix was a no-op for the branch
that actually fires.

Measured on the live daemon today, both `ERROR` on CurseForge:

  architectury-api  Quilt / MC 1.20.4   "Required dependency unavailable ... 306612"   (Fabric API)
  waystones         Forge / MC 1.21.11  "Required dependency unavailable ... 531761"   (Balm)

**The lesson is the test boundary, not the bug.** A unit test that constructs the value under test cannot
see a producer constructing it wrongly — the same shape as the loader step-down whose pin injected the
very versions it was meant to prove were fetched. So these drive the real `resolveDependency` with canned
JSON, and `theRefusalNamesTheModAcrossBothPlatforms` asserts the composition: it is the only arrangement
in which a positional-argument slip in either platform fails a test.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Red for the missing parameter. This is the harmful half of the `architectury-api` report — the
unreadable `306612` was cosmetic, this refused a boot that should have run.

`resolveDependency` reads a single page of 50 files, deliberately: a dependency needs *a* usable file,
not a history, and paging every dependency of every candidate would multiply the API key's quota. But it
asked for the newest 50 **unfiltered**, and CurseForge returns those newest-first across every loader and
every Minecraft version. For a library that publishes constantly, the window never reaches back: Fabric
API has well over a thousand files there, so its newest 50 are all current Minecraft.

Live, 2026-09-04: `architectury-api` scored ERROR on Quilt / Minecraft 1.20.4 with *"Required dependency
unavailable … 306612"*. Fabric API has shipped 1.20.4 builds since December 2023 — the file exists; we
asked in the wrong window. And a staging refusal publishes ERROR over whatever the store held.

The fixture reproduces the API's real shape: the unfiltered page holds only current-Minecraft builds and
the older one is reachable only by asking for it.

**`modLoaderType` is pinned as *not* sent**, though the API supports it. Filtering to Quilt would hide
Fabric API's Fabric-tagged files — precisely the fallback `BootCandidateSelector.fallbackLoaders` exists
for, with Fabric API as its canonical case. Version narrows the set; the loader stays in the selector.

Parameters verified against https://docs.curseforge.com/rest-api/ for `/v1/mods/{modId}/files`:
`gameVersion`, `modLoaderType`, `gameVersionTypeId`, `index`, `pageSize`.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Turns DependencySlugTest green. Refusals said `306612` and `531761`; they now say `fabric-api` and
`balm`.

`unsatisfiedLabel` names a resolved project by `ProjectFiles.slug` and always did — but **both**
platforms' `resolveDependency` passed `nativeRef` into that parameter positionally, so the label resolved
the project and read back the ref it started from. The earlier labelling fix only ever helped the two
branches that append something (`(unresolved X project)`, `(distribution-locked on X)`); the plain
resolved case, which is the common one, printed the id.

CurseForge is free: `modNode` is the `/mods/{id}` response already fetched for `websiteUrl`, and the slug
sits in it unread.

Modrinth costs **one extra GET per resolved dependency** — its dependency path fetched only the version
list, and a version object carries no slug. Paid on the dependency path only, deduped within a candidate
by `visited`. It falls back to the ref when the lookup fails rather than losing the project: the slug is
presentation, the files are the functional half, and the ref is a working Modrinth URL, so the fallback
degrades to exactly the previous behaviour. The project URL now uses the slug too, which is the same
defect one field over — a dependency link a human can read.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Turns DependencyFileWindowTest green, and is the half of the `architectury-api` report that actually
cost a verdict.

`resolveDependency` reads one page of 50 files — deliberate, and still right: a dependency needs *a*
usable file, not a history. What was wrong is that it asked **unfiltered**, and CurseForge answers
newest-first across every loader and Minecraft version. A library that publishes as often as Fabric API
(1000+ files there) therefore has nothing but current Minecraft in its newest 50, so a boot on 1.20.4
found no candidate and staging refused — publishing ERROR over whatever the store held, for a file that
has existed since December 2023.

`/v1/mods/{modId}/files` takes `gameVersion`, which is exactly the missing narrowing; parameters verified
against https://docs.curseforge.com/rest-api/. `resolveDependency` gains a `minecraftVersion`, defaulted
null so nothing else has to care, and both call sites already had the value in scope.

**`modLoaderType` is supported and deliberately not sent.** Asking for Quilt returns nothing for Fabric
API and would re-create the same refusal one layer down — `BootCandidateSelector.fallbackLoaders` has to
*see* the Fabric builds to fall back to them, and Fabric API is its canonical case. Version narrows the
set; loader choice stays in the selector, together with the obtainability preference.

Modrinth accepts the parameter and ignores it, with the reason in the doc: its version endpoint returns
a project's whole version list in one response, so there is no newest-N window to fall outside of. The
defect is CurseForge's paging, not the interface's.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Two landmines worth more than the fixes themselves: `resolveDependency` reads a single page and is only
correct once narrowed by `gameVersion`, and `modLoaderType` — which the API does support — must never be
sent, or the Quilt-to-Fabric fallback loses the files it exists to find.

Also records the reusable lesson: a unit test that constructs the value under test cannot see a producer
constructing it wrongly, which is why the first labelling fix passed its tests and changed nothing in
production.

clientside 354 → 362, re-derived from build/test-results.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
architectury-api published ERROR for "Required dependency unavailable ... 306612". Two defects behind
one sentence: both platforms passed the ref into ProjectFiles' slug parameter positionally, so the label
resolved the project and read back the ref; and the dependency lookup read CurseForge's newest 50 files
unfiltered, which for Fabric API is all current Minecraft, so a 1.20.4 boot was refused for a file that
has existed since December 2023.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Red on exactly one case — `anInstallFromDifferentTemplatesIsRebuilt`, expecting 1 install and getting 0.
The other five pass, which is what proves the fixture rather than the guard.

`LoaderCache.isInstalled` compares a cached layer's recorded template digest against the current one, and
`TemplateProvenanceTest` proves it does. **Nothing in `src/main` ever called it:**

    $ grep -rn "isInstalled" src/main/ | grep -v "fun isInstalled"
      >>> no match <<<

`ensureInstalled` decides a cache hit through `markUsed`, which only asks whether the completion marker
exists. So the digest was written on install and never read back, and a start-script template change kept
being served from a layer the old templates produced — the exact failure the mechanism was built to
prevent, and one this module's documentation (and `TemplateProvenanceTest`'s own class comment) described
as already fixed.

**The evidence is the installer call count**, deliberately: it is the only observable that separates
"served from cache" from "installed again", and the one a marker check cannot fake. Asserting on the
marker would have passed against the broken code.

This sits beside `TemplateProvenanceTest` rather than replacing it — that one asserts the decision, this
one asserts the decision is reachable. A unit test of a predicate cannot see a caller that never consults
it, which is the third instance of that boundary in two days.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`ensureInstalled` now asks `isInstalled` — which compares the recorded template digest against the
current one — instead of `markUsed` alone, which only asks whether the completion marker exists.

The provenance machinery was complete and unreachable: `TemplateProvenance.digestOf` computed, the
supplier wired from `GrinderApplication`, the digest written into every marker — and never read back,
because the only reader had no production caller. A start-script template change was served from the
layer the old templates produced, indefinitely.

`markUsed` still runs on a hit: stamping the tuple as used is what keeps it alive against
`evictUnusedSince`, and that is a separate job from deciding whether it may be served.

Two accepted consequences, both deliberate:

  - **`templateProvenance()` is now evaluated on every cache lookup rather than only on install.** In
    production it digests the handful of start-script templates; against a boot measured in minutes it
    does not register.
  - **A rebuilt tuple logs its mismatch twice**, once at the racy fast path and once under the lock. The
    alternative is a second silent predicate beside the logging one, and two ways to answer the same
    question is how the metadata scanners drifted. Once per rebuilt tuple, once per template change.

A provenance miss falls through to the ordinary install path, so it is also subject to the failure
cooldown — correct, since a stale layer is a miss, not a usable install.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Red on the two cases that matter: after `beginPass(2, …)` the counters still read the first pass's work
(1 instead of 0, 6 instead of 1).

`verified`, `failed` and `skippedFresh` are documented on `StatusSnapshot` as "this pass" and are rendered
by `StatusDashboardRenderer` directly beneath `Pass N (M candidates)` — which really is per-pass. They
were neither: three `AtomicInteger`s named `*Total`, incremented for the daemon's whole life, that
`beginPass` never reset. A dashboard therefore read "Pass 12 (25 candidates)" above "Verified 3,140", and
the ratio those two invite is meaningless.

Per-pass is the reading kept because it is what both the documentation and the only rendering of these
numbers already promise, and because it answers the question the block exists for: *is the pass now
running getting anywhere?* A lifetime verdict count is already available, and more accurately, from the
store — `/status` reports it as `verdicts`.

`uptimeSeconds` and `startedAt` are pinned as **not** pass-scoped in the same file, so the reset cannot
grow to cover them.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`beginPass` now zeroes `verified`, `failed` and `skippedFresh`, and the fields are renamed `*ThisPass`
so their scope is stated where they are declared rather than only where they are published.

They were lifetime totals published under per-pass documentation and rendered beneath
`Pass N (M candidates)`, so the dashboard invited a ratio between a whole run's work and one pass's slice.

`startedAt` and `uptimeSeconds` are deliberately untouched: the daemon's uptime is lifetime, and the
guard pins that so this reset cannot grow to cover them. Operators wanting a lifetime count still have
`verdicts` on the same document, which is better than these ever were because the store survives restarts.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Red on seven, green on the two that assert unchanged behaviour.

`from` documents that nothing here throws — "a typo in a unit file should not stop a service that has
verdicts to serve" — and `"abc"` honoured that. `"0"` did not: it parses perfectly, is simply unusable,
and travelled onward to whatever consumed it. The consequences were not uniform, which is why this is
closed in one place:

  - `SPC_GRINDER_WORKERS=0` reached `GrindPool`'s `require`, which `GrindLoop` builds **inside the pass
    loop** — so the daemon started, bound the report port, logged a healthy startup line, then died on a
    message naming `workerCount` rather than the variable the operator set. Under `Restart=on-failure`
    that is a restart loop shaped like a crash.
  - `SPC_GRINDER_INTERVAL=-1` throws nothing at all: the pause is negative, the wake-up instant is already
    past, and the loop paces itself by not pausing — a silent hot loop over the catalogue, and the worse
    of the two precisely because nothing reports it.

Coercion rather than rejection is the deliberate reading of that contract: the value actually used is on
the startup line either way, so an operator who set nonsense sees a default in the log rather than a dead
unit.

Values that legitimately mean something at their boundary are pinned as **kept**: port `0` (any free
port), CPU/memory `0` (uncapped), log budget `0` (keep nothing), and the flush interval's zero.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Three range-checking readers — `intIn`, `longAtLeast`, `capAtLeastZero` — replace the bare
`toIntOrNull() ?: default` on every numeric knob, so a value the daemon cannot use falls back exactly as
`"abc"` already did. `from` still never throws, which is its documented contract.

`capAtLeastZero` also rejects non-finite values: `"NaN"` and `"Infinity"` both parse to a Double and both
reach `ContainerResources.forLimits`, whose `require(cpus.isFinite())` would then stop the daemon at
startup over a typo.

Boundaries that mean something are inside the allowed range and are pinned as kept: port `0` (any free
port), `0` cores or GiB (uncapped), a `0` log budget (keep nothing). The flush interval is untouched,
because negative there already means write-through and is a real choice.

`everyVariableReadIsDeclaredAsAKnob` needed the three new reader names. Its regex alphabet is explicit on
purpose and must stay so — `Knob("SPC_GRINDER_HOME", …)` declares knobs in the same file, so a regex
matching any call with a quoted name would match the declarations and the guard would assert nothing.
That is now stated at the line, since this change is precisely the case that would tempt someone to
generalise it. Its assertions are unchanged; only the set of function names it scans grew.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Red for the missing `awaitWithin`.

`GrinderApplication`'s shutdown hook budgets one `SHUTDOWN_GRACE` window across the whole stop: containers
first, then whatever is left to the workers, floored so an interrupt can still be observed. `close()` was
the one step in that budget that could not expire — it blocked on `Future.get()` with no timeout. The
per-container `stopContainerCmd.withTimeout(...)` bounds Docker's *internal* SIGTERM-to-SIGKILL window,
not the HTTP call that asks for it, so a wedged daemon socket parks the shutdown hook until systemd's
`TimeoutStopSec` fires — the SIGKILL that orphans containers, which is the outcome `close()` exists to
prevent.

Pinned as a pure helper rather than through the engine. [DockerJavaContainerEngine] needs a live daemon and
this module carries no mocking library, so the alternative was hand-writing a stub of an 80-method
interface; the decision that was wrong — *wait for these, but not past here* — needs no Docker at all.

Timing assertions are loose on purpose: what is asserted is the outcome and that it returns nowhere near
the blocked task's own duration. A tight margin would buy nothing but flakiness on a loaded box. One case
pins that four blocked tasks cost **one** budget between them, not one each, which is the shape that
turns a bounded wait back into an unbounded one.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`close()` waits through `awaitWithin(pending, shutdownGrace)` instead of an untimed `Future.get()`, and
says so in the log when the budget expires.

It was the only step in the shutdown hook's budget that could not expire. `stopContainerCmd.withTimeout()`
bounds Docker's internal SIGTERM-to-SIGKILL window, not the HTTP call that asks for it, so an unresponsive
daemon socket held the hook open until `TimeoutStopSec` fired — and that SIGKILL orphans the containers
`close()` exists to collect, turning the safety net into the failure.

**One budget across the whole set, not one per task**, which is the distinction that matters: a per-task
timeout multiplied by the abandoned containers is an unbounded wait wearing a limit. A task still running
when the budget is spent is left to `shutdownNow`; its container keeps the owner label and `reapOrphans`
collects it on the next start, which is the path already designed for a killed run. The warning names that
so an operator reading the journal knows the recovery is automatic.

A task that *threw* counts as finished — the drain cares whether it is still waiting, and the failure was
logged where it happened.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Red with three files named, one per failed write:
`[requeue1672910610270244357.json, requeue17294795878435009850.json, requeue4021562841657233531.json]`

`write` creates a temp file, fills it, moves it over the queue, and wraps the lot in `runCatching` because
a queueing problem must never stop a grind. The swallow is right; what was missing is that a failure
between "created" and "moved" left the temp file where it fell. `JsonVerdictStore` gets away with the same
shape because it writes to a fixed `<name>.tmp` and overwrites its own debris — this one asks for a fresh
random name every call, so failures accumulate one file each, forever, in a directory the reaper is
deliberately not entitled to touch (the queue is operator-authored state, not scratch).

The failure is provoked the only portable way: make the destination a **directory**, so the move can never
replace it. No permission games, no root-only setup, identical on every filesystem.

The other two cases pin what must not change — a failing queue still does not throw, and the successful
path still leaves exactly the queue.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Turns `aFailedWriteLeavesNoTemporaryFile` green — three leaked files became none.

The temp file is now created, used and deleted under `try`/`finally`. After a successful move the delete
is a no-op; after a failure it is the only thing that removes it. `JsonVerdictStore` survives the same
shape because it writes to a fixed `<name>.tmp` and overwrites its own debris — this one asks for a fresh
random name every call, so each failed write left one more file, in the queue directory, which the reaper
is deliberately not entitled to sweep because that is operator-authored state.

The `runCatching` around the whole write stays: a queueing problem must not stop a grind. That contract is
why the debris went unnoticed, not why it was there.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Where two KDoc blocks sit adjacent with nothing between them, Kotlin binds only the second and discards
the first — so six declarations carried documentation the compiler and dokka both threw away, while the
declaration each block described was left undocumented. Against this module's comment-everything rule, and
invisible in review because the prose is right there in the file.

  Grinder.kt                     `grind`'s explanation of `force` -> `grind` (it sat above
                                 `queueBlamedDependencies`, which has its own doc; the module's central
                                 function had none, and the lost paragraph is the one explaining why a
                                 queued grind must bypass the freshness check)
  GrinderApplication.kt          `env` -> `env`
  DockerLoaderInstaller.kt       `readyLine` -> `readyLine` (its doc sat above `installLogName`)
  FallbackPropertiesRenderer.kt  `normalise` -> `normalise`
  ReportServer.kt                `queryParameter` -> `queryParameter`
  VerdictReportRenderer.kt       two blocks that both described `headerCell`, merged into one

Text is moved verbatim except the merge, which is the one case where neither block was misplaced — the
sort-link behaviour and the `<th>`/`SortKey` rationale are both about that function, so they are now one
doc with the page-reset note kept as its own paragraph.

Verified by re-running the detector that found them: zero adjacent-KDoc pairs remain in `src/main`.
Documentation only — no declaration, signature or statement is touched.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Two small things the audit turned up, both the same shape — a name or a lifecycle that reads as correct
until you look twice.

**`store.close()` instead of `store.flush()`** in the shutdown hook. `JsonVerdictStore` declares
`AutoCloseable` and nothing ever honoured it, so the flusher executor was never stopped. `close()` is
`shutdownNow()` then `flush()`, which is strictly the better order here: the scheduled tick can no longer
race the final write. The flush remains the load-bearing half — writes are coalesced, so without it every
verdict since the last tick is lost on an orderly stop — and the failure message still says so.

**`queueBlamedDependencies`' local `store` renamed to `queue`.** It bound a `RequeueStore` over the class's
own `VerdictStore` property, in the one class that holds both, so two reads three lines apart looked like
the same collaborator. Behaviour untouched; the comment says why the obvious name is the wrong one here.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The provenance one above all: a cache-hit check must ask `isInstalled`, never `markUsed` alone, and the
reason it went unnoticed for so long — template changes fail silently, and the only test that could have
caught it asserted the predicate rather than the caller. Recorded with the pin style that does catch it
(installer call count), since a marker assertion passes against the broken code.

Also landmined: the per-pass counters and what must stay lifetime; the knob-coercion contract and which
boundary values are deliberately legal; and that every wait in the shutdown path is budgeted, with the
one-budget-not-one-per-task distinction spelled out.

Grinder count 465 → 490, re-derived from build/test-results.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Template provenance was write-only: LoaderCache.isInstalled compares a cached tuple's start-script digest
against the current one and had no production caller at all, because ensureInstalled decided a cache hit
with markUsed, which only asks whether the marker exists. A template change was served from the stale
layer indefinitely — the failure the mechanism was built to prevent, and one the docs described as fixed.

Also: per-pass counters that were lifetime, two knobs whose unusable values were only caught deep in the
run (or not at all), an unbounded wait that could hold the shutdown hook to TimeoutStopSec, six KDoc
blocks the compiler discarded, a leaked temp file, a shadowed field and an unhonoured AutoCloseable.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Red for the missing `missingRuleIds` and the private `bundledPattern`.

`BootLogClassifier` keeps the ladder's *order* in code and looks each rung's *pattern* up in
`boot-rules.default.json` by id. An id that does not resolve produced `Regex("(?!)")` — matches nothing —
with no log, no error, nowhere. That is a silently disabled rung, and nothing guarded it.

Which rung goes decides how it hurts, and both directions are bad:

  - lose `client-only-class`, `lwjgl-on-a-dedicated-server` or `fml-invalid-dist` and every true positive
    falls through to the bare exit-code rung, which is not decisive — so **nothing is ever published
    again** and the engine merely looks like it found nothing.
  - lose a fair-run guard such as `out-of-memory` or `launch-failure` and host trouble stops being
    excused, so a starved box publishes its biggest mods as clientside. That one is already on this
    engine's record.

The file ships in our own jar, so a rename there is a packaging bug and belongs to the build — not to a
verdict store read weeks later.

The second case gives the guard teeth: without it, `everyRungFindsItsBundledPattern` would pass by
construction if the recording mechanism itself were broken. A bundled file that cannot be read *at all*
stays a separate, deliberate degradation and is not what this pins.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`bundledPattern` records and logs an unresolved rule id instead of quietly returning a regex that matches
nothing. The never-matching fallback stays — the ladder must keep working — but it is no longer invisible.

Two silent paths, not one, and the compiler found the second: `BootRule.regex` is
`runCatching { Regex(pattern) }.getOrNull()`, so a rule that *is* present but carries an uncompilable
pattern also yields `null` and disables its rung exactly like a missing id does. Both are now recorded.

Why it matters more than a missing log line: a disabled decisive rung means every true positive falls
through to the exit-code rung, which is not decisive, so nothing is published and the engine merely looks
like it found nothing. A disabled fair-run guard is the mirror image — host trouble stops being excused
and a starved box publishes its biggest mods as clientside.

`BootLogClassifier` had no logger at all; it has one now, used only here.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Red with the wrong attribution named: expected `client-only-class`, got `operator-note`.

`Classification.firedRule` is documented as "the rule that decided **or annotated**" — a rule stating no
verdict rides along on whatever the ladder settles, deliberately, so its author can see the pattern matched
without it changing anything. `verdictOf` then read `firedRule ?: decidedBy?.ruleId` as the confirming
rule, crediting a rule that explicitly declined to state one.

The verdict itself was never wrong: CONFIRMED is gated on `BootDecision.decisive`, and an annotating rule
cannot change the rung. What it costs is auditability — this engine's stated standard is *a verdict that
cannot name its own evidence cannot be audited*, and the report's Rule column was sending an operator
asking "which rule excluded this mod?" to one that did not. Same family as the install reported as "not
retried" that had just been attempted.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`verdictOf` names the operator's rule as the confirming evidence only when `decidedBy` is
`OPERATOR_RULE` — i.e. when the rule is what decided. Otherwise the rung names itself, which is what
actually settled the verdict.

`firedRule` carries both the deciding rule and one that merely annotated (stated no verdict and rode along
on the ladder's decision, which is a deliberate feature), and the two were indistinguishable here. The
verdict was never wrong — CONFIRMED is gated on the rung being decisive — but the Rule column pointed an
operator at a rule that had declined to state a verdict.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Green from the start and deliberately so — the code was already right; six rungs simply had no position in
the guard that exists to pin position. That is the shape that lets a reorder pass unnoticed, so it is
mutation-verified rather than trusted: hoisting `mixin-apply-failure` above `client-only-class` now fails
with *"the client-class marker must outrank a mixin that could not apply"*, and before this it passed.

Rungs 9, 10 and 12–15 were asserted nowhere in `theGuardOrderIsPinnedAsAWhole`: the decisive pair
(`lwjgl-on-a-dedicated-server`, `fml-invalid-dist`) and the four excuses below them (`sandbox-network`,
`mixin-apply`, `loader-solver`, `runtime-mismatch`). Each excuse is now asserted below both decisive
markers and above the bare exit code, and the decisive pair is asserted to survive a zero exit — the
ServerStarterJar prints FML's refusal in full and exits 0 — while still yielding to a fair-run guard.

The doc block is rewritten because it was wrong in four ways at once: it said "eight ordered guards" while
listing fourteen, the list omitted `lwjgl` and `fml-invalid-dist`, a stray fragment of an older ladder sat
after the closing parenthesis, and the real count is sixteen. It already carried a note about having been
wrong twice; the instruction to re-derive from `classify` is now the first thing it says.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Two adjacent KDoc blocks mean Kotlin binds only the second and discards the first, so seven declarations
carried documentation nothing ever saw while the declaration each described went undocumented.

`BootLogClassifier` had **three** stacked at one point: `BootResult`'s doc and `Classification`'s doc both
piled above `enum class BootDecision`, which has its own — so two public types in the module's most
safety-critical file were undocumented while their prose sat sixty lines away on a third.

  BootLogClassifier.kt   `BootResult`, `Classification`, `clientOnlyClassMarker` -> their own declarations
  BootVerifier.kt        `boot` and `refuseForMissingDependencies` -> theirs
  ClientsideVerifier.kt  `loaderDisprovingTheCrash` -> its own — and this one carried the landmine about
                         checking *whose* boot a SURVIVED belongs to, which dokka was dropping entirely
  BundledJars.kt         a near-duplicate of `idsOfNested`'s doc, superseded by the block below it that also
                         carries the do-not-spool-to-a-temp-file landmine; deleted rather than moved

**`BootDecision.decisive`'s own doc said "exactly two qualify" and there are four.** It listed
`CLIENT_ONLY_CLASS` and `OPERATOR_RULE`, and never followed when `lwjgl-on-a-dedicated-server` and
`fml-invalid-dist` were promoted from examples to shipped defaults — so the doc understated what may
publish a clientside entry by half. Corrected, with the instruction to re-derive it from the constants.

Documentation only; no declaration, signature or statement changed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`render` gates publication on `it.verdict == Verdict.CONFIRMED` alone, and has since the four-verdict
redesign made the decisive-rung check structural. `decisive()` was the old second gate, left behind with a
seventeen-line doc describing a rule it no longer enforces.

Its neighbouring comment already says asking twice "would only invite the two to drift apart" — and a dead
private function carrying the *old* criterion is exactly that invitation, one `git blame` away from being
restored by someone who reads the doc and assumes it is load-bearing. It would also now be **wrong**:
`propagateClientOnlyProof` mints CONFIRMED for loaders that inherit another loader's proof, and those rows
carry their own non-decisive `decidedBy`, so re-deriving decisiveness here would drop exactly the sodium
case the propagation exists to publish.

Behaviour-preserving: the function had no callers, and its `BootDecision` import went with it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The ladder's content lives in a shipped JSON and its order in code, so an id that stops resolving switches
a rung off silently — landmined with both directions of harm, because which rung goes decides whether the
engine stops publishing or starts publishing host trouble.

Two corrections the audit forced, both in claims this file stated confidently:

  - `BootDecision.decisive` marks **four** rungs, not two. It never followed when `lwjgl-on-a-dedicated-server`
    and `fml-invalid-dist` became shipped defaults, so both this file and the KDoc understated what may
    publish an entry by half.
  - the ladder is **sixteen** rungs, not fourteen. That number has now been wrong three times, which is why
    the instruction to re-derive it from `classify` is repeated at both sites rather than the number trusted.

Also records that the order guard now covers all sixteen and is mutation-verified, and that a confirmation
credits only the rule that decided.

clientside 362 → 368, re-derived from build/test-results.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
bundledPattern resolved a missing rule id to a regex matching nothing, silently: lose a decisive rung and
the engine stops publishing while looking like it found nothing; lose a fair-run guard and host trouble
publishes as clientside. Now recorded, logged, and caught at build time.

Six of sixteen ladder rungs had no position in the guard that pins position, so reordering them passed —
extended and mutation-verified. A confirmation credited a rule that had declined to decide. And the
decisive set had grown from two to four without either doc following.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Closes ANALYSIS-AUDIT M-1. Green when written — the mapping was correct — so it is mutation-verified
rather than trusted. Two mutations, both caught:

  filenamePattern = verdict.suggestedEntry        -> "'SENTINEL_FILENAME' was dropped by the mapping"
  declaredClientSide = verdict.declaredServerSide -> "expected: <REQUIRED> but was: <UNSUPPORTED>"

`Grinder.grind` assigns eighteen fields by hand. `GrinderTest` — the only test that drove `grind` and
inspected the store — asserted five. Every report, CSV, query and filter test builds its `GrindVerdict`
through the `grindVerdict(...)` fixture, so none could see a producer filling a field wrongly: the same
boundary that let a dependency-label fix pass its tests while both platforms fed the labeller the wrong
slug.

The unasserted fields were the ones that matter most. **`verdict`** is what `/as-properties` gates
publication on; `declared`, `firedRule` and `decidedBy` are what make a published exclusion auditable;
`filenamePattern` and `detail` are report columns.

Every field gets a **distinct** sentinel, which is the mechanism rather than decoration — equal values
cannot detect a swap, so the two `DeclaredSupport` fields deliberately take different constants and no two
enums share a name.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Closes ANALYSIS-AUDIT M-2. Green when written, so mutation-verified: swapping arms 2 and 3 of
`pickDependencyFile` fails with `expected: <lib-0.9.0.jar> but was: <lib-1.5.0.jar>`.

The selector narrows four times — satisfying-and-obtainable, obtainable, satisfying, anything. Every
existing test varied one axis at a time: locked-versus-obtainable with no constraint in play, and
constraint-narrowing with nothing locked. The middle pair was therefore never separated, and swapping
them passed the suite.

An obtainable file must win even when the locked one is the only version the constraint accepts: a locked
file has no `downloadUrl` at all, so picking it guarantees the dependency is reported unmet, while a
version the constraint dislikes at least stages and boots. That is the `306612` / Fabric-API refusal fixed
on 2026-09-04, one layer down — and a staging refusal publishes ERROR over whatever decisive verdict the
store held.

The second test is the counterweight: with both files obtainable the constraint decides again, so
obtainability reads as the stronger preference rather than the only one.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Closes ANALYSIS-AUDIT L-1. `FilenameStemDeriver.deriveStems` (plural) had **no production caller anywhere
in the repo** — the singular `deriveStem` is used at three sites — while one test exercised it and two
KDoc blocks cited it as authoritative.

That citation is why this needed care rather than a delete. `ClientsideVerifier` and
`ClientsideVerifierCrossLoaderTest` both point at it as "the shape `deriveStems` documents", meaning the
`sodium-fabric-` versus `embeddium-` divergence — so the explanation lived on the one function nothing
ran. It now lives on `deriveStem`, which is what actually produces those stems, with the consequence made
explicit: that divergence is *why* `loaderDisprovingTheCrash` compares entries rather than loaders.

Same species as the grinder's `FallbackPropertiesRenderer.decisive()` removed earlier today — dead surface
that reads as load-bearing because a comment vouches for it, one `git blame` from being restored by
someone who trusts the doc.

Behaviour-preserving: no production call site existed, and the orphaned test went with it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Appends the resolution section to `ANALYSIS-AUDIT.md` (it is an accumulating evidence log, so earlier
sections stay), with the mutation results for each green-written guard — the reason those matter is that a
guard added green and never mutated is indistinguishable from one asserting nothing.

Also records the `REFACTOR-AUDIT.md` sweep as a table of where each candidate was verified closed, so the
four are not re-litigated: OBS-1's QSL rule, iteration 38's `!!`, iteration 39's snapshot accessors, and
iteration 40's wiring guard.

Status-table counts re-derived from build/test-results rather than incremented — the api figure had been
stale at 387 against an actual 405.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
M-1: Grinder.grind assigns 18 fields by hand and 5 were asserted end to end; every report/CSV test builds
its GrindVerdict through a fixture, so the producer was untested by construction. Now pinned with a
distinct sentinel per field, mutation-verified.

M-2: pickDependencyFile's arms 2 and 3 were never separated — obtainability versus the version constraint.
Swapping them passed the suite; it now fails.

L-1: deriveStems had no caller but carried the sodium-fabric/embeddium example two files cite as
authoritative. Deleted, with the explanation moved onto deriveStem.

The four candidate open items in REFACTOR-AUDIT.md were checked against the code and are all already
closed; the table in ANALYSIS-AUDIT.md records where, so they are not re-litigated.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
docs: complete the Refactor-state migration whose other half was committed by mistake
All checks were successful
Continuous / Build AppImage (aarch64) (push) Successful in 2m37s
Test / build (push) Successful in 14m35s
Continuous / Build Install4J Media (push) Successful in 7m33s
Continuous / Continuous Pre-Release (push) Successful in 3m6s
Documentation / Writerside webhelp (push) Successful in 2m3s
Qodana / scan (push) Successful in 8m18s
Continuous / Build JAR (push) Successful in 13m3s
Docker Test / build image (push) Successful in 14m30s
Qodana / notify (push) Successful in 16s
Continuous / Build AppImage (x86_64) (push) Successful in 2m15s
Documentation / Help image (push) Successful in 4m21s
7e484cc7ca
The `/doctor` run moved the `clientside` and `grinder` cells out of the root `CLAUDE.md`'s always-loaded
*Refactor state* table and into the two module files, which load only when working in those modules —
21,637 chars, ~5,400 est. tokens back in every session, and it took the root file from 52,186 to 30,549
chars, under the ~40,000-char large-memory-file warning threshold.

Those edits were deliberately left uncommitted for review. A `git add CLAUDE.md` in the preceding docs
commit — made to update stale test counts in the same table — swept the root half in with them, leaving the
migration split across a commit and the working tree: the cells removed from the root file, but the text
they were moved to still unstaged. This commits the destination half so the two agree.

Nothing is pushed (`origin/develop` is 34 commits behind), so this is recoverable either way; completing
the move was preferred over rewriting the merge for a documentation file. To undo the migration entirely:
`git revert` this commit and restore the two cells from `/tmp/root_CLAUDE.md.bak`, or ask.

Each cell was moved **verbatim** under a clearly-labelled heading rather than diffed against the sections
above it, so nothing could be lost — expect it to restate them in condensed form.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The report server has no machine-readable feed that keeps a verdict's shape:
/export.csv flattens every field to a string, so stagedDependencies arrives
comma-joined and has to be re-split by the consumer. VerdictField, VerdictQuery
and VerdictSelection are internal to this module, so nothing outside it can
reuse the selection — it has to travel over the wire.

Six guards, red before the endpoint exists. Five fail because /verdicts.json
falls through to "/" and is served the HTML table; the sixth
(leavesTheStatusDocumentUntouched) is green by design — it pins that the mapper
change /verdicts.json needs stays inert for the endpoint operators script.

Two guards are worth naming. The timestamp one pins verifiedAt as an ISO string:
ReportServer's mapper is a bare jacksonObjectMapper() with no JavaTimeModule,
which writes an Instant as {"epochSecond":…,"nano":…} — parseable, but not a
timestamp any client recognises, and not what JsonVerdictStore writes to disk.
The agreement one asserts the JSON and the CSV return identical rows across four
queries, so the two renderings agree because they share
VerdictSelection.select, not because two row-pickers were kept in step by hand.

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

The route reuses VerdictQuery.parse + VerdictSelection.select — the same two
functions "/" and /export.csv call — so the three renderings agree because only
one of them picks rows, not because three row-pickers were kept in step. Same
q / f.<field> / sort / dir / page / size spelling everywhere, and defaultSize =
null so a bare call is unpaged, matching the documented /export.csv behaviour.

What it buys over the CSV is shape. The CSV flattens every field to a string, so
stagedDependencies arrives comma-joined; here it stays an array. That matters
because VerdictField, VerdictQuery and VerdictSelection are internal to this
module — a consumer outside it cannot reuse the selection and would otherwise be
re-parsing an export meant for a spreadsheet.

The shared mapper gains JavaTimeModule and loses WRITE_DATES_AS_TIMESTAMPS, the
same configuration JsonVerdictStore already uses, so verifiedAt is an ISO-8601
string on the wire exactly as it is on disk. /status writes only primitives and
is unaffected — pinned rather than reasoned about, since reshaping a neighbouring
document is exactly how a shared-mapper change goes wrong.

Full grinder suite green.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A pf4j plugin module modelled on serverpackcreator-plugin-example: kotlin and
dokka conventions, kapt(pf4j) for the extension index, the `pluginArtifact`
consumable configuration the root build copies from, plugin.toml expansion
through processResources, and the Plugin-* jar manifest attributes.

Depends on :serverpackcreator-api alone. A plugin compiles against the published
API surface; depending on -clientside or -grinder would tie this jar to modules
that are not published and churn freely. Everything it needs from the grinder
arrives over HTTP as JSON, which is what the previous commit's /verdicts.json is
for.

Two things worth stating rather than leaving to be rediscovered:

The plugin id is "grinder" and must stay so. ApiPlugins stores a plugin's
configuration as <pluginId>.toml in SPC's plugin-configs directory and only
extracts the shipped config.toml when that file does not exist, so a renamed id
orphans every user's saved selection.

copyPluginsApiUnitTests keeps copying the EXAMPLE plugin alone, and now says why
in a comment at the point somebody would add the second one: ApiPluginsTest
loops over every jar in the api test-resources plugins directory and asserts each
provides all six extension types. This plugin provides two, so adding it there
turns that suite red. The grinder plugin gets its own resolvable configuration
and reaches only the app's manual-test directory.

Verified: `./gradlew :serverpackcreator-plugin-grinder:jar` produces a jar whose
plugin.toml expands to id/name/description/author/version and which carries
META-INF/extensions.idx.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Red: every failure is an unresolved reference to a type this commit does not
yet create (GrinderUrl, GrinderClient, FetchResult, SelectionStore,
SelectionPane and the read model's properties). The one non-symbol error, a
missing get-operator at GrinderClientTest.kt:203, is a knock-on of
FetchResult.value being unresolved and goes with it.

Running the pins before committing them caught one fixture fault worth naming,
since it is exactly the failure mode the convention exists for: the config
fixture called TomlFormat.instance().createParser().parse(File), an overload
that does not exist, so the "red" would have been the test's own compile error
rather than the missing implementation. It now parses through
TomlParser().parse(file, FileNotFoundAction.THROW_ERROR, UTF_8) — the same call
ApiPlugins.registerPluginConfig makes.

Three things these guards fix in place:

GrinderClient is pinned against a real loopback HttpServer, not a mock, because
what is under test is behaviour at a socket — a refused connection, a 502 from a
proxy in front of a stopped daemon, a 200 carrying HTML. The governing rule is
that none of them throws: this client runs on a Swing worker and on the
generation path, where an escaped exception is a dead tab or an aborted server
pack, and a Failed carrying a reason is a sentence the operator can act on. It
also has to tolerate fields it has never heard of, since the daemon is updated on
a different schedule than the plugin.

SelectionStore is pinned against the config.toml this module actually ships,
parsed by SPC's own parser, so a key renamed in one place and not the other
fails here rather than at a user's next generation. Two behaviours are decided
rather than left to emerge: an entry the grinder no longer reports stays ticked
(pruning it would silently un-exclude a mod the user chose to exclude), and a
blank entry is refused (an empty exclusion matches every mod name under
startsWith/contains, which would empty a server pack's mods directory).

GrinderUrl exists so the Settings pane and the client resolve an address through
one rule, rather than the pane calling something valid that every fetch then
misses.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Turns the previous commit's 21 guards green. No new compiler warnings from this
module.

GrinderClient walks responses as a JSON tree rather than binding them to a
class. A plugin is updated on the user's schedule and the daemon on the
operator's, so a field this build has never heard of has to be ignorable rather
than a parse failure the user reads as "the grinder is broken". Nothing escapes
as an exception — an unusable address never reaches the network, a non-2xx
carries its status into the reason (the one fact that separates "daemon down"
from "something answered instead"), and an InterruptedException restores the
flag before returning, because a SwingWorker cancels by interrupting.

GrinderVerdict is the plugin's own read model rather than the daemon's
GrindVerdict, which lives in a module that is neither published nor a dependency
of a plugin. Its exclusionEntry deliberately offers only suggestedEntry, the same
field FallbackPropertiesRenderer publishes; filenamePattern is not a fallback,
because it is a regex over a filename and SPC's default matching mode is not
regex, so offering it would silently exclude nothing.

SelectionStore is a typed view over the CommentedConfig ServerPackCreator owns —
no state of its own, so it is cheap to construct wherever one is in hand. That
config object is the mechanism the whole feature rests on: ApiPlugins hands the
same instance to the tab and to the pre-generation extension, so a tick reaches
generation without a save, while saveConfiguration() carries it across a restart.
Every read tolerates a damaged value, because the file is hand-editable and this
object is constructed on the generation path, where throwing over a typo would
abort a server pack.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Red: seven unresolved references to ClientsideEntryInjector and two to
GrinderPreGenExtension, plus the type-inference errors that follow from an error
type sitting opposite emptyList() in an assertion. Nothing else.

GrinderPreGenExtensionTest pins the real extension point rather than a helper
standing in for it. ServerPackHandler.run calls runPreGenExtensions(packConfig,
…) and then reads packConfig.clientMods to compile the mod list, so mutating
that list there is what actually excludes a mod — and because the hook lives
inside ServerPackHandler.run it fires for the GUI, the CLI and the web backend
alike, which is how a headless run honours ticks made in the GUI. That is worth
pinning against the genuine `run` signature, so mockk arrives as a test-only
dependency for the three collaborators this extension never touches.

Two of the guards are about damage rather than the happy path. Idempotence:
generation runs repeatedly against one live plugin config, and a list that grew
by a copy of the selection each time would be visible to the user, since it is
the same list the GUI field shows. And a missing plugin configuration — what
ApiPlugins hands over when it could not parse the file — has to leave generation
alone rather than abort it.

ClientsideEntryInjector is a pure function over two lists precisely so it can be
pinned this hard: it is the only place where a mistake silently removes mods from
a server pack, or silently fails to. Case-insensitive de-duplication because
SPC's own matching is, order preserved because the exclusion list is applied in
sequence and re-sorting a user's list is not this plugin's business, and blanks
refused because an empty entry matches every mod name under startsWith/contains.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Turns the previous commit's guards green; the module's suite is 33 tests, no
failures, no new compiler warnings.

GrinderPreGenExtension is where the plugin actually changes a server pack.
ServerPackHandler.run calls runPreGenExtensions and then reads
packConfig.clientMods to compile the mod list, so appending there is what
excludes a mod — and because the hook sits inside run() rather than in the GUI,
one selection covers the GUI, the CLI and the web backend. The selection is read
out of the plugin configuration, the same CommentedConfig instance ApiPlugins
hands the tab, so a headless run honours ticks made in the GUI.

Nothing it does is destructive. The user's own list keeps its contents and its
order, entries are only appended, and serverpackcreator.conf is never written —
unticking an entry puts the next generation back exactly as it was. An absent
plugin configuration, which is what ApiPlugins provides when the file could not
be parsed, is a no-op: an exclusion the user cannot see is not worth aborting a
server pack over.

ClientsideEntryInjector is the merge itself, kept a pure function over two lists
because it is the only place here where a mistake silently removes mods from a
server pack or silently fails to.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Red: every failure is an unresolved reference to VerdictTableModel or one of its
members. Headless — an AbstractTableModel needs no display, so no Swing component
is instantiated here.

The model is pinned and the rendering is not, matching this project's stance on
tables elsewhere: trivial format lambdas against brittle component assertions.
But everything a user can get wrong by clicking is decided in the model, so it
gets guards.

Four of them encode decisions rather than mechanics. A row the grinder gave no
name-pattern for is not editable, because rendering it as an ordinary unticked
box invites a click that silently does nothing. Deselect-all clears only the rows
that table shows, since the two panes hold different lists over one saved
selection and clearing Confirmed must not untick anything in Other Verdicts. A
refresh replaces rows but never the selection — the same rule SelectionStore
keeps, so an entry the grinder stopped reporting stays excluded. And assigning
the selection wholesale, which is what loading a saved configuration does, must
not fire the change callback, or the tab writes its config on every refresh.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Turns the table-model guards green. Module suite 44 tests, no failures, no new
compiler warnings; the built jar's META-INF/extensions.idx carries both
GrinderTabExtension and GrinderPreGenExtension.

A TabExtension contributes exactly one tab, so Confirmed / Other Verdicts /
Dashboard / Settings are a nested JTabbedPane inside GrinderTab.

The two list panes are one class with different rows and a different banner, over
one shared selection — the split into "proven" and "everything else" is a
statement this interface makes to the user about risk, not a distinction a server
pack generation observes. A JTable rather than a column of checkboxes, and a
TableRowSorter rather than rebuilding the model, because a mature grinder holds
thousands of verdicts. The filter quotes its input, so an operator typing "c++"
gets a search rather than a PatternSyntaxException.

The Dashboard reads /status, not /dashboard: that page is an HTML shell whose
numbers arrive from JavaScript, and Swing's HTML renderer executes none. The
fields are the ones StatusDashboardRenderer.READ_FIELDS names, read defensively —
this points at a daemon the user upgrades independently, so a reshaped field
renders as an em dash rather than emptying the tab. The worker rows follow the
daemon's actual WorkerSnapshot (worker/platform/slug/busySeconds), which was
worth checking rather than guessing; the first draft invented name/subject.

Threading is a plain SwingWorker with results applied on the EDT. No coroutines:
a plugin cannot reach ServerPackCreator's lifecycle-cancelled scopes, and
GlobalScope is the anti-pattern this project spent a sprint removing from its own
GUI. The Swing Timer that drives the Dashboard fires on the EDT and only starts
the worker, so no request ever runs there.

One landmine found by the compiler and worth keeping named: inside a
JButton.apply { } the identifier `model` resolves to the button's own ButtonModel
and silently shadows the pane's table model. The bulk-select listeners now call a
named method instead.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Red, and red for the defect rather than for itself: 4 tabs where 2 are correct,
and each plugin handed both plugins' TabExtensions.

ApiPlugins.getAllExtensionsOfPlugin(plugin, type) ignores its `plugin` argument
and delegates to pluginManager.getExtensions(type), which answers with every
plugin's extensions. Its callers iterate plugins and call it once each, so the
result multiplies: addTabExtensionTabs adds every tab once per installed plugin,
and runPreGenExtensions / runPreZipExtensions / runPostGenExtensions /
runConfigCheckExtensions run every extension that many times.

With one plugin installed the defect is invisible — one times one is one — and
until this branch added a second plugin this repository shipped exactly one. That
is why it survived. Reproduced 2026-09-06 by running ServerPackCreator with the
example and grinder plugins side by side: the tab strip read
"Grinder | Tetris | Grinder | Tetris".

Two plugins are the whole point of the guard, so it builds the second one at
runtime by cloning the example jar under a new id rather than checking in a
second fixture that would then need maintaining. The id is rewritten in both
places that carry it — the jar manifest, which pf4j's descriptor finder reads,
and plugin.toml, which ServerPackCreatorPlugin reads for its own fields — since
rewriting one leaves a plugin whose two identities disagree.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
getAllExtensionsOfPlugin(plugin, type) accepted its `plugin` argument and threw
it away, delegating to pluginManager.getExtensions(type) — every plugin's
extensions, for whichever plugin you asked about. Passing the plugin id turns the
previous commit's guards green: 2 tabs instead of 4, one TabExtension per plugin
instead of two.

Every caller loops over the installed plugins and asks once per plugin, so the
wrong answer multiplied. addTabExtensionTabs added each tab once per installed
plugin; runPreGenExtensions, runPreZipExtensions, runPostGenExtensions and
runConfigCheckExtensions ran each extension that many times. A ConfigCheck
extension reporting an error reported it N times.

It survived because one plugin times one plugin is one, and this repository
shipped exactly one plugin until this branch. It surfaced on 2026-09-06 the first
time two were installed together, as a tab strip reading
"Grinder | Tetris | Grinder | Tetris".

This is a behaviour change on the published API, so it has a row in
claude-docs/API-BEHAVIOUR-CHANGES.md. An embedder with one plugin sees nothing.
One with several sees each generation extension run once rather than N times —
which is the contract the method's name always claimed — and a plugin that had
come to rely on reaching another plugin's extensions through this call will no
longer see them.

Full -api suite: 407 tests, 0 failures. ApiPluginsTest, which loops over every
installed plugin, still passes.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Running the module's suite creates a `tests/` ServerPackCreator home — log4j2
initialises against it the moment an extension asks for the AddonsLogger — with
logs, manifests and a plugin-configs directory under it. Every other module
already has this pair of rules; the new one was missing them, so the whole home
showed up as untracked after the first test run.

Same shape as -app, -clientside, -grinder and -plugin-example: ignore the
contents, keep the .gitkeep so the directory itself survives a clone.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
New serverpackcreator-plugin-grinder/CLAUDE.md carries the module's own state:
how the shared CommentedConfig makes a tick reach a headless generation, why
suggestedEntry is the only field that becomes an exclusion entry, why a selection
is never pruned, and the copyPluginsApiUnitTests landmine.

Root CLAUDE.md gains the module-map entry and a refactor-state row, and the api
row moves 405 → 407 for ExtensionScopingTest. The "current phase" section records
two things that outlive the module's own docs:

The extension-scoping bug, with the lesson generalised — a defect whose
multiplier is the count of something the repository only ever has one of cannot
be found by testing what the repository ships. It took installing a second plugin
to see it, and it had been there all along.

And an open, pre-existing defect this work surfaced but did not cause: the
*example* plugin dies with a StackOverflowError in CustomPluginFactory when
started in CLI mode, because its init calls ApiWrapper.api() re-entrantly.
Generation still completes and the GUI path is fine. Confirmed pre-existing by
reproducing it against develop's unmodified ApiPlugins, and written down because
it appears in any CLI log with plugins installed and reads like a regression.

The grinder's module and report-subsystem CLAUDE.md files gain /verdicts.json:
why it exists (the selection is internal, so it must travel over the wire, and
the CSV flattens every field to a string), that it shares VerdictSelection.select
with the other two renderings, and the shared-mapper landmine — ReportServer's
mapper is used by /status too and needed JavaTimeModule for an Instant to be a
timestamp rather than an epoch object.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
495 → 501, the six VerdictsJsonEndpointTest guards; 29 skips unchanged.
Re-derived from serverpackcreator-grinder/build/test-results/test/*.xml after a
run of every affected module.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This section was already written and uncommitted when the grinder-plugin branch
started — an audit of the 2026-09-05 `develop` commits, verifying the pin-first
boundary by checking out every pin in a detached worktree and running it.

It is committed on its own rather than alongside the iteration-42 entry it has
nothing to do with. Iteration 41's own MED-1 is a commit that "carried an
unrelated edit and left it split", so folding this into another commit's diff
would have reproduced the finding it records.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Two accumulating logs, appended not overwritten.

REFACTOR-AUDIT iteration 42 finds no HIGH: there is no `refactor:` commit on the
branch to have mixed behaviour into, no module boundary crossed, and the one
changed plugin-API contract is labelled `fix:`, pinned red in its own commit and
recorded in API-BEHAVIOUR-CHANGES.md. The pin-first boundary held on all five
pairs, and `bbfdf42f1` is the strongest of them — it fails on a real assertion
(4 tabs where 2 are correct) rather than on a compile error.

Four MEDIUM findings, two of them defects in shipped code: the selection-pane
attribution in GrinderTab files every stale entry as CONFIRMED, and Swing renders
grinder-supplied text as HTML. The other two are missing guards over correct
code.

ANALYSIS-AUDIT carries the coverage map, the edge cases the existing guards miss,
and the security pass. Both reports cross-reference rather than restate.

One finding is WITHDRAWN in the same entry, and the withdrawal is the more useful
half. LOW-1/A-5 claimed ClientsideEntryInjector's `lowercase()` was
locale-sensitive; the guard written for it was green on first run. Measured under
a Turkish default locale: Java's `toLowerCase()` gives `ıceberg-`, but Kotlin's
`lowercase()` — which exists precisely because of that — compiles to
`toLowerCase(Locale.ROOT)` and gives `iceberg-`. The audit reasoned from the Java
API and attributed its behaviour to the Kotlin one.

That is this log's own recurring lesson one level up: an audit is unpinned
reasoning, and a finding from it is indistinguishable from a real defect until
something executes it. Writing the guard before the fix is what caught it. In the
other order, Locale.ROOT would have been added, the guard would have passed, and
a non-bug would sit here recorded as fixed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Red, and the reds are of two different kinds — worth separating, because only
the second kind proves a defect exists.

Compile-red, for logic that has to be extracted before it can be tested at all:
SelectionAttribution (7 guards), PlainTextRendering (4) and StatusFormatting
(7). Every failure is an unresolved reference to one of those three types, plus
the inference errors that follow an error type sitting opposite `emptySet()`.

Assertion-red, the one that demonstrates a live bug:
`reportsAWrongShapedDocumentRatherThanNoVerdictsFound` fails with
`expected Failed, got Ok(value=[])`. A 200 carrying valid JSON that is not a
verdict document currently reads as "no verdicts found", which an operator
cannot tell from a grinder that has genuinely ground nothing — and the guard
that names this hazard only ever covered non-JSON.

SelectionAttributionTest is the important one. It pins which config key each
ticked entry is written under, logic that was buried in a Swing class and
therefore untested, and it is wrong: `partition { it in shownInOther }` files an
entry shown in *neither* pane as CONFIRMED, and the module's deliberate
never-prune rule guarantees such entries accumulate. Every tick silently
reclassifies what the user accepted at their own risk as a proven finding.

PlainTextRenderingTest pins that grinder text is never parsed as HTML, with a
control guard asserting Swing *would* otherwise have parsed it — without that,
the other three assert a null property for reasons unrelated to the fix.

Green on first run, and kept as coverage rather than as regression pins: the two
`/verdicts.json` paging guards, `requestsTheDocumentedEndpoints` (the fixture
serves "/" and so matched every path — nothing proved the client asked for the
right one), the bare-array and empty-list client guards, the non-tick column
class, the negative poll interval, and GrinderTabExtension's identity.

The locale guard was also green, and that is a withdrawn finding rather than
coverage — see the correction in claude-docs/REFACTOR-AUDIT.md. Its doc comment
now states what it actually proves.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Turns the previous commit's compile-red and assertion-red guards green.
plugin-grinder 44 → 69 tests, api 407 → 409, grinder 501 → 503, app 149. Zero
failures, and no compiler warning from this module.

MED-1, the defect. SelectionAttribution now owns which config key each ticked
entry is written under, and it keeps a stale entry — one neither pane is showing
— under the pane it was saved in. The old `partition { it in shownInOther }`
filed every such entry as CONFIRMED, and the module's deliberate never-prune rule
guarantees they accumulate, so every tick was quietly reclassifying what the user
accepted at their own risk as a proven finding. What a pane *shows* still wins
over what was stored, which is how a re-ground verdict moves lists; an entry that
is neither shown nor stored goes to the side that warns.

MED-2, the security finding. PlainTextRendering builds the labels and the table
cell renderer with `html.disable`, and every component carrying grinder- or
daemon-supplied text now goes through it: all eight verdict columns, the worker,
crawl, boot-rule, rule-error and loader-cache lines, the dashboard card values
and both status lines. Measured, headless: a JLabel and a DefaultTableCellRenderer
both install an HTML view for a string starting with `<html>`, and Swing's HTML
subset fetches remote images — so a mod name was enough to make a user's window
issue a request. The grinder's own web report was hardened against this same
input class; the Swing surface had reintroduced it.

A-3. A 200 carrying valid JSON that is not a verdict document is now Failed
rather than Ok(empty), which an operator could not tell from a grinder that had
ground nothing. readVerdicts returns null for that; an empty `verdicts` array
still reaches the success branch, so a genuinely empty grinder is unchanged.

MED-3. ExtensionScopingTest gains the half that costs something — an extension
running once per installed plugin rather than once. Written after the fix, so it
was verified red by reverting the one-line change: all four guards then fail with
4 where 2 is correct.

LOW-2/3, efficiency: the pane summary is a set intersection instead of
selection × rows on every filter keystroke, and getValueAt reads exclusionEntry
once per tick cell instead of twice.

LOW-4/5/7, tidying: the unused JsonNode import, the dead SettingsPane.isUsable
(GrinderTab already asks the same question through resolvedUrl), and
copyExamplePluginsToApp → copyPluginsToApp, which has taken two plugins since the
scaffold commit.

LOW-6: the dashboard Timer stops in removeNotify and resumes in addNotify, so an
unattended ServerPackCreator no longer polls its grinder forever.

Also fixed, and not in the audit because it was found by re-running the check the
audit did not repeat: getColumnClass used `java.lang.Boolean::class.java`, which
warns "not recommended for use in Kotlin". `Boolean::class.javaObjectType` is the
same boxed class without the warning — and still not `Boolean::class.java`, which
is primitive boolean.class and has no JTable renderer.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Both logs get a resolution section: every finding closed, LOW-1 withdrawn, and
the re-verification against real runtimes rather than only the suite — one
Grinder tab and one Tetris tab in the running GUI, where the same strip read
"Grinder | Tetris | Grinder | Tetris" before the ApiPlugins fix.

Four of the fixes became landmines in the module's own CLAUDE.md, because each
is a rule a future edit could quietly break: every component showing text from
outside the plugin comes from PlainTextRendering; pane attribution is
SelectionAttribution's decision and is *not* "whichever pane shows it"; a
wrong-shaped 200 is a failure rather than an empty list; and getColumnClass
returns javaObjectType, since Boolean::class.java is primitive boolean.class and
has no JTable renderer.

Counts re-derived from build/test-results after a run of every affected module:
api 409, grinder 503, plugin-grinder 69, app 149.

One process note is recorded rather than smoothed over. The audit did not find
the compiler warning in getColumnClass — the clean-warnings check had been run
after the core commit, before the GUI commit existed, and was never repeated. A
clean-warnings check is only worth what its most recent run covered.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Red on the first assertion: 1.20.4 should satisfy '[1.20.3],[1.20.4]'.

Found by reading the ERROR verdicts on the public grinder
(https://grinder.serverpackcreator.de) rather than by inspection. Two of its 54
ERROR rows are this bug: `distanthorizons` declares `[1.20.3],[1.20.4]` and was
refused for a 1.20.4 pack, `mru` declares `26.2,26.3` and was refused for a 26.2
pack. Executed against the parser before writing anything, both constraints
refuse *every* version they list, and Maven's own documented union example
`(,1.0],[1.2,)` refuses everything — the union form was never satisfiable.

One comma does two jobs. Inside a bracketed range it separates lower bound from
upper; between ranges it separates alternatives. mavenRangeHolds assumes the
first reading unconditionally, so `[1.20.3],[1.20.4]` parses as a single range
from `1.20.3]` to `[1.20.4`, and numbersOf maps the bracketed first component to
0 — making the upper bound 0.20.4, which nothing real can be below.

Neither VersionConstraintTest.readsMavenRanges nor VersionConstraintFuzzTest
covered it, and the fuzz test's own premise is that a wrong refusal "is
indistinguishable from the dependency being genuinely unsatisfiable". That is
exactly what happened; it just needed a well-formed constraint rather than a
malformed one.

It fails safe — a refused boot publishes nothing, so no wrong exclusion ever
reached a user. The cost is coverage: those mods are never boot-verified, and the
ERROR reads as a statement about the mod rather than about the parser.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Turns the previous commit's guard green. clientside 369 → 370 tests, api 409,
both zero failures.

Two changes, one for each spelling the live grinder produced. A bracketed
constraint is split on its top-level commas first, so `[1.20.3],[1.20.4]` becomes
two ranges and any of them may hold; `unionMembers` tracks bracket depth rather
than matching a regex, because the comma between ranges and the comma inside one
are spelled identically and only nesting distinguishes them. A bare
comma-separated list — `26.2,26.3` — is read as alternatives too: Maven would
call an unbracketed version a soft requirement rather than a constraint, but mod
authors write this meaning "either", and reading it as a single version refused
both.

An unbalanced string still yields one member, the whole input, so a malformed
constraint reaches mavenRangeHolds exactly as before and still resolves to
accept — the fail-open rule VersionConstraintFuzzTest exists to protect.

Not deployed: the public instance runs an older build, so its two affected ERROR
rows stay until it is updated and those projects are re-ground. `--requeue`
against distanthorizons and mru is the way to reclaim them without waiting for
the TTL.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Red on the two real gaps: KnownModIds returns null for 'mtlib' on CurseForge,
and CurseForgePlatform.resolveDependency answers null for a slug ref.

Reported from the live grinder: modtweaker on Forge/1.12.2 refused for `mtlib`,
a project CurseForge publishes under exactly that slug. Executed against the
registry before writing anything — `mtlib`, `crafttweaker`, `jei`, `athena`,
`flywheel` and `xaerolib` every one returned null for CurseForge and their own id
for Modrinth. So *every* manifest-declared dependency of a CurseForge candidate
was unmappable unless it was one of four hardcoded aliases.

The asymmetry had a real cause — CurseForge addresses projects by numeric id,
which cannot be guessed from a mod id — but the conclusion did not follow: its
search endpoint takes a slug, and CurseForgePlatform.resolve was already calling
it that way. resolveDependency simply began with `nativeRef.toLong()`.

Running the pins first caught a fixture fault, which is the whole reason for the
rule: `stillResolvesADependencyGivenByNumericId` failed because the fake fetcher
answered `/mods/search` and `/mods/238222/files` but not `/mods/238222`, which
the numeric path reads for slug and website. That is the fixture's fault, not the
implementation's, and it would have made a green look like a fix. The fake now
answers every URL these tests actually cause.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This reverts commit f5d13646ba.
Red: unresolved reference to newestReleaseSatisfying, five times.

Griefed's call, 2026-09-06, from the live grinder's ERROR rows.
`moonlight-1.20.4-2.9.9-forge.jar` is tagged 1.20.4 and only 1.20.4, while its
descriptor declares `[1.20,1.20.2)` — so platform and jar share no version at
all, newestVersionSatisfying returns null, and the candidate is refused outright.
"Bump the version to the one specced in the JAR, then run the grind."

The existing re-selection only reconsiders versions the *platform* tagged, which
is why JEI was rescued (tagged 1.21 and 1.21.1, declaring `[1.21, 1.21.1)`) and
moonlight was not. The jar is the better authority when the two disagree, and not
merely a different one: the loader enforces this range at runtime, so booting
inside it is what gets the mod loaded, while booting at a version the author
ticked on a web form gets it rejected by FML before it runs. Only the pack's
Minecraft version moves; the file is unchanged.

Two guards bound it rather than just asserting the happy path. The loader gate
still applies, so a release with no loader build is skipped for the next one
down. And a constraint that constrains nothing — empty, `*`, or unreadable — must
not bump at all: VersionConstraint deliberately accepts anything it cannot parse,
so without that check an unreadable descriptor would relocate every candidate to
the newest Minecraft in existence.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Turns the previous commit's five guards green. clientside 370 → 374, api 409,
grinder 503, zero failures.

reselectOnMinecraftContradiction only reconsidered versions the *platform*
tagged, which is why JEI was rescued and moonlight was not: JEI is tagged 1.21
and 1.21.1 while declaring `[1.21, 1.21.1)`, so an agreed pick existed, whereas
moonlight-1.20.4-2.9.9-forge.jar is tagged 1.20.4 and only 1.20.4 while
declaring `[1.20,1.20.2)` — the two share nothing and the candidate was thrown
away. It now falls back to the newest real Minecraft release the jar's own
descriptor accepts, still gated on the loader having a build there.

The jar is the better authority when the two disagree, and not merely a different
one: the loader enforces this range at runtime, so booting inside it is what gets
the mod loaded, while booting at a version the author ticked on a web form gets
it rejected by FML before it runs. The file is unchanged; only the pack's
Minecraft version moves, and the log line says when the version was one the
platform never tagged.

The bound that matters is `constrainsAnything`. VersionConstraint accepts
anything it cannot parse — deliberately, so a grammar gap can never mass-refuse —
so an empty, wildcard or unreadable descriptor would otherwise "satisfy" the
newest Minecraft in existence and silently relocate every candidate there. It is
decided by asking whether the constraint excludes anything in the very set about
to be searched, rather than by trying to re-detect which shapes the parser
tolerates, which would be a second copy of that grammar.

Still exactly one retry, via stageBootPack rather than prepareBootPack, so a
second contradiction surfaces instead of looping.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Three entries, from reading the public grinder's ERROR rows.

The comma-union defect, with the measurement that a union was never satisfiable
and the note that it failed safe, so the cost was coverage rather than a wrong
exclusion. Worth recording why the existing guards missed it: the fuzz test
sweeps malformed constraints and this one is well-formed.

The version bump, with the reason the jar outranks the platform tags — the loader
enforces the range at runtime — and the landmine that constrainsAnything is what
stops an unreadable descriptor relocating every candidate to the newest Minecraft
in existence.

And one left OPEN rather than fixed. A CurseForge candidate's manifest
dependencies are unresolvable, which is what Griefed reported via modtweaker and
mtlib. The obvious fix was implemented and reverted: it fails four deliberate
guards and would move ids that map-then-fail from `unmapped` into `unsatisfied`,
which refuses — the documented xaerolib trap, where being almost resolvable was
worse than being unknown. The fix that has both is to key the refusal split on how
confident the mapping was, and that changes what this file calls the whole safety
property, so it is a decision rather than a quiet edit.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Red: unresolved ModIdMapping and mappingFor, plus the parameter changes on
planManifestDependency. Nothing else fails.

The split keys on **how far a ref got** — mapped-then-unstageable refuses,
unmappable does not — which makes being *almost* resolvable worse than being
unknown. That is this file's own xaerolib case: a real Modrinth project of that
name exists but publishes nothing for the pack's loader and Minecraft version, so
a guess that happened to hit refused a boot an outright miss would have allowed.
It is also why CurseForge was given no guess at all, and therefore why
`modtweaker` never staged `mtlib`.

It now keys on **how the ref was arrived at**. An alias is a project we know the
id names, so failing to honour it is a real gap and may refuse. A guess is an
optimistic slug that may name nothing or something else, so it never refuses at
any stage — which is what makes guessing safe to extend to CurseForge, whose
search endpoint resolves a slug to the numeric id its other routes need.

Existing assertions changed, and that is the stop-and-flag signal working rather
than being bypassed: this is a deliberate behaviour change, asked for explicitly
after the tradeoff was put to Griefed, and it is labelled `fix:` not `refactor:`.
Three are signature-only (refFor → mappingFor, same expectations). Four are
substantive:

- anUnknownIdIsNotGuessedOnCurseForge is replaced by
  anUnknownIdIsGuessedOnBothPlatforms; its old rationale — that a guess costs a
  refusal — is exactly what no longer holds.
- The three "must not be fabricated on CurseForge" assertions become "must not be
  claimed for the alias", which was always the intent: lucko's
  fabric-permissions-api-v0, fabric-language-kotlin and quilt_loader must not
  resolve to Fabric API or QSL. They are now guesses at their own slugs, which
  refuse nothing.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Turns the previous commit's guards green. clientside 374 → 382, api 409, grinder
503, app 149, zero failures.

ModIdMapping carries how a ref was arrived at: Alias for a project we know the id
names (the table, or a recognised Fabric API / QSL module shape), Guess for the
optimistic slug, None for nothing to try. planManifestDependency reads it instead
of a bare String?, and the confidence rides on Stage so the download-failure
branch obeys the same rule.

An alias refuses exactly as before. A guess never refuses, at any stage — not
when it resolves to a project publishing nothing usable, not when the download
then fails. That closes the xaerolib trap directly: a real Modrinth project of
that name exists but publishes nothing for the pack's loader and Minecraft
version, so a guess that happened to hit used to refuse a boot an outright miss
would have allowed. Being almost resolvable is no longer worse than being
unknown.

Which is what makes the guess safe to extend to CurseForge, and that closes
Griefed's modtweaker report: a manifest id is offered as a slug on both
platforms now, and CurseForgePlatform.resolveDependency resolves a non-numeric
ref through the same slug search `resolve` was already using, so `mtlib` is found
and staged instead of being unmappable. Exact-match on the slug, so it finds the
project the id names or finds nothing — it cannot substitute a similarly-named
one.

Worth stating plainly, since it reverses a documented decision: CurseForge was
given no guess *because* a guess could refuse. That premise is gone, so the
conclusion goes with it. The safety property the old split protected is intact
and now stated directly rather than emerging from how far a lookup happened to
get.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Replaces the OPEN entry with the closed one, and marks the older
"how far did it get" description as superseded rather than deleting it — the
reasoning below it is why the split exists at all and still holds; only its key
changed.

States the premise that moved, so nobody re-derives the old conclusion from the
old rationale: CurseForge was given no guess *because* a guess cost a refusal.
It no longer does, so the guess is safe to offer, and mtlib is resolvable.

Counts re-derived after a run of every affected module: clientside 382, api 409,
grinder 503, app 149.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Red on the mtlib case: expected MTLib-3.0.7.jar, got null.

This is the actual cause of Griefed's modtweaker report, and finding it needed
the live CurseForge API — the earlier diagnosis was wrong. `modtweaker-4.0.20.11`
declares dependency `253211`, a *numeric* ref, so the refusal came from the
platform path and never touched the manifest-id mapping I had been changing.
`253211` resolves to mtlib and returns 7 obtainable files for 1.12.2 — every one
carrying `loaders=[]`, because CurseForge had no modloader facet before Minecraft
1.13. `pickForLoader` requires `loader in it.loaders`, which no empty set
satisfies, so the dependency was unpickable and the boot was refused.

Measured with Griefed's key, 2026-09-06: mtlib is 15/15 files untagged,
iron-chests 106/138, waystones 70/494, crafttweaker 28/500 — essentially all
pre-1.13, plus modern stragglers (journeymap, 5 files at 26.1.2). jei and athena
have none.

Three guards bound the fallback rather than just asserting it fires. Untagged is
the last resort, so a tagged file wins and this can only add a pick where there
was none. The Minecraft version stays exact — untagged excuses the loader, never
the version. And a file tagged for a *different* loader is still refused, because
untagged means "the author told us nothing", which is not the same as "the author
told us this is Fabric".

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Turns the previous commit's four guards green, and verified against the live
CurseForge API: pickDependencyFile(Forge, 1.12.2) for mtlib now returns
MTLib-3.0.7.jar where it returned nothing. clientside 382 → 386, api 409, grinder
503, app 149, zero failures.

pickUntagged is the last arm of pickFrom, after the exact loader and the
Quilt-to-Fabric fallback, so a file whose author did state a loader always wins
and this can only add a pick where there was none. The Minecraft version stays
exact — untagged excuses the loader, never the version — and a file tagged for a
different loader is still refused, because that tag is a statement and an empty
set is the absence of one.

Worth recording that the first diagnosis of this report was wrong, and only the
live API showed it. modtweaker-4.0.20.11 declares dependency `253211`, a numeric
ref, so the refusal came from the platform path and never touched the manifest-id
mapping the previous two commits changed. Those commits are independently correct
— they close the xaerolib trap and make CurseForge manifest ids resolvable — but
they would not have fixed what Griefed reported. A cause that survives a code
read can still be the wrong one.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The measurements belong here rather than in a commit message, since the next
reader of pickForLoader needs them: CurseForge had no modloader facet before
1.13, so mtlib is 15/15 files untagged, iron-chests 106/138, waystones 70/494,
crafttweaker 28/500, plus modern stragglers.

Also records that the first diagnosis was wrong and why it looked right — the
refusal names a slug because unsatisfiedLabel resolves the ref, which reads like
a manifest mod id, while modtweaker actually declares the numeric 253211.

And leaves the candidate half OPEN with its own measurement: mtlib as a candidate
for Forge returns nothing, so a project whose files are all untagged is never
ground at all. Widening pickBootableCandidate decides which mods get ground
rather than which dependency is staged, so it is Griefed's call — with the note
that the risk is bounded to wasted boots, since a mod under an unsupported loader
fails to load and reads INCONCLUSIVE, never CONFIRMED.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Red on the mtlib case: an all-untagged project must still be ground, got null.

The candidate half of the untagged-loader rule. pickBootableCandidate has the
same `loader in it.loaders` test pickForLoader had, so a project whose files are
all untagged was never selected under any loader — measured against the live
CurseForge API, pickBootableCandidate(mtlib.files, "Forge") returns nothing, all
15 of its files carrying loaders=[]. iron-chests and waystones escaped only
because their newer files are tagged.

Widening it here needs a different argument than for a dependency, and the guards
carry it. Picking an untagged file for the wrong loader could in principle stage
a jar that loader ignores, boot cleanly, and publish a false CLEAR — "proven
server-safe" for a mod that never loaded. Two things prevent that.
loaderVersionAvailable covers the dominant case: untagged files are
overwhelmingly pre-1.13, where Fabric and Quilt have no builds at all, so only
Forge is reachable and untagged means Forge. For anything newer,
refuseForSelfDeclaration reads the downloaded jar's own descriptor before the
boot and refuses one carrying only another loader's — the "carries only Forge
descriptor(s), so it is not a NeoForge mod" refusal already visible in the live
store. So the cost of being wrong is a refused attempt, not a wrong verdict.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Turns the previous commit's four guards green, and verified live:
pickBootableCandidate(mtlib.files, "Forge") now returns MTLib-3.0.7.jar @ 1.12.2
where it returned nothing. clientside 386 → 390, api 409, grinder 503, app 149,
zero failures.

The untagged arm is last, so a file whose author stated a loader always wins and
this only adds a candidate where there was none. pickBootableCandidate's body is
split into newestOf so both arms share the ordering and the availability gate
rather than restating them.

The safety argument, since it differs from the dependency half: an untagged file
picked for the wrong loader could stage a jar that loader ignores, boot cleanly
and publish a false CLEAR, which is the worst outcome this engine has — it claims
proof about a mod that never loaded. loaderVersionAvailable covers the dominant
case, because untagged files are overwhelmingly pre-1.13 where Fabric and Quilt
have no builds at all, so only Forge is reachable and untagged means Forge. For
anything newer, refuseForSelfDeclaration reads the downloaded jar's descriptor
before the boot and refuses one carrying only another loader's.

Measured consequence for mixed-era projects, verified live on iron-chests: a
Fabric attempt now picks an untagged 1.16.2 Forge jar and is refused by the
descriptor gate, where before it was refused at selection. Same verdict class
either way, a more precise reason, and one download's worth of extra work — the
project publishes no Fabric-tagged file at all, so that attempt was never going
to succeed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Replaces the OPEN entry with what was decided and measured, keeping the safety
argument — it differs from the dependency half and a future reader will need it
before touching either arm.

Records the live consequence for mixed-era projects rather than only the win: an
iron-chests Fabric attempt now downloads an untagged Forge jar and is refused by
the descriptor gate instead of at selection. Verdict-neutral, better reason, one
extra download.

clientside 390.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
merge: the grinder plugin, and four engine defects its ERROR rows exposed
Some checks failed
Qodana / scan (push) Successful in 11m24s
Documentation / Writerside webhelp (push) Successful in 1m37s
Docker Test / build image (push) Successful in 16m28s
Documentation / Help image (push) Failing after 4m36s
Continuous / Build JAR (push) Successful in 18m33s
Qodana / notify (push) Successful in 10s
Continuous / Build AppImage (x86_64) (push) Successful in 1m53s
Continuous / Build AppImage (aarch64) (push) Successful in 2m11s
Continuous / Build Install4J Media (push) Successful in 8m51s
Continuous / Continuous Pre-Release (push) Failing after 1m37s
Test / build (push) Successful in 56m34s
09a740e68c
35 commits. Two strands: a new GUI plugin, and the clientside fixes that came out
of reading what the public grinder had actually published.

The plugin (serverpackcreator-plugin-grinder) reads a grinder's verdicts over
HTTP, lets the user tick individual entries across all four verdict classes, and
folds those ticks into packConfig.clientMods just before the mod list is compiled
— so one selection covers the GUI, the CLI and the web backend. It needed a
machine-readable feed, so the daemon gained /verdicts.json, sharing
VerdictSelection.select with the table and the CSV so the three cannot disagree.
Verified end-to-end against a live ReportServer: three ticked entries produced a
pack holding only the unticked and the unknown mods, with the GUI never opened.

Installing a second plugin exposed ApiPlugins.getAllExtensionsOfPlugin ignoring
its plugin argument — every tab added once per installed plugin, every generation
extension run that many times. Invisible while the repository shipped exactly one
plugin, and visible immediately as "Grinder | Tetris | Grinder | Tetris". Fixed,
pinned, and recorded in claude-docs/API-BEHAVIOUR-CHANGES.md.

Then four engine defects, each found by reading the live ERROR rows rather than
the code, each pinned red before its fix:

- A comma-separated union of version ranges was never satisfiable, so
  [1.20.3],[1.20.4] refused both versions it lists and Maven's own documented
  example refused everything.
- A jar whose declared range shares no version with its platform tags was thrown
  away; it now bumps to a release the jar itself accepts.
- The refusal split keyed on how far a lookup got, which made being almost
  resolvable worse than being unknown. It now keys on mapping confidence: an
  alias may refuse, a guess never does.
- A CurseForge file carrying no loader tag was read as incompatible rather than
  unknown, so pre-1.13 dependencies were unstageable and all-untagged projects
  were never ground at all.

The last of those is the one Griefed reported via modtweaker and mtlib, and the
first diagnosis of it was wrong — only driving the real CurseForge API showed
that the refusal came from the platform path via a numeric ref, not from the
manifest-id mapping two earlier commits had changed.

Suites: api 409, clientside 390, grinder 503, app 149, plugin-grinder 69,
plugin-example 3. Zero failures.
Red on purpose. Two guards fail against current code:

  JarSelfDeclarationTest.aNeoForgeBootOnMinecraft1201AcceptsAForgeJar
    expected: <null> but was: <Mantle-1.20.1-1.11.117.jar carries only
    Forge descriptor(s), so it is not a NeoForge mod>

  BootCandidateSelectorTest.theNeoForgeFallbackToForgeAppliesOnMinecraft1201Only
    NeoForge 20.1.x loads a Forge 1.20.1 mod unchanged
    expected: <dep-forge.jar> but was: <null>

NeoForge 20.1.x is a fork of Forge 47 that kept the net.minecraftforge
packages, javafml and META-INF/mods.toml; the package rename landed with
1.20.2, from where the two are separate ecosystems. 1.20.1 is therefore
the entire compatibility band, not the start of one.

The live false positive: the grinder published an ERROR row for
CurseForge/mantle on NeoForge, "Refusing to boot NeoForge on Minecraft
1.20.1: Mantle-1.20.1-1.11.117.jar carries only Forge descriptor(s), so
it is not a NeoForge mod" -- for a file CurseForge ticks Forge AND
NeoForge, and which had booted to a ready-line under Forge minutes
earlier in the same run.

The remaining three guards are green already and stay as regression
cover: the band ends at 1.20.1, the concession is one-way (Forge still
cannot read neoforge.mods.toml), and a real NeoForge build still beats
the Forge fallback.

Also strengthens theFallbackDoesNotApplyToOtherLoaders, whose Forge
fixture was tagged 1.20.1 and asked for at 1.21.1: it answered null
because no file carried the version, so the assertion could not see the
cross-loading rule its own message was about.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Turns the two red guards green. NeoForge 20.1.x is a fork of Forge 47
that kept the net.minecraftforge packages, the javafml language provider
and META-INF/mods.toml, so on Minecraft 1.20.1 a Forge build and a
NeoForge build are the same file. NeoForge renamed those packages for
1.20.2, which is where the compatibility ends -- the band is one version
wide, and the rule says so literally rather than as a range.

Two call sites held separate, silently diverging copies of "which loader
runs whose builds": JarSelfDeclaration.alsoRuns (the pre-boot descriptor
gate) and BootCandidateSelector.fallbackLoaders (dependency selection).
Both were `Quilt to Fabric`, and only one of them could ever have learned
this. They now share LoaderCompatibility.alsoRuns(loader, minecraft),
which takes the Minecraft version because the NeoForge claim is
worthless without one.

What it fixes, live: CurseForge/mantle published an ERROR row on
NeoForge -- "Refusing to boot NeoForge on Minecraft 1.20.1:
Mantle-1.20.1-1.11.117.jar carries only Forge descriptor(s), so it is
not a NeoForge mod" -- for a file CurseForge ticks Forge AND NeoForge,
and which had reached a ready-line under Forge minutes earlier in the
same run. A verdict about the grinder's own descriptor table, published
as a verdict about the mod.

The dependency half closes the same gap one step earlier: a dependency
publishing only Forge files was unpickable for a NeoForge 1.20.1 boot,
and refuseForMissingDependencies scores an unstageable requirement
INCONCLUSIVE, so the whole boot was lost.

The concession stays one-way in both directions it could have leaked:
Forge still cannot read neoforge.mods.toml, and a real NeoForge build
still beats the Forge fallback where the project publishes one.

:serverpackcreator-clientside:test 395/395 green (390 before these five
guards); :serverpackcreator-grinder:test and :serverpackcreator-app:test
green. No new compiler warnings.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
One landmine, two halves. The fact -- NeoForge 20.1.x is Forge 47 under
another name, and the 1.20.2 package rename ends it -- plus the reason it
must be stated as the single version rather than a lower bound: a range
boots Forge jars under NeoForge 1.20.2+, where FML rejects them and the
failure is scored against the mod.

Also records why LoaderCompatibility exists at all (two diverging copies
of the same question, only one of which could ever have learned this) and
the live mantle ERROR row that produced it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Red on purpose, three guards, and the middle one reproduces the live
symptom exactly:

  MetadataScannerTest.aConnectorPlaceholderIsScannedAsTheFabricModItWraps
    the placeholder mods.toml declares nothing; the fabric.mod.json
    beside it declares client
    expected: <CLIENT> but was: <SERVER_OR_BOTH>

  JarSelfDeclarationTest.aConnectorPlaceholderNamesItselfInItsModsToml
  JarSelfDeclarationTest.anythingWithoutTheMarkerIsNotAConnectorPlaceholder
    kotlin.NotImplementedError: the placeholder marker is not read yet

A Sinytra Connector "placeholder" is a Fabric mod wrapped so a platform
can tag it Forge. Read from the live continuity-3.0.0+1.20.1.forge.jar:
its META-INF/mods.toml carries [properties] "connector:placeholder" =
true and version-less dependency entries, and the fabric.mod.json in the
same jar holds the actual mod, declaring "environment": "client".

Scanning that with the Forge scanner reads the stub, which declares no
sideness at all. Measured live 2026-09-06: Modrinth/continuity's Forge
row came back jarScan=SERVER_OR_BOTH and declared=CONTRADICTORY against a
platform declaring client_side=REQUIRED, while the same project's Fabric
row read CLIENT off the same descriptor. The false contradiction is what
arms ClientsideVerifier's other-version crash re-check, which spends up
to three boot budgets (~45 min) arguing with a contradiction that was
never there.

JarSelfDeclaration.isConnectorPlaceholder is declared as TODO() so the
test tree compiles and every guard runs red for the one reason. The
fourth guard is green already and stays as regression cover: a genuine
multi-loader jar carries both descriptors too, so the redirect keys on
the marker, never on the pair.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Turns the three red guards green. A Sinytra Connector placeholder is a
Fabric mod wrapped so a platform can tag it Forge: its META-INF/mods.toml
carries [properties] "connector:placeholder" = true and exists only to
get the file past Forge's mod discovery, while the fabric.mod.json beside
it holds the actual mod. Scanning the stub reads no sideness at all.

JarSelfDeclaration.isConnectorPlaceholder reads the marker (nightconfig's
TomlParser, already on the compile classpath via -api), failing toward
false like everything else in that object. MetadataScanner substitutes
the scanner's INPUT, not the dispatch: the loader -> scanner choice still
goes through ModScanner.scannerFor, so this class and ModListCompiler
cannot drift the way they once did.

Keyed on the marker, never on carrying both descriptors -- a genuine
multi-loader jar ships a real mods.toml beside a real fabric.mod.json and
each speaks for its own loader.

What it fixes, live 2026-09-06: Modrinth/continuity's Forge row came back
jarScan=SERVER_OR_BOTH and declared=CONTRADICTORY against a platform
declaring client_side=REQUIRED, while the same project's Fabric row read
CLIENT off the identical descriptor. The contradiction was manufactured
by the scanner choice, and ClientsideVerifier.declaresServerSupport --
the same predicate -- is what arms the other-version crash re-check,
which spends up to three boot budgets (~45 min) per armed candidate.

The Forge boot is still attempted: a working Connector setup would still
be verified, and its INCONCLUSIVE stands on its own evidence rather than
on a false metadata contradiction. Griefed's call.

Not fixed here, and not ours: Connector beta.49 under Forge 47.4.23 did
not convert the jar at all ("Dependency resolution found 0 candidates to
load"), which is why the boot failed. The grinder had staged exactly the
right files -- newest Sinytra Connector and newest Forgified Fabric API
for 1.20.1.

--rerun-tasks: clientside 399/399, grinder 503 (29 skip), app 149/149,
all green. No new compiler warnings.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
What the marker is, why the redirect substitutes the scanner's input
rather than its dispatch, why it keys on the marker and not on carrying
both descriptors, and -- so nobody re-litigates it from the symptom --
that the failing Forge boot was NOT a staging gap: the newest Connector
and the newest Forgified Fabric API for 1.20.1 were both present.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Red on purpose: nine pure guards on the decision, plus the staging join.

  DependencyBacktrackStagingTest
      .aDependencyDemandingAnUnavailableVersionIsDroppedToAnOlderBuild
    the 3.6.6 build demands fabric-api >=0.100.0+1.20.6 and must not
    survive staging
    expected: <[YetAnotherConfigLib-3.4.2.jar, Zoomify-2.13.3.jar,
               fabric-api-0.97.8.jar]>
    but was:  <[Zoomify-2.13.3.jar, fabric-api-0.97.8.jar,
               yet_another_config_lib_v3-3.6.6.jar]>

  DependencyBacktrackTest (nine)
    kotlin.NotImplementedError: the staged set is not checked against its
    own declared requirements yet / nothing is demoted yet

Staging resolves each dependency on its own -- the newest file of that
project tagged for the pack's Minecraft -- and never asks whether the
resulting SET is coherent. Measured live 2026-09-06, Modrinth/zoomify on
Quilt / Minecraft 1.20.5: Modrinth tags
yet_another_config_lib_v3-3.6.6+1.20.6-fabric.jar for 1.20.5 and 1.20.6,
and its own descriptor declares "minecraft": "~1.20.5", so neither
selection nor the descriptor gate objects -- but it also declares
"fabric-api": ">=0.100.0+1.20.6", and the newest Fabric API Modrinth
publishes for 1.20.5 is 0.97.8+1.20.5 (verified against the live API:
four files, 0.97.5 through 0.97.8). No fabric-api satisfies it there, so
staging MORE cannot fix the pack; only an older YACL can. 3.4.2+1.20.5
requires nothing but fabric-resource-loader-v0.

The staging test drives the real join -- resolve, download, scan, judge,
demote, re-stage -- with a fake platform and a downloader that writes
real jars, so the pure decision is proven to be wired to something. It
stays offline by injecting a LoaderVersionPolicy answering a build no
config check accepts: selection passes, generation fails, and everything
asserted happens before generation.

aCoherentSetKeepsTheNewestDependency is green already and stays as the
counterweight: without it the fix would be indistinguishable from
"always take the older dependency".

Fixture note, caught by running the pins before committing them: the
descriptor map first held whole JSON objects trimmed with
trim('{','}'), which strips EVERY trailing brace and left
"depends":{... unterminated -- so fabric-api was never staged and the
guard would have gone red for its own fixture rather than for the
missing implementation. The map now holds descriptor bodies.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Behaviour-identical: JarSelfDeclarationTest and MetadataScannerTest stay
green with no assertion touched.

nightconfig's Config.valueMap() is deprecated, so the two calls added by
"scan a Connector placeholder as the Fabric mod it wraps" raised two new
compiler warnings -- which that commit's message claims it did not. It
was wrong; this corrects it rather than rewriting the commit.

UnmodifiableConfig.get(path) replaces both, which also reads better here:
the property key carries a colon rather than a dot, so passing the path
as a list is what keeps nightconfig from splitting it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Turns the ten red guards green. Staging resolved each dependency alone --
the newest file that project publishes for the pack's Minecraft -- and
never asked whether the resulting SET was coherent. Where it is not, the
loader refuses the pack, ~70 s of container is spent, and the CANDIDATE
wears the INCONCLUSIVE: the same "the mod never got a fair run" shape
this engine keeps a dozen guards for, arriving one layer earlier.

After staging and before generation, the pack is now judged against
itself: each staged jar's declared requirements against the versions
actually staged. A dependency whose demand cannot be met is dropped a
build and the pack is re-staged, up to DependencyBacktrack.MAX_BACKTRACKS
(10).

The live case, Modrinth/zoomify on Quilt / Minecraft 1.20.5:
yet_another_config_lib_v3-3.6.6+1.20.6-fabric.jar is tagged for 1.20.5
and declares "minecraft": "~1.20.5", so neither selection nor the
descriptor gate objects -- while demanding "fabric-api":
">=0.100.0+1.20.6". Verified against the live API: Modrinth publishes
exactly four fabric-api files for 1.20.5, 0.97.5 through 0.97.8. Staging
MORE cannot fix that pack; only an older YACL can, and 3.4.2+1.20.5
requires nothing but fabric-resource-loader-v0.

Four things it deliberately does not do:

- It never demotes the candidate. That is the subject of the experiment;
  swapping it would answer a question about a different mod.
- It never refuses. Every uncertainty -- no scanner, an unreadable jar, a
  version the platform never reported, a range VersionConstraint cannot
  parse, an exhausted budget -- proceeds to the boot exactly as before.
  A gate that refused on doubt is the mass-INCONCLUSIVE shape this module
  has already paid for twice.
- It ignores optional dependencies. The loader loads the mod without
  them, so one being older than a `recommends` asked for cannot be why a
  pack is refused.
- It ignores a requirement naming something not staged at all. That is
  refuseForMissingDependencies' case; demoting over a gap that dropping a
  jar cannot close would burn the budget and change nothing.

Cost, stated rather than optimised away: a backtrack re-stages from
scratch, so it re-downloads the candidate and every dependency. The
zoomify case needs seven of them (YACL ships 3.6.6 down to 3.6.0 tagged
for 1.20.5, every one a +1.20.6 build with the same demand). That is
still cheaper than the wasted boot it replaces, and skipping files
already on disk is an optimisation to make only if the rate warrants it
-- 2 of 250 live verdicts currently reach DEPENDENCY_FAILURE.

InjectedDependency gained `version`, which is how the judge learns what
is really staged without a second accumulator threaded through every
level of the recursion.

--rerun-tasks: clientside 410/410, grinder 503 (29 skip), app 149/149,
all green. No new compiler warnings.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The gap (each dependency resolved alone, the set never judged), the
measured zoomify case, the four things the judge deliberately does not
do -- each of which is what keeps it from becoming a refusal gate -- and
the re-download cost with the measurement that would justify optimising
it away.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Suite 390 -> 410, re-derived from
serverpackcreator-clientside/build/test-results/test/*.xml after a full
./gradlew build, and one line naming what landed so a reader knows which
module file to open.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Not this branch's work -- pre-existing drift on develop, surfaced because
`./gradlew build` regenerates the report and left the tree dirty.

The only delta is com.microsoft.playwright:playwright:1.62.0 dropping
out, 44 dependencies to 43. It was removed on 2026-09-02 ("the route
existed only to circumvent the distribution block, and by the end it did
not work at all") and the generated report was never re-committed, so
every full build since has dirtied both copies.

Generated by the build, not hand-edited; both files are the same report
and move together.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
merge: three field reports from the live grinder, and the drift a build exposed
All checks were successful
Documentation / Writerside webhelp (push) Successful in 3m43s
Continuous / Build JAR (push) Successful in 12m52s
Qodana / scan (push) Successful in 21m17s
Docker Test / build image (push) Successful in 27m2s
Continuous / Build AppImage (x86_64) (push) Successful in 3m35s
Test / build (push) Successful in 18m20s
Continuous / Build AppImage (aarch64) (push) Successful in 3m43s
Qodana / notify (push) Successful in 51s
Documentation / Help image (push) Successful in 14m16s
Continuous / Build Install4J Media (push) Successful in 11m2s
Continuous / Continuous Pre-Release (push) Successful in 5m0s
300a4aae6c
12 commits. Griefed read three rows off the public grinder — one false positive
and two INCONCLUSIVEs he judged solvable — and each turned out to have a
different cause than the symptom suggested. Two of the three diagnoses had to be
corrected against the live APIs before anything was written.

NeoForge runs Forge builds on Minecraft 1.20.1, and on nothing else. NeoForge
20.1.x is a fork of Forge 47 that kept the net.minecraftforge packages, javafml
and META-INF/mods.toml, so there a Forge jar and a NeoForge jar are the same
file; the rename to net.neoforged landed with 1.20.2 and ends it. CurseForge/
mantle published an ERROR row refusing to boot Mantle-1.20.1-1.11.117.jar as
NeoForge — for a file CurseForge ticks Forge AND NeoForge, and which had reached
a ready-line under Forge minutes earlier in the same run. The fact had two homes
that had silently diverged, JarSelfDeclaration.alsoRuns and
BootCandidateSelector.fallbackLoaders, both spelling Quilt -> Fabric, so only
one of them could ever have learned it; LoaderCompatibility is now both, and it
takes the Minecraft version because the NeoForge claim is meaningless without
one. Stated as the single version, never a lower bound: a range would boot Forge
jars under NeoForge 1.20.2+, where FML rejects them and the failure is scored
against the mod.

A Sinytra Connector placeholder is a Fabric mod, and the Forge scanner reads a
stub. Modrinth/continuity's Forge row came back jarScan=SERVER_OR_BOTH and
declared=CONTRADICTORY against a platform declaring client_side=REQUIRED, while
the same project's Fabric row read CLIENT off the identical descriptor. Pulled
down, continuity-3.0.0+1.20.1.forge.jar carries [properties]
"connector:placeholder" = true with version-less dependency entries, and the
fabric.mod.json beside it holds the real mod, "environment": "client" included.
The contradiction was manufactured by the scanner choice — and
declaresServerSupport, the same predicate, is what arms the other-version crash
re-check, so a false one costs up to three boot budgets (~45 min) per armed
candidate. The redirect substitutes the scanner's input, not the dispatch, so
the MetadataScanner/ModListCompiler drift cannot come back. Reported as "it
requires the fabric-api despite being a Forge mod"; the staging was in fact
already right — the newest Connector (beta.49) and the newest Forgified Fabric
API (0.92.6+1.11.15) for 1.20.1 were both present, and Connector under Forge
47.4.23 still logged "Dependency resolution found 0 candidates to load" and
never converted the jar. That half is Connector-internal and is not ours. The
boot is still attempted, so its INCONCLUSIVE now stands on its own evidence.

A pack whose own jars contradict each other backtracks instead of booting.
Staging resolved every dependency alone — the newest file that project publishes
for the pack's Minecraft — and never asked whether the resulting set was
coherent. Modrinth/zoomify on Quilt / Minecraft 1.20.5 was reported as needing a
newer fabric-api; the live API says there is none, Modrinth publishing exactly
four files for 1.20.5, 0.97.5 through 0.97.8, the newest of which the grinder had
already staged. The unsatisfiable link is
yet_another_config_lib_v3-3.6.6+1.20.6-fabric.jar: tagged for 1.20.5, declaring
"minecraft": "~1.20.5" so neither selection nor the descriptor gate objects, and
demanding "fabric-api": ">=0.100.0+1.20.6". Staging more cannot fix that pack;
only an older YACL can, and 3.4.2+1.20.5 requires nothing but
fabric-resource-loader-v0. DependencyBacktrack judges the staged set against
itself before generation and drops an over-demanding dependency a build, up to
ten times. It never demotes the candidate, never refuses — every uncertainty
proceeds to the boot exactly as before, because a gate refusing on doubt is the
mass-INCONCLUSIVE shape this module has already paid for twice — and ignores
both optional dependencies and requirements naming something not staged at all.
Cost stated rather than optimised away: a backtrack re-stages from scratch, and
zoomify needs seven.

Verification. ./gradlew build green with a clean working tree; clientside
410/410 (390 before, 20 new guards), grinder 503 (29 skip), app 149/149, all
under --rerun-tasks. Equivalence checked the way this repo asks: develop's
unmodified test tree against the branch's production code, 390 pre-existing
guards, zero failures and zero compile errors. Every fix landed as a red test()
commit first and each pin was run before being committed — which caught a
fixture bug where trim('{','}') stripped both closing braces, so that guard
would have gone red for itself rather than for the missing implementation.

Two things that were not asked for and are worth knowing. The Forge arm of
theFallbackDoesNotApplyToOtherLoaders asserted nothing: its fixture was tagged
1.20.1 and asked for at 1.21.1, so it answered null for version reasons whatever
the loader rule said, while its message spoke about cross-loading. And the full
build regenerated licenses/LICENSE-AGREEMENT.txt, exposing drift that predates
this branch — playwright:1.62.0 was removed on 2026-09-02 and the generated
report never re-committed, so every full build since has dirtied both copies.
Regenerated in its own commit.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Committed red: three of the four fail, and the red is the missing implementation, not a broken
fixture -- `expected: <[]> but was: <[Conflict(... requiredModId=balm, versionConstraint=>=26.2.0,
stagedVersion=Balm 26.2.0.7)]>`.

`VersionConstraint` promises to fail toward accepting, and `DependencyBacktrack`'s own class doc
repeats the promise: "VersionConstraint accepts anything it cannot read, so an unparseable range
never produces a conflict". The promise is kept on the *constraint* side only. On the version side
`numbersOf` splits on `.` and maps a digit-less component to `0`, so `Balm 26.2.0.7` reads as
`[0, 2, 0, 7]` and `balm-fabric-26.2-26.2.0.7.jar` -- everything before the first `-` -- as `[0]`.
Both then sit below almost any range, and a conflict is manufactured out of decoration.

Text arrives there by design: CurseForge has no version field, so `CurseForgePlatform.toModFile`
fills `ModFile.version` with the author-typed `displayName`, documented in place as "often
decorated". Every CurseForge dependency therefore carries a version this parser cannot read.

Measured on the live daemon 2026-09-07, one day after DependencyBacktrack landed: 1014
`re-staging ... without it` lines and 146 `publishes no ... file for Minecraft` lines in one day,
ending in 47 published ERROR verdicts for files that exist. Asked directly (misc/cf-dependency-probe.sh),
the CurseForge API returns every one of them correctly loader-tagged -- balm-fabric-26.2-26.2.0.7.jar,
architectury-9.2.14-fabric.jar, thermal_foundation-1.20.1-11.0.6.70.jar and ten Fabric-tagged
create-fabric 1.20.1 builds. The demote loop had exhausted the file list first, and `withoutExcluded`
left `pickDependencyFile` nothing to pick.

`aReadableVersionIsStillCompared` also catches a second, older instance of the same defect that the
fuzz test carries in its version list and never asserts on: `v2.1` parses as `[0, 1]`.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Turns UnreadableStagedVersionTest green. The whole clientside suite is green at 414 tests (410 before
the pin), and no existing assertion was touched -- only production code changed here, which is what
makes the previous commit's red a boundary anyone can check out.

Two guards, both extending a promise the class doc already made ("a version string that is not a
version" must never refuse) to the side that never had it:

- `readableVersion` gates `satisfies` on the *version*, where `looksLikeVersion` gates only the
  constraint. It is stricter on purpose: "holds a digit" is too generous for a version, since
  `Balm 26.2.0.7` holds four and still reads as `[0, 2, 0, 7]`. Every dot-separated component of the
  core must be numeric, so prose accepts instead of comparing as ~zero.
- `numbersOf` drops a leading `v`, so `v2.1` is `[2, 1]` rather than `[0, 1]`. Same defect, older, and
  carried in the fuzz test's own version list without ever being asserted on.

Deliberately NOT done: extracting a version out of a decorated release name. Guessing which digits in
`Create 6.0.10 for NeoForge 1.21.1` are the mod's is exactly the silently-plausible-value trap this
module keeps paying for -- and the two candidate readings there differ by four major versions.

What this costs: a real conflict spelled in a version we cannot parse is now missed, and the pack
boots as it did before DependencyBacktrack existed. That direction is the cheap one -- a missed
conflict costs one boot, an invented one costs a published verdict, and 47 of them are published
right now.

The backtrack's other half is untouched: `aReadableStagedVersionStillConflicts` keeps the zoomify case
(fabric-api 0.97.8+1.20.5 against >=0.100.0+1.20.6) demoting exactly as designed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Committed red, and the red is the defect verbatim -- all three cases produce the byte-identical
sentence `Required dependency unavailable for Fabric / Minecraft 26.2: yacl.`, differing only in
whether the project published nothing usable, the download died, or staging dropped every build itself
while backtracking.

`unsatisfiedLabel` distinguishes two of the five ways a dependency reaches `unsatisfied` (an unresolved
ref, a distribution-locked file); the remaining three print the bare slug. That is the same standard
this module already enforces on boot verdicts -- `BootDecision.decidedBy` exists precisely so a verdict
can name its evidence -- not yet applied to a staging refusal, which publishes ERROR just the same.

The cost is measured, not hypothetical. Diagnosing 2026-09-07's 47 such rows needed a CurseForge API
probe against Griefed's key to establish the files existed and were correctly tagged, plus a log grep
on the daemon host to find 1014 `re-staging ... without it` lines against 4 real staging failures. The
verdict itself said none of that, and the backtrack case says the opposite of the truth: the project
published builds, and we excluded them.

Harness is DependencyBacktrackStagingTest's -- fake platform, real jars written to disk, generation
made unreachable so every assertion is about staging.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Turns UnmetDependencyReasonTest green; clientside is 417 tests, zero failures, and -app and -grinder
still compile against it.

`UnmetReason` carries the evidence: UNRESOLVED, NO_USABLE_FILE, DROPPED_BY_BACKTRACK,
DISTRIBUTION_LOCKED, DOWNLOAD_FAILED. `refuseForMissingDependencies` renders it per entry, so the three
failures that shared one sentence now read differently:

  ... : yacl (nothing published for this loader and Minecraft version).
  ... : yacl (download failed).
  ... : yacl (every usable build was dropped resolving a version conflict).

DROPPED_BY_BACKTRACK is the one worth the work. `backtrackReason` re-runs the same pick over the
*unfiltered* file list, so "the project publishes nothing usable" is distinguished from "it does, and
we excluded all of it" — the second having been reported as the first, which is the opposite of the
truth and is what hid 1014 re-stagings behind 47 verdicts on 2026-09-07. It costs no request: the
project is already resolved and in hand.

**The reason travels BESIDE the name, not inside it.** `unsatisfied` is now `Map<name, reason>` rather
than `Set<label>`, because the dedupe that keeps one mod one entry when it is missing by both the
platform and the manifest route (the `waystones` case) keys on the name — and two routes can fail for
two different reasons. `unsatisfiedLabel` therefore lost its `file` parameter and does one job: naming.

STOP-AND-FLAG, per the refactor conventions: this changed the *expected value* of one existing
assertion. `everyMissingDependencyIsNamedInAStableOrder` asserted the substring `alpha, zeta`, and the
two names are no longer adjacent now that each carries a reason. It asserts the ordering by position
instead, which is the rule it was always about. Every other test edit is argument adaptation with
assertions untouched (`setOf` -> `mapOf`, the new `platformName`), except the two locked/obtainable
label tests, which now make the same claim through the refusal because that is where locked moved to.

`planManifestDependency` gained an `excluded` parameter (defaulted, so its existing tests are
unchanged) and now applies the exclusions itself rather than being handed a pre-filtered project —
without the unfiltered list it cannot tell the two refusals apart either. `withoutExcluded` moved to
the companion for the same reason.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Two entries under the DependencyBacktrack section, where the next reader of that code will be: the
platform release name that cannot be compared as a number (with the live counts and the probe that
established the files existed), and the UnmetReason vocabulary a staging refusal now publishes.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Committed red, and the red is the pack the loader refuses: `expected: <[create-6.0.8.jar, ...]> but
was: <[create-6.0.10.jar, ...]>`. The counterweight passes already, by construction -- it becomes a
real guard once demotion can happen at all.

`dependencyToDemote` builds its "what is on the classpath" map from `modsDir.listFiles()` and
`InjectedDependency.version`. A jar-in-jar library is in neither: it is not a top-level file and the
platform never published it. `DependencyBacktrack.conflicts` then skips the requirement naming it,
deliberately -- a requirement naming something unstaged is `refuseForMissingDependencies`' case -- so
a pack whose own jars contradict each other boots anyway.

Live case, CurseForge/createaddition on NeoForge 21.1.250 / Minecraft 1.21.1, 2026-09-07:

    Mod ID: 'ponder', Requested by: 'create', Expected range: '[1.0.82,)', Actual version: '1.0.64'

`ponder` is in none of that verdict's four stagedDependencies. The container was spent and the
CANDIDATE wore the INCONCLUSIVE, which is the shape every other guard here exists to prevent. Rare,
but it is the direction that publishes a wrong verdict rather than merely wasting a boot.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Turns NestedDependencyConflictTest green; clientside is 419 tests, zero failures, and no existing
assertion changed -- BundledJars' behaviour for `idsIn` is identical, only its internals were split so
both questions read the same jars by the same rules.

`BundledJars.versionsIn` reads each nested descriptor's own `version` beside the ids `idsIn` already
returned, and `BootVerifier.nestedVersions` folds those into `stagedVersions` **under** the top-level
entries -- a jar-in-jar copy can only fill a gap, never overwrite the build staging deliberately chose,
which is also the build a demotion would act on.

Ambiguity is dropped rather than guessed, at both levels: an id bundled at two different versions
(within one jar, or across two staged jars) contributes nothing. Which copy a loader picks is its own
resolution behaviour, and this module fails toward proceeding -- no opinion costs a missed conflict,
a wrong one manufactures a demotion.

An id whose nested descriptor states no version still counts as *present* via `idsIn`, so
`stageableRequirements` keeps skipping its download; it simply has no version to be compared against.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Beside the other two entries from this investigation, where the next reader of dependencyToDemote will
be: what the map was missing, the live createaddition/ponder evidence, and the two deliberate
restraints (nested loses to top-level, ambiguity contributes nothing).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Reported as unresolved dependencies for createaddition, crafting-tweaks and cooking-for-blockheads;
found to be 47 published ERROR rows on the live daemon, for files CurseForge returns on request.

Three defects, each pinned red before its fix:

- A CurseForge `ModFile.version` is the author-typed `displayName`, and `numbersOf` mapped its
  digit-less first component to 0 -- `Balm 26.2.0.7` read as `[0, 2, 0, 7]`. Practically every
  CurseForge dependency therefore looked older than its declared range, DependencyBacktrack demoted
  it, and the demote loop walked each project's file list to the end before staging refused. Measured
  live the day after the backtrack shipped: 1014 re-stagings and 146 "publishes no ... file" lines in
  one day against 4 real staging failures.
- A staging refusal printed the bare slug for three of the five ways a dependency goes unmet, so
  "publishes nothing usable", "the download died" and "we dropped every build ourselves" were one
  sentence. Diagnosing the 47 rows needed a CurseForge API probe and a log grep on the daemon host
  purely because the verdict named no evidence.
- A jar-in-jar library was invisible to the coherence check, so a pack whose own jars contradict each
  other still booted and the candidate wore the INCONCLUSIVE (createaddition: create demands ponder
  [1.0.82,), the pack held the 1.0.64 nested in another jar).

Suites after: clientside 419, grinder 503 (29 skipped), app 149, zero failures.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Findings A-2, A-3, A-4, A-5 and A-7 of claude-docs/ANALYSIS-AUDIT.md, all green on landing: they
characterise behaviour that is already correct but was asserted nowhere, so each one is a mutation the
suite could not previously see.

- BundledVersionTest (new, 8 guards) pins `versionsIn`: the version read from a nested descriptor, a
  `provides` alias carrying it too, a nested mod with no version staying *present* via `idsIn` while
  contributing none, the Quilt spelling, unreadable input — and both ambiguity rules, which are the
  safety property and were the mutation nobody would have caught.
- NestedDependencyConflictTest gains `aTopLevelJarOutranksABundledCopyOfTheSameId`. That rule lived
  entirely in the operand order of `nestedVersions(...) + scanned…`. **Mutation-verified**: swapping the
  operands makes it fail with `create-6.0.8.jar` where `create-6.0.10.jar` belongs, and nothing else in
  the suite notices. Its sibling also moves from `contains` to an exact-set assertion (A-7), and the
  duplicated jar builder in it is gone — one parameterised harness now serves every case.
- UnmetDependencyReasonTest reaches the two reasons that were asserted only where the string is
  *rendered*, never where it is chosen: DISTRIBUTION_LOCKED and UNRESOLVED, both through real staging,
  plus `backtrackReason`'s three branches directly.

Two of these were red first for reasons worth recording, because both were faults in the test rather
than the code, and running them before committing is what separated the two:

- The fake downloader ignored `downloadUrl`, so a distribution-locked file "downloaded" fine and the
  case came back as a backtrack. It now mirrors `HttpJarDownloader`'s first line. A fake that is more
  capable than the real thing cannot test the path where the real thing refuses.
- The middle `backtrackReason` branch as first written asked the function about a state it is never
  called in (a filtered pick that would have succeeded). Reframed to the branch that does occur —
  exclusions present, nothing usable published either — and the precondition is now stated in the test.

clientside 419 -> 433, zero failures (re-derived from build/test-results, not incremented).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Committed red on exactly one guard, and the red is the finding: `20260908120000 exceeds Int.MAX_VALUE,
and reading it as 0 puts the version below the bound ==> expected: <true> but was: <false>`.

A-1 of claude-docs/ANALYSIS-AUDIT.md. `numbersOf` ends in `toIntOrNull() ?: 0`, and `readableVersion`
admits any component that is all digits — so ten-plus digits, which is what a date or a CI counter looks
like, parses to null and is read as **zero**. That is the `Balm 26.2.0.7` defect reached one door along:
all digits is not the same question as a number we can hold.

Latent, not live: nothing in the observed corpus hits it, and the cost is a wrong demotion rather than a
wrong sideness verdict. Pinned anyway, because this exact shape published 47 verdicts last week.

Two boundary guards land green beside it and stay: `Int.MAX_VALUE` itself still compares, and the
not-a-version edges (`v`, `1..2`, `1.`, `.1`, blank, `1_0`, `one.two`) all accept.

`1.0.0-rc1+build` is deliberately excluded from that edge list — first written into it, and it went red
for the right reason: its core is `1.0.0`, which is readable, and refusing it against `>=99.0` is
correct. It is now asserted as such, so nobody widens the guard into "anything ornamented accepts".

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Turns the A-1 pin green; clientside 436, zero failures, no existing assertion touched.

`readableVersion` asks `component.toIntOrNull() != null` instead of `all { it.isDigit() }`, which is the
question `numbersOf` actually needs answered — it ends in `toIntOrNull() ?: 0`, so the two predicates
disagreed exactly where the answer becomes zero. A version carrying a date or a CI counter now accepts
(no opinion) rather than comparing as though its largest component were nothing.

Chosen over widening `numbersOf` to `Long`, which moves the ceiling rather than closing the gap: the
same silent `?: 0` would still be there for anything past it, and this module's rule is that a value we
cannot read yields no opinion.

Sign-prefixed components cannot slip through the looser parse: `substringBefore("+")` and
`substringBefore("-")` have already removed everything from the first sign onward, so a `+5` or `-5`
component leaves an empty string, which `toIntOrNull` rejects.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Behaviour-preserving: clientside 436, zero failures, and not one assertion, argument or expected value
changed. Findings M-2 (REFACTOR-AUDIT) and A-6 (ANALYSIS-AUDIT) of the same-day pass.

M-2 — the "drop every id that resolved to more than one version" rule was written twice, once inside a
jar and once across a pack's jars. `BundledJars.unambiguous` is now the single implementation and
`BootVerifier.nestedVersions` calls it. Two copies of one rule is precisely the
MetadataScanner/ModListCompiler drift this module's context file opens with; it was caught the day the
second copy appeared, which is the cheapest it will ever be to merge them.

Safe to do now rather than earlier: BundledVersionTest and `aTopLevelJarOutranksABundledCopyOfTheSameId`
landed first and pin both levels of the rule, including the mutation that swaps the precedence.

A-6 — `explain()` returned `String?`, `null` meaning "the label already says this", and two log sites
interpolated it directly. Neither can reach that value today, so the literal `null.` would have appeared
only after some later edit, silently. It now always returns a sentence, and whether to append it to a
refusal is a rendering decision that lives in the renderer as `worthAppending`. The published refusal
strings are byte-identical, which is what keeps the label `refactor:`.

`nestedVersions` also becomes `internal` — it is pure, it takes its input as a parameter, and the
companion is where this file's other pure decisions already live.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The second half of A-2, reachable now that `nestedVersions` sits in the companion: two staged mods each
bundling a different build of one library yield no version at all, and two bundling the same build agree.

Asserted on `nestedVersions` directly rather than through staging on purpose. The end-to-end route would
have to infer which wrong version a broken fold kept, and that depends on `File.listFiles()` order — a
guard that fails only on some runs is worse than no guard, and this repository has paid for flaky
evidence before.

clientside 436 -> 438, zero failures.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Findings L-1, L-2 and L-3 of claude-docs/REFACTOR-AUDIT.md. Documentation only; suite green at 438.

L-1 — `DependencyBacktrack.Conflict` carried a one-line class doc and five bare properties. Each now
says what it is, and the type doc says what the pair of `versionConstraint` and `stagedVersion` is for:
together they are the whole finding.

L-2 — `Requirement` used a class-level `@param` block, which the root conventions call out specifically:
dokka does not attach those to the properties, so they render undocumented. Reshaped to one documented
parameter per line. Names, types, order and defaults are untouched, which is the only thing that reshape
is allowed to change.

L-3 — pre-existing, from `39d340340` (2026-08-23, `fix(clientside): re-check a crash across loaders and
Minecraft lines`), surfaced rather than deferred: two KDoc blocks were stacked before `bootableReleases()`,
so the block describing `bootableCombination()` sat above the wrong function and that one had no doc at
all. Moved to the function it describes. Nothing else in either file was touched — the Boy-Scout fix is
the move, not a rewrite.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Two appended sections, one per log, covering `300a4aae6^1..HEAD` — the 2026-09-06 field reports and the
eight commits that fixed what the third of them did to the live daemon.

REFACTOR-AUDIT carries the per-commit red/green verification: eight commits checked out in a detached
worktree and run individually, every pin red at its own commit and green at the next. It also carries the
methodology landmine that nearly produced two false findings — reusing one build directory across
checkouts made Gradle answer `No tests found` for a class that was in the source tree, which is the same
incremental-compilation trap `18f59b4bf` is already recorded for. Wipe the module build directory between
checkouts, and treat `No tests found` as its own outcome rather than as RED.

ANALYSIS-AUDIT carries the depth pass: one HIGH (the `Int.MAX_VALUE` repeat of the decorated-version
defect), four MEDIUM coverage and duplication findings, and a "do not re-litigate" list for the four
things that look wrong and are not — `backtrackReason` ignoring a constraint whose null-ness cannot
differ, `idsIn` being behaviour-identical after its extraction, the absence of any new warning or unused
import, and the absence of any new concurrency or security surface.

Both are appended, never overwritten: earlier sections are the evidence that stops the same ground being
re-argued.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Findings M-4 and M-5 of claude-docs/REFACTOR-AUDIT.md.

M-4 — REFACTOR-LOG's last entry was 2026-09-05, so neither the 2026-09-06 field reports nor the
2026-09-07/08 storm fix had a narrative anywhere outside their commit messages and the module context
file. Two entries appended: the three field reports in short form (each one's detail lives in
`serverpackcreator-clientside/CLAUDE.md`), and the storm with its three defects, the live measurements,
and the two lessons the same-day audit produced.

M-5 — the root status table said clientside 410 and its prose stopped at 2026-09-06. Now 438,
re-derived from `build/test-results` rather than incremented, with the header date moved off 2026-08-31.
The row also says what the backtrack cost before it was corrected, because a reader arriving at
`DependencyBacktrack` should meet that in the same breath as the feature.

The suite figures are deliberately given as two spans rather than one: 410 is what the tree carried at
`300a4aae6` and is measured, the 2026-09-06 batch reported 395 at its own commit, and chaining those
into a single number would state a figure nobody took.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
docs: close both audit logs — every finding resolved or accepted
All checks were successful
Documentation / Writerside webhelp (push) Successful in 4m50s
Continuous / Build JAR (push) Successful in 15m5s
Qodana / scan (push) Successful in 14m14s
Documentation / Help image (push) Successful in 8m44s
Continuous / Build AppImage (x86_64) (push) Successful in 3m34s
Test / build (push) Successful in 16m42s
Docker Test / build image (push) Successful in 33m33s
Continuous / Build AppImage (aarch64) (push) Successful in 3m1s
Qodana / notify (push) Successful in 1m46s
Continuous / Build Install4J Media (push) Successful in 10m40s
Continuous / Continuous Pre-Release (push) Successful in 4m43s
786b7bfe93
A resolution table per log, naming the commit that closed each finding, so the next reader can tell
"fixed" from "still open" without re-deriving it.

One finding is accepted rather than fixed: M-3, the behaviour-preserving refactor merged inside
`06c3ac3c5`. Splitting it now would rewrite shared history for a move that was disclosed in its own
commit message and changes no behaviour; it is recorded so the next audit does not re-raise it.

Both tables end on measured numbers rather than assertions: clientside 438, grinder 503 (29 skipped),
app 149, zero failures, result files timestamped after the last code commit — which is the check L-4
existed to demand.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Red on purpose, every guard failing with the one NotImplementedError from the two TODO() stubs
(`LoaderVersionDemand.unmetIn`, `BootVerifier.shouldRecheckOnNewestBuild`), so the test tree compiles
and nothing fails for a second reason.

BootVerifierCrashRecheckTest's own doc states the hazard this covers: "a mod needing a newer loader
than the cached build fails to load, the server exits non-zero, BootLogClassifier reads that as
CRASHED". `shouldRecheckCrash` re-boots such an outcome on the newest build before it may stand. Then
`dependencyFailureMarkers` was widened on 2026-08-29 and that console became INCONCLUSIVE, which the
re-check never looks at — the guard's premise moved out from under it.

Measured on the live daemon 2026-09-08, ten hours after a full clear: all 511 Fabric boots ran loader
0.19.3 while Fabric's stable is 0.19.5, and 17 of 42 DEPENDENCY_FAILURE rows are exactly this —
fabric-language-kotlin, a dependency of a great many mods, demands fabricloader >=0.19.5. Each is an
INCONCLUSIVE charged to a candidate over the harness's choice of loader build.

`CachedLoaderVersions` logs the property that does not hold when it reuses an older cached build:
"(a crash on it is re-checked against <newest> before it counts)". True of a crash; false of the
dependency failure that same choice actually produces.

Consoles are verbatim from live boot logs — Fabric from Modrinth/libipn, Quilt from
CurseForge/fzzy-config (which words it "requires version [0.19.5, ∞) of fabricloader"), and the
negative from CurseForge/createaddition's ponder demand. Only the FML loader case is constructed, from
the same message shape with the loader as the unmet id rather than a mod; that is stated in the doc.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Turns LoaderTooOldRecheckTest green; clientside 446, zero failures. `BootVerifierCrashRecheckTest`'s
four existing assertions are unchanged in substance — only the function name they call moved, and the
crash cases still behave exactly as they did.

`shouldRecheckCrash` becomes `shouldRecheckOnNewestBuild` and gains a second arm: a crash, **or** any
outcome whose console says the loader itself was too old. `LoaderVersionDemand.unmetIn` reads that,
matching a version-demand phrase and a runtime-provided loader id **on the same line**, which is what
separates "replace fabricloader 0.19.3 with 0.19.5" from `Mod ID: 'ponder' … Expected range '[1.0.82,)'`
— a demand no newer loader can satisfy, and `DependencyBacktrack`'s case, not this one.

SURVIVED is never re-run: it already answered the question.

Why it is not a BootRule: the rules file maps a console onto a verdict, this maps one onto "try again
differently". An operator's typo should cost accuracy at worst, never containers.

`mentions` requires a whole-word match so `forge` does not fire inside `forgeconfigapiport` — which is
a real id in the live store, and would otherwise have re-booted every pack that names it.

The re-check's log line and `reconcileRecheck`'s three notes said "crash" of an outcome that is now
often a dependency failure; they say "the boot"/"the failure" instead. The existing assertions match on
"52.1.16", "confirmed" and "could not be re-checked", all of which survive.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`./gradlew :serverpackcreator-api:updateManifests`, then the api suite re-run **against the copied
snapshot** rather than the pre-copy one the task itself depends on: 409 tests, zero failures, one skip,
`ShippedManifestSnapshotTest` included.

Measured, before -> after:

    fabric-manifest.xml         <latest>0.19.3</latest> -> 0.19.5   (lastUpdated 20260601 -> 20260828)
    quilt-manifest.xml          0.30.1-beta.2 -> 0.31.0-beta.4
    quilt-installer-manifest    0.15.0 -> 0.15.1
    neoforge-manifest-new.xml   26.2.0.41-beta -> 21.1.250
    forge / minecraft / fabric-intermediaries: content only, no <latest> element

The Fabric line is why this was done now. The daemon seeds version metadata from this snapshot at
startup and refreshes in a background coroutine; after Griefed cleared SPC_GRINDER_HOME the first
Fabric boot raced that refresh, installed what the stale snapshot named, and `CachedLoaderVersions`
has preferred that most-recently-used build ever since. Measured on the live daemon: **all 511** Fabric
boots ran loader 0.19.3, and 17 of 42 dependency failures were mods demanding `fabricloader >=0.19.5`.

**NeoForge's `<latest>` moving backwards is upstream behaviour, not damage.** Their maven `<latest>` is
whatever was published last, and 21.1.x LTS still receives releases after 26.2 betas; `NeoForgeMeta`
derives the newest build per Minecraft version from the version list, never from that element. Stated
because a reader diffing this commit will see a version number go down.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Red: `expected: <Alias(ref=yacl)> but was: <Guess(ref=yet_another_config_lib_v3)>`.

YACL's mod id carries its major version (`yet_another_config_lib_v3`); both platforms publish the
project as `yacl`, so the optimistic slug guess resolves to nothing and a guess never refuses — the mod
boots without it and the loader refuses the pack instead.

Observed twice before being added, which is the bar `KnownModIds` sets for growing the table:
Modrinth/do-a-barrel-roll on Fabric / Minecraft 26.2 failed with "requires any version of
yet_another_config_lib_v3, which is missing", and the zoomify backtrack case reached the same project
by its platform ref.

What makes it unreachable by any other route: **Modrinth marks YACL `optional` for do-a-barrel-roll
while the jar declares it under `depends`**. The platform half therefore filters it out — correctly,
that filter exists and was added deliberately — leaving the manifest half as the only way in.

Pinned as a shape (`yet_another_config_lib_v<digits>`) rather than one literal, for the reason the
Fabric API modules are: the suffix tracks the library's major version and has already moved once, so a
literal entry would go stale at the next major and cost the same debugging session again. The
counterweight guard keeps the shape narrow — another library with a versioned id is still only a guess.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Turns the pin green; clientside 448, zero failures, no existing assertion touched.

`yet_another_config_lib_v<digits>` joins the Fabric API and QSL module shapes as a recognised alias,
resolving to `yacl` on Modrinth and `667299` on CurseForge — both verified against the live APIs today
(Modrinth `slug=yacl id=1eAoo2KR`, CurseForge id 667299, title "YetAnotherConfigLib").

Third and last of the three fixes agreed for this round. Note that the id-learning work Griefed asked
about next would subsume this particular entry — YACL is *linked* from do-a-barrel-roll's Modrinth
page, so a learned mapping could read the id straight out of the downloaded jar instead of being told.
It is added anyway because it also covers the case learning cannot reach: a descriptor naming an id no
platform links at all.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Red: all seven guards fail with the single NotImplementedError from the TODO() stub, none for a second
reason.

Reported by Griefed from the live daemon: `CurseForge/aether` on Forge / Minecraft 1.20.2 published
"Required dependency unavailable … owo-lib (nothing published for this loader and Minecraft version)".
Both halves of that sentence are true — owo-lib publishes no Forge build at all — and the conclusion is
still wrong, because the Forge/NeoForge jar's mods.toml does not list owo-lib. Only Aether's Fabric and
Quilt builds need it; CurseForge's per-file relations carry it anyway.

The rule this pins: a platform's dependency list is a self-report by the author, while the jar's
descriptor is what the loader enforces, so where they disagree the descriptor wins. Same relationship
the ladder already encodes one layer up — the console decides, the metadata only declares.

The matching is fuzzy on purpose and the doc says why: an unstageable project can never be downloaded,
so its declared mod id is unknowable at this point and the comparison runs between the project slug and
the ids the descriptor names. `kleeslabs` declaring `balm-fabric` against a project published as `balm`
is the case that forbids an exact match, and a two-letter fragment is the case that forbids a loose one.
Both mistakes cost at most one container and neither can produce a wrong sideness verdict.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Turns PlatformDependencyDemandTest green and adds the end-to-end half; clientside 456, zero failures.

`CurseForge/aether` on Forge / Minecraft 1.20.2 was published ERROR for `owo-lib`. Every fact in that
refusal is true — owo-lib publishes no Forge build — and the conclusion is wrong: the Forge/NeoForge
jar's mods.toml does not list owo-lib, only the Fabric and Quilt builds do, and CurseForge's per-file
relations carry it regardless. The candidate wore a verdict about its project page.

A platform dependency that cannot be staged now refuses only when the staged jar's **own descriptor**
asks for it; otherwise it is recorded in `unmapped` — reported, never fatal — and the boot proceeds.
Both failure branches obey it, the unstageable one and the failed download, because the question is the
same in each.

The descriptor is read **once per staged jar** (`declaredDependencies`) and handed to both halves of
staging. They used to scan the same file separately, which is duplicated work and two chances to
disagree about what it said. `null` (no scanner, or a scan that threw) and empty are kept distinct:
`null` means we do not know and the platform stays in charge, which is the behaviour that predates this.

**Mutation-verified, because the end-to-end guard landed after the code it covers.** Disabling the
demand check makes `aPlatformDependencyTheJarNeverNamesDoesNotRefuseTheBoot` fail at the assertion that
the refusal is absent. The pure predicate was pinned red first, in its own commit; this is the
production wiring, and a predicate proved correct in isolation says nothing about whether production
consults it — the lesson `DependencySlugTest` exists for.

`aProjectPublishingNothingUsableSaysSo` is the counterweight and still passes: there the descriptor
does declare `yacl`, so the refusal stands exactly as before.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Red: all eight guards fail on the one TODO() stub.

Griefed's question was whether `KnownModIds` can be automated, and the answer is that most of its
entries are facts the grinder already held. Every entry today costs a human noticing a wasted boot,
reading a log and looking the project up on two platforms — while staging had the project downloaded
and its descriptor states the mod id.

What this pins: a jar staged under ref R that declares id X proves this platform serves X at R. That is
evidence, so a learned mapping is an Alias with the refusal rights an alias carries, while an id no jar
has proved stays a Guess. It compounds across candidates —
`yet_another_config_lib_v3` is unmappable by spelling, and the moment any candidate stages YACL through
a platform ref, every later candidate declaring that id resolves for free.

Two rules worth the guards: a ref is only valid on the platform it came from (a Modrinth base62 id is
not a CurseForge number), and the FIRST project to prove an id keeps it — two projects declaring one id
is an upstream collision this cannot adjudicate, and overwriting would make the answer depend on grind
order, the same reason `BundledJars.unambiguous` drops a contested version rather than picking one.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Turns LearnedModIdsTest green and proves it through the staging join across two grinds. clientside 466,
grinder 503 (29 skipped), app 149, zero failures.

Griefed asked whether `KnownModIds` can be automated. Most of it, yes: every entry is a fact staging
already held. It downloads the project and the jar's descriptor states the mod id — the table exists
only because nothing wrote that pair down.

`LearnedModIds` is that pair, recorded as each dependency is staged. The ref now travels with the
recursion (`stagedFromRef`), and `identityOf` reads the jar's own `id` + `provides` from the descriptor
it already has on disk. `stageManifestDependencies` consults what has been learned before the table, and
`platformRefFor` does too — they must agree, or the dedupe re-downloads what the learned map matched.

LearnedMappingStagingTest is the claim end to end: a library whose mod id (`mysterylib_v9`) resembles
its ref (`weird-slug`) not at all, is in no table, matches no shape, and which the platform will not
answer to by id. Grind the mod whose page links it, then grind a mod that only names the id — it stages.
Its counterweight runs the second grind with a fresh map and asserts the candidate stages alone, so the
pair cannot pass against a platform that simply answered to ids.

Bounded deliberately. It records what staging fetches anyway and never fetches a project to find out
what is inside it — the last step Griefed described, downloading a linked project *because* an id is
unresolved, closes the remaining gap (an id reachable only through an `optional` link, which is exactly
how Modrinth lists YACL for do-a-barrel-roll) at the cost of downloads on a path that currently fails
for free. That is a decision to take on its own, not one to smuggle in here.

Two rules keep it honest: only a jar's own identity is learned, never what it bundles — a nested
`fabric-api-base` belongs to Fabric API, and recording its host would send a later candidate to the
wrong project — and the first project to prove an id keeps it, because a collision this cannot
adjudicate must not resolve differently depending on grind order.

`learnedModIds` sits before `bootArtifactSink` in the constructor, which the parameter-order landmine
requires to stay last.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Re-derived from build/test-results after the loader re-check, the aether fix and the learned id
mapping, not incremented.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Red on both platforms, with the empty pool the field currently returns: `YACL is linked as optional and
is exactly the project this pool exists for: []`.

`requiredDependencies` is what gets staged and stays exactly as strict as it is. `relatedDependencies`
is a second, wider list that stages nothing by itself — it is the pool of projects worth *asking what
they are* when a required mod id resolves to nothing, which is the last step of the algorithm Griefed
described.

Optional links have to be in it or the reported case is unreachable: Modrinth/do-a-barrel-roll declares
`yet_another_config_lib_v3` under `depends` in its jar while Modrinth lists YACL for it as **optional**,
so the required list never mentions YACL and the id resolves to nothing by spelling. The link was on the
page the whole time.

Incompatible links are excluded deliberately — downloading a project the author declared incompatible to
read its id would be reading the right file for the wrong reason, and a match would then stage the one
jar that must not be there. Modrinth's `embedded` is out for a different reason: it is already inside
the jar, which `BundledJars` covers.

Fixtures use the live shapes: Modrinth's four dependency_types on do-a-barrel-roll's real version, and
CurseForge's numeric relationTypes 3/2/1/5.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Turns LinkedDependencyTest green; the whole clientside suite is green and `requiredDependencies` is
byte-identical on both platforms — every existing dependency guard still asserts exactly what it did.

Modrinth filters `dependency_type` to `required`+`optional`, CurseForge relationTypes to 3+2. Embedded
(Modrinth `embedded`, CurseForge 1) is already inside the jar, which is `BundledJars`' case, and
incompatible (5) is excluded on purpose: fetching a project the author declared incompatible in order
to read its id would be the right file for the wrong reason.

Nothing consumes the new list yet — this is the model half, split from the behaviour that uses it so
the widening can be reviewed on its own.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Red where it must be: `expected: <[Learner-1.0.0.jar, MysteryLib-9.0.0.jar]> but was:
<[Learner-1.0.0.jar]>` — the library the jar hard-depends on is not staged, because the only trace of
it anywhere is an optional link on the candidate's own page.

That is `Modrinth/do-a-barrel-roll` in miniature: it declares `yet_another_config_lib_v3` under
`depends`, Modrinth lists YACL for it as optional, and the id resolves to nothing by spelling. The
required list never mentions it, so today the boot goes ahead without it and the loader refuses the
pack — one container for a fact the page was carrying all along.

`nothingIsProbedWhileTheIdStillResolves` lands green and is the cost rule: probing is only worth a
download because the alternative is a wasted container, so a pack whose ids already resolve must not pay
for it. It asserts the exact set of files fetched, via a recording downloader, rather than trusting that
no extra work happened.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Turns the probe pin green. clientside 471, grinder 503 (29 skipped), app 149, zero failures.

The last step of the algorithm Griefed described, and it closes `do-a-barrel-roll`: the jar declares
`yet_another_config_lib_v3` under `depends`, Modrinth lists YACL for it as **optional**, and the id
resolves to nothing by spelling — so the required list never mentions it and the container was spent
booting a pack the loader immediately refused.

`askLinkedProjects` downloads what the page links, reads what each one is, and stops at the first that
declares the wanted id; the ordinary planner then runs again and stages it, so one code path still
decides what enters the pack and the injection record, recursion and dependency cap all still apply.

Gated exactly as asked — only for a requirement that is required (optional ones never reach that loop),
declared by the jar, and unresolvable by the learned map, the table and the slug guess. Against the
alternative, which is a whole wasted container, a jar download is cheap; against a pack whose ids
already resolve, it costs nothing at all, and that is pinned rather than assumed by asserting the exact
set of files fetched.

Everything probed is learned whether it matched or not, so a project identified once is never fetched
to be identified again. The probe copy is written outside `mods/` and deleted immediately: a project
that turns out to provide something else must not end up in the pack.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Re-derived from build/test-results after the linked-project probe, not incremented.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Red: two guards on the TODO() stubs, and `onlySomethingNewAnnouncesItself` on `expected: <1> but was:
<0>` — nothing announces anything yet.

Griefed asked for the map to be persisted to SPC_GRINDER_HOME, so clientside needs three things and no
more: a snapshot as plain data, a restore, and a hook telling an owner when there is something new worth
writing. The file, its format and its location stay in the grinder, where every other piece of daemon
state already lives (`JsonVerdictStore`, `JsonCursorStore`).

Two rules the guards state. Only a genuinely new pair announces itself: every staged dependency
re-declares its own id on every candidate that uses it, so persisting on each `learn` would mean a write
per staged jar for a document that did not change. And restoring is silent — loading a file at startup
must not immediately ask to write it back.

The snapshot shape is `platform -> id -> ref`, nested rather than flat, because a ref is meaningless on
the other platform and a joined-string key would let that mistake through the file too.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Six guards, all red on the one TODO() stub. The clientside half of the same change lands here green —
`snapshot`, `restore` and the news-only `onLearned` hook, which the previous commit pinned red.

The contract is `JsonCursorStore`'s deliberately, because the failure modes are identical and the
daemon's answer to them must be too: a missing file is a first start, a corrupt one is logged and
treated as empty rather than refusing to boot the service, and each write is temp-then-atomic-move so a
crash cannot truncate the document. Everything in this file is re-derivable by grinding, so losing it
costs some probe downloads while refusing to start costs the whole service.

`reLearningTheSameThingDoesNotRewriteTheFile` is the one that matters for cost: every staged dependency
re-declares its own id on every candidate that uses it, so a write per `learn` would be a write per
staged jar for a document that did not change. It asserts the mtime does not move.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Turns JsonLearnedModIdsTest green and wires it through the daemon. clientside 475, grinder 509
(29 skipped), zero failures.

What one run proves about which project serves which mod id, the next run now starts with — otherwise a
restart re-pays every probe download, which is the one cost that route exists to avoid.

`JsonLearnedModIds` follows `JsonCursorStore` exactly, because the failure modes are the same: loaded on
construction, whole document rewritten temp-then-atomic-move so a crash cannot truncate it, and an
unreadable file logged and treated as empty rather than refusing to start. Everything in it is
re-derivable by grinding, so losing it costs downloads while refusing to boot costs the service. Writes
are guarded too — an unwritable disk must cost the memory of what was learned, not the boot in progress.

`SPC_GRINDER_LEARNED_IDS` defaults to `~/.spc-grinder/learned-mod-ids.json`. Adding it to `KNOBS` made
`ReadmeConfigurationTest` and `SystemdUnitConfigurationTest` fail until the README table and the unit
file described it, which is exactly what that landmine promises — the red was the documentation, and it
is now written.

`ContainerCandidateVerifier` takes the map and shares one instance across every grind; its default keeps
a verifier built in a test free of a home directory. The daemon hands in the file-backed one.

Known and accepted: a learned pair is identity, not availability, so it does not go stale the way a
version does — but a project that renames its mod id would keep answering to the old one until the file
is deleted. That is a `rm` of a pure cache, and it is documented as such in the README, the unit and the
module context file.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
docs: clientside 475, grinder 509 in the root status table
All checks were successful
Documentation / Writerside webhelp (push) Successful in 2m7s
Continuous / Build JAR (push) Successful in 14m36s
Qodana / scan (push) Successful in 12m46s
Docker Test / build image (push) Successful in 19m30s
Documentation / Help image (push) Successful in 5m39s
Continuous / Build AppImage (x86_64) (push) Successful in 2m32s
Continuous / Build AppImage (aarch64) (push) Successful in 1m51s
Qodana / notify (push) Successful in 43s
Continuous / Build Install4J Media (push) Successful in 8m37s
Continuous / Continuous Pre-Release (push) Successful in 4m5s
Test / build (push) Successful in 35m31s
955319dda1
Re-derived from build/test-results after the learned-map persistence, not incremented. app stays 149.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Red, and the red is a dead JVM rather than an assertion: 53 ApiWrapper constructions, 268 log lines
mentioning `example-kotlin`, 15 OutOfMemoryErrors, and the api test task fails as a whole. That is the
defect Griefed reported as "the log-output for the example plugin a gazillion times", reproduced here
for the first time.

The chain:

  1. `ApiWrapper.api()` builds a wrapper. The companion's field is assigned only when the constructor
     RETURNS, and `@Synchronized` is re-entrant on the same thread, so it stays null throughout.
  2. The constructor runs `setup()` -> `stageThree()`, which touches `apiPlugins` FIRST.
  3. `ApiPlugins.init` calls `loadPlugins(); startPlugins()`, so pf4j runs plugin code from inside a
     lazy initialiser.
  4. `Example.init` calls `ApiWrapper.api()` six times. The field is still null, so a SECOND wrapper is
     built, which loads the plugins again, which…

Why the suite never caught it: tests share a JVM, and whichever class called `ApiWrapper.api()` first
did so before anything had copied a plugin jar into `tests/plugins`. `ExtensionScopingTest` installs one
in its own `@BeforeAll` and loads it by hand, long after the singleton is published, so the re-entrant
call returns it and nothing recurses. The defect needs a populated plugins directory at FIRST startup —
every real CLI run, and no test until this one, which installs the jar in `@BeforeAll` and then triggers
`api()` from a field initialiser.

There is a second cycle underneath, which the fix has to close as well: even with the singleton
published, `Example.init` reaches `ApiWrapper.api().serverPackHandler`, whose lazy initialiser needs
`apiPlugins` — and Kotlin's `SynchronizedLazyImpl` is re-entrant, so it does not block, it runs the
initialiser again and loads the plugins again.

`ApiPlugins.loadAndStart` is stubbed `TODO()` so the tree compiles and the first guard fails for one
stated reason.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Turns PluginLoadingOrderTest green and kills the recursion at both ends. Measured on the same
reproduction that was red one commit ago:

    ApiWrapper constructions   53 -> 0
    example-kotlin log lines  268 -> 7
    OutOfMemoryError           15 -> 0

api 412, app 149, clientside 475, grinder 509 (29 skipped), zero failures.

Two cycles, two changes.

`ApiWrapper.api()` publishes the instance BEFORE running setup. Setup loads plugins, plugin code calls
`ApiWrapper.api()`, and both `@Synchronized` and the inner `synchronized(this)` are re-entrant on one
thread — so assigning only after the constructor returned meant the re-entrant caller saw null and built
another wrapper. A failed setup still un-publishes, so a later call retries from scratch rather than
handing out a half-built wrapper; that was the one useful property of assign-on-success.

`ApiPlugins.loadAndStart()` replaces the constructor's `init`, and `stageThree` calls it **last**, after
`configurationHandler` and `serverPackHandler` exist. Without that, the plugin's
`ApiWrapper.api().serverPackHandler` entered that lazy from inside `apiPlugins`' own lazy initialiser,
and `SynchronizedLazyImpl` re-enters rather than blocking: the initialiser simply ran again and loaded
the plugins again. Fixing only the singleton would have swapped one recursion for the other.

The example plugin is deliberately left alone. Calling `ApiWrapper.api()` from a plugin's `init` is what
the example documents and what third-party plugins copy, so the API has to survive it; editing the
example would have hidden the defect rather than fixed it.

Two rows in claude-docs/API-BEHAVIOUR-CHANGES.md — `ApiPlugins` is published, and an embedder
constructing it directly now gets a manager with no plugins loaded until `loadAndStart()`. No signature
changed, so nothing fails to compile, which is precisely why it is written down.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The root CLAUDE.md listed this as open, pre-existing and CLI-only with the GUI unaffected. Two of those
three were understated: it is unbounded recursion rather than one failed instantiation, and nothing about
it is specific to CLI — it needs a populated plugins directory at first startup, which is every real run.

Both files now carry the measurement (53 wrappers / 268 log lines / OOM, against 0 / 7 / 0) and the
mechanism, including the part worth knowing well beyond this bug: Kotlin's SynchronizedLazyImpl re-runs
its initialiser on a re-entrant same-thread read rather than blocking, so two lazies that can reach each
other can loop.

Also records why the suite stayed green with the example plugin installed the whole time — a fixture
installed after the thing it is meant to exercise has already run is not a fixture. api 409 -> 412 in the
status table, re-derived.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Red on both halves: the client returns `null` where the feed says `CLIENT`, and the table's columns are
still `[, Name, Entry, Verdict, Loader, Platform, Scanned, Detail]`.

`Verdict` is the conclusion; `declared` and `jarScan` are the two readings it was concluded from, and
they disagree often enough to be worth reading beside it — on the live feed today, **161 of 2057** rows
are `CONTRADICTORY`, meaning the platform and the jar say different things about the same mod. The two
columns therefore sit immediately after `Verdict`: conclusion first, then what it rests on.

Values taken from the live feed rather than imagined: `declared` is CLIENT / SERVER / CONTRADICTORY or
absent, `jarScan` is CLIENT / SERVER_OR_BOTH / DEFERRED / ERROR. **18 of 2057 rows carry
`declared: null`**, so a guard pins that an unrecorded reading renders as an empty cell — "null" in a
table cell reads as a value rather than as its absence, which is the same conflation `textOrNull` exists
for one layer down.

The fields are on `GrinderVerdict` with `null` defaults so the tree compiles; nothing reads them yet.
Existing column guards iterate the columns rather than naming indices, so they neither changed nor
needed to.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Turns the pins green; plugin-grinder 69 -> 72, zero failures.

`GrinderVerdict` gains `declared` and `jarScan`, `GrinderClient` reads them off the feed, and the table
renders them immediately after `Verdict`: the conclusion first, then the two readings it was drawn from.

They earn the width. On the live feed today **161 of 2057** rows are `CONTRADICTORY` — the platform's
declaration and the jar's own descriptor disagreeing about the same mod — and that is precisely the row
a maintainer wants to look at by hand rather than take on trust. Reading the verdict without them says
what was concluded and not what from.

Both stay nullable and render as an empty cell when absent: 18 of 2057 rows carry `declared: null`, and
"null" in a table cell reads as a value rather than as its absence — the same conflation `textOrNull`
was written for one layer down.

Named `jarScan` after the feed's own field so the mapping is one hop, and labelled "JAR sideness", which
is what it means to someone reading the table.

Inherited, not designed, and worth knowing: the search box filters through a column-less
`RowFilter.regexFilter`, so it now matches these values too — typing `CONTRADICTORY` filters the table.

Not visually verified: the pane renders whatever the model reports, `AUTO_RESIZE_LAST_COLUMN` leaves
`Detail` absorbing the slack, and the only index anything outside the model names is `TICK_COLUMN`,
which is still 0.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Found by running the GUI with the plugin installed against the live daemon and looking at the tab, which
is the only way this class of thing shows up: at an equal share of the table the column rendered
`SERVER_OR_BO...` — and `SERVER_OR_BOTH` is not an edge case, it is 1718 of 2057 rows, so it clipped on
nearly every row of the table it had just been added to.

A *preferred* width of 140, not a minimum: the column still shrinks with the window, it just does not
start clipped. Same idiom the tick column already uses, one line below it.

`VerdictTableModel.JAR_SIDENESS_COLUMN` names the index rather than spelling `5` in the pane, and a guard
asserts the constant points at the column it names — the failure mode of a bare index is silently sizing
a *different* column, which nothing else would notice.

plugin-grinder 72 -> 73, zero failures.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Re-derived from build/test-results after the column-width guard.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
fix(plugin-grinder): widen Declared so CONTRADICTORY stops clipping
All checks were successful
Documentation / Writerside webhelp (push) Successful in 2m21s
Continuous / Build JAR (push) Successful in 14m51s
Qodana / scan (push) Successful in 13m0s
Docker Test / build image (push) Successful in 17m48s
Continuous / Build AppImage (x86_64) (push) Successful in 2m4s
Documentation / Help image (push) Successful in 6m33s
Continuous / Build AppImage (aarch64) (push) Successful in 2m1s
Qodana / notify (push) Successful in 24s
Continuous / Build Install4J Media (push) Successful in 8m15s
Continuous / Continuous Pre-Release (push) Successful in 4m17s
Test / build (push) Successful in 28m42s
adb21743a4
Second width found the same way as the first — by looking at the running GUI. The Other Verdicts tab
rendered `CONTRADICTO...` on every `3dskinlayers` row, which is the one value these two columns were
added to surface: 161 of 2057 rows, and the case where the platform and the jar disagree about a mod.
Clipping *that* defeats the point of having the column.

Preferred width 130, beside the 140 the JAR sideness column already had. `VerdictListPane` builds both
the Confirmed and the Other Verdicts pane, so one place sizes both.

`VerdictTableModel.DECLARED_COLUMN` joins `JAR_SIDENESS_COLUMN` as a named index, and the guard now
covers both — a bare index's failure mode is silently sizing a different column.

Verified after the change on the Confirmed tab, which carries a CONTRADICTORY row (`sodium` / NeoForge):
both `CONTRADICTORY` and `SERVER_OR_BOTH` now render in full.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`pickDependencyFile` refuses any Minecraft version but the exact one being
booted. That is right across a version-line and too strict inside one:
1.20.1, 1.20.2 and 1.20.3 run each other's mods, and a library that skipped
a patch release is not a missing dependency.

Measured against the live Modrinth API on 2026-09-09, six published ERROR
verdicts on grinder.serverpackcreator.de name a dependency that exists one
patch away -- playeranimator for Forge 1.20.2 (published 1.20, 1.20.1), yacl
and forgified-fabric-api for Forge 1.20.6, cobblemon for Fabric 1.21.11
(published 1.21.1), and QSL for Quilt 1.21.1/1.21.11 (published 1.21). QSL is
the case that shows the width is right: its last release is Minecraft 1.21 and
the project is discontinued, so every Quilt mod declaring a quilt_* module on
1.21.1 or later is refused permanently.

Red: 5 of the 9 guards fail for the missing fallback. The other 4 are the
boundaries it must not cross and pass already -- the version-line, the
NeoForge/Forge loader rule at 1.20.1 only, the exact match winning, and a
pre-release not being a patch neighbour.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A dependency publishing nothing for the exact Minecraft version being booted
now falls back to another patch release of the same version-line, nearest
first, ties going to the newer build. Only versions the project actually
publishes are considered, so the search is bounded by its real history.

The line stays the boundary the old refusal was right about -- a 1.19.4 jar in
a 1.20.1 pack is the conflict that rule exists to prevent -- and the fallback
widens nothing else. Cross-loading is still asked about the version the pack
BOOTS at, not the one the file carries, so a Forge 1.20.1 build is still not a
dependency for a NeoForge 1.20.2 pack.

The three preferences are now ordered explicitly in `preferenceLadder`:
obtainability, then the Minecraft version, then the declared constraint. That
promotes obtainability over the version match for the same reason it already
outranked the loader match -- a distribution-locked file has no download URL,
so an obtainable neighbour is a working dependency where a locked exact match
is nothing. The locked half of the ladder still runs last but does run, so a
project publishing only locked files still yields one and the refusal can name
the opt-out instead of claiming nothing is published.

484 tests, 0 failures (475 before, plus the 9 pins).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`stageableRequirements` drops a requirement that is optional, bundled inside
the candidate, environment-provided, or whose platform *ref* was already
resolved. It never asks whether the id is already in mods/. The ref dedupe is
not that question: a project is reachable under two equally-valid refs -- the
one the platform page links, and whatever LearnedModIds/KnownModIds maps the
manifest id to -- and when those differ the same id is looked up a second time
against a different project, whose "publishes nothing for this loader and
Minecraft version" then refuses the boot.

Ten published ERROR verdicts on grinder.serverpackcreator.de are this, measured
2026-09-09: create (copycats, create-steam-n-rails, createaddition on both
platforms), farmersdelight (ends-delight) and sophisticatedcore (both
unofficial Fabric ports). All ten declare the project as a platform dependency
too, so the jar was staged before the refusal was raised -- and Modrinth
project LNytGWDc publishes 17 Forge 1.20.1 and 11 NeoForge 1.21.1 files, so the
project publishing "nothing" is not the one in the pack.

Red: `aDependencyAlreadyInThePackDoesNotRefuseTheBoot` fails with the live
message verbatim, while its first assertion confirms the dependency really was
staged. `aDependencyMissingFromThePackStillRefusesTheBoot` passes already, so
the fix has to stay a dedupe rather than an amnesty.

The pure `stageableRequirements` guards land with the parameter they exercise
in the fix commit -- a parameter that does not exist yet cannot go red, only
fail to compile.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`stageableRequirements` now drops a requirement whose id the staged pack
already answers to, alongside the optional, bundled, environment-provided and
already-resolved-by-ref cases it dropped before. Every staged jar's own
identity (its `id` plus everything it `provides`) accumulates into a `provided`
set threaded through the staging recursion.

That closes ten published ERROR verdicts, measured 2026-09-09: create for
copycats/create-steam-n-rails/createaddition on both platforms, farmersdelight
for ends-delight, and sophisticatedcore for both unofficial Fabric ports. Each
declares the project as a platform dependency as well, so the jar was staged
and then the same id was resolved a second time -- under the ref the learned
map holds rather than the one the page links -- against a different project
that publishes nothing for the loader being booted.

Compared lowercased on both sides, unlike the neighbouring `bundledIds`, which
compares two ids read by the same scanner; a case mismatch here costs a boot.

The descriptor is now read once per staged jar for all three of its readers,
where `declaredDependencies` and `identityOf` scanned the same file separately
-- two chances to disagree about what it said. `scanStagedJar` returns the
`ScannedMod` list, `identityIn` derives the ids from it, and null still means
"could not be read" as distinct from "declared nothing".

489 tests, 0 failures (484 before, plus the 5 pins).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`LearnedModIds` records one ref per id and keeps whichever project proved it
first. That is the right call about overwriting -- grind order must not decide
the answer -- and the wrong one about forgetting: cross-loader forks and
unofficial ports deliberately keep the original's mod id, so whichever is
ground first owns the id for every loader afterwards. And a learned mapping is
an Alias rather than a Guess, so the wrong project's "publishes nothing for
this loader and Minecraft version" carries the right to refuse the boot.

The live row is `chefs-delight` on Forge / Minecraft 1.20.1, published ERROR
for `farmersdelight` (2026-09-09). It can only have come from the manifest
route: the platform route labels an unmet dependency with the resolved
project's slug, which is `farmers-delight`, and the manifest route refuses only
on a confident mapping, which KnownModIds does not give that id -- it gives a
Guess. So the alias came from the learned map, while the real project publishes
FarmersDelight-1.20.1-1.3.4.jar for Forge 1.20.1, verified live the same day.

Red: `aSecondProjectIsTriedWhenTheFirstCannotStage` stages only the candidate,
because the first ref's project has no build for the boot and nothing tries the
second. `anIdNoProvenProjectCanStageStillRefuses` passes already, so the fix
must not become an amnesty.

The unit-level guards over `refsFor`/`mappingsFor`/`planManifestDependency`
land with those signatures in the fix commit; a parameter that does not exist
yet cannot go red, only fail to compile.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`LearnedModIds` now keeps every ref that has proved an id, in the order they
proved it, and `planManifestDependency` tries each in turn. The first prover
still leads -- grind order must not decide the answer -- but it no longer owns
the id, and a project with no build for the boot in hand no longer ends the
search. Keeping every prover is what makes this loader-aware without adding a
loader dimension: `pickDependencyFile` already filters by loader and Minecraft
version, so whichever project fits is the one that stages.

That closes `chefs-delight` on Forge / Minecraft 1.20.1, published ERROR for
`farmersdelight` while Farmer's Delight publishes FarmersDelight-1.20.1-1.3.4
.jar for exactly that combination (verified live 2026-09-09). It is also the
general fix behind the create / farmersdelight / sophisticatedcore collisions
that the provided-ids dedupe closes from the other side.

The safety property is untouched: a refusal needs every mapping to have failed
and only an alias may raise one, so a guess that is almost resolvable twice is
still no worse than being unknown once. The registry's answer is tried last
rather than instead, and is skipped when it names a ref already learned.

The ref that actually staged is now claimed in `visited` too, since with
several mappings per id the ref `platformRefFor` claimed need not be the winner.

The persisted document's values become lists. `JsonLearnedModIds` reads the old
bare-string shape as well, because rejecting it would silently re-pay every
probe download the deployed daemon has ever made.

Existing expectations wrapped for the new return shapes (`mappingFor` ->
`mappingsFor`, ten call sites in ManifestDependencyTest, three in
LearnedModIdsTest, one `restore`): the values are unchanged, only the arity.
`aContestedIdKeepsTheFirstThingThatProvedIt` is renamed `...InFront` and its
doc says leading is not owning; its assertion is untouched and still green.

clientside 501 tests, grinder 509 (29 skipped), 0 failures.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Verdict.ERROR promises "an operator's problem, never evidence about the mod"
and carries three unrelated things: the host being broken, CurseForge
withholding a download, and a loader/Minecraft combination nothing upstream
ever published for. Only the first is an operator's problem, and mixing the
other two into the one bucket somebody is expected to read and fix is what
makes the bucket unreadable.

Measured over the public grinder's 53 ERROR rows, 2026-09-09: 15 are the mod's
own file being distribution-locked (corail-tombstone, entityculling,
not-enough-animations, skin-layers-3d, structory), 2 are a required dependency
being locked (better-combat-by-daedelus), ~14 are an upstream gap, and 4 are a
jar carrying only another loader's descriptor.

These guards assert what a prevented grind is NOT, because that is the whole
claim expressible before the verdicts that replace it exist -- an added enum
constant cannot go red, only fail to compile. It also stays the claim worth
keeping afterwards: whatever the vocabulary grows into, a CurseForge opt-out
must never be filed as ServerPackCreator's failure.

Red: the three "not our failure" guards all return ERROR.
`theHostsOwnTroubleIsStillAnError` passes already and is the counterweight --
generation failures and a broken loader cache have to stay visible.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Verdict.ERROR promises "an operator's problem, never evidence about the mod".
Over the public grinder's 53 ERROR rows on 2026-09-09, 17 were a CurseForge
allowModDistribution=false opt-out -- the mod's own file (corail-tombstone,
entityculling, not-enough-animations, skin-layers-3d, structory) or a required
dependency's (better-combat-by-daedelus) -- and ~18 were a loader/Minecraft
combination nothing upstream ever published for. The bucket an operator reads
to find work was mostly things nobody can fix.

LOCKED and UNVERIFIABLE now carry those. They are separate from each other
because a distribution opt-out is a named fact with a project, a file and an
author's decision behind it -- worth filtering for, and reversible by that
author -- where "nothing published for this combination" is an absence. Both
keep no logs: no container ran.

StagingOutcome.Prevented and Prepared.Failed now carry a typed
PreventionCause (HOST / DISTRIBUTION_LOCKED / UPSTREAM_UNAVAILABLE) instead of
only a sentence, BootOutcome.prevention replaces the boolean flag with
stagingPrevented derived from it, and VerdictPolicy.decide is the only place
that maps cause to verdict. Every default is HOST, so a refusal site that
forgets to say stays in the loud, actionable bucket.

UnmetReason owns its own cause, so a reason added later cannot reach a refusal
without somebody deciding whose problem it is. preventionCauseFor folds a set
to the most actionable present -- HOST beats a permanent fact because it is the
only one anybody can retry, and a named opt-out beats an absence.
DROPPED_BY_BACKTRACK is deliberately HOST: staging dropped those builds itself.

Verdict.grindRan exists because propagateClientOnlyProof asked
`== Verdict.ERROR` and would have silently missed both new verdicts; asking
"did anything run?" as a list of verdict names is how such a list loses one.

Grinder rank: CONFIRMED, INCONCLUSIVE, ERROR, LOCKED, UNVERIFIABLE, CLEAR, with
everyVerdictHasARank failing the build if a verdict is added without one -- an
unranked verdict sorts to 99, behind everything, silently. /as-properties still
gates on CONFIRMED alone. The plugin keeps the verdict as a string, so both
names render without a plugin release.

PreventedGrindBlameTest was rewritten to drive the real prepareBootPack and the
real refuseForMissingDependencies: the cause is now a field rather than
something inferable from a detail string, so a fixture passing it in would
assert only that a `when` branches on its argument. Its expectations are
unchanged. Mutation-verified -- forcing either cause site to HOST fails exactly
the three "not our failure" guards and leaves the counterweight green.
VerdictAggregationTest's fixture helper maps its boolean onto HOST; its
arguments and assertions are untouched.

clientside 515, grinder 512 (29 skipped), plugin-grinder 73, api 412 (1
skipped), app 149 -- all green, all re-derived from build/test-results. The app
suite needs a local MongoDB on 27017; without one its Spring context tests time
out and take the Gradle worker with them, which is unrelated to this change and
was confirmed by running it against mongo:8.0.5 in Docker.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Module notes for this batch, with the measurements they rest on:

- clientside: a new entry per fix -- the provided-ids dedupe, the learned
  multi-ref map, the patch-version fallback -- plus the prevention-cause /
  verdict split, each with its landmine. Three are worth reading before
  touching that code: every PreventionCause default is HOST on purpose, the
  patch fallback must not widen the loader rule (`compatibleAt`), and the
  blame guard cannot be a fixture that passes the cause in.
- grinder: the rank table and the log-retention rule now name six verdicts,
  and say that everyVerdictHasARank fails the build if a seventh arrives
  without one.
- grinder README: the "Interpreting confidence" section described HIGH/MEDIUM,
  a vocabulary retired on 2026-09-04, and the column list still said
  Confidence. Both replaced by a table of the six verdicts and what to do with
  each, with a pointer to VerdictField as the authority rather than the prose.
- root: status table counts re-derived (clientside 475 -> 515, grinder 509 ->
  512), and the two lessons that generalise past this module -- a category
  named after a consequence accumulates everything with that consequence, and
  a published report can be enough to locate the bug without host access.
- REFACTOR-LOG: the blow-by-blow, including what all 53 ERROR rows actually
  were and why three of the four red commits pin behaviour rather than the new
  signatures (an added enum constant cannot go red, only fail to compile).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`preferenceLadder` passed `whole + narrow` to `patchNeighboursOf`, and `narrow`
is a subset of `whole` in both arms -- the satisfying files intersected with the
obtainable ones, against the obtainable ones. The concatenation could only ever
repeat versions the function already de-duplicates, while reading as though the
narrowed set contributed something of its own.

Behaviour-preserving: same neighbours, same order. The nine patch-fallback
guards and the twenty-two in BootCandidateSelectorTest stay green with no
assertion touched.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
475 pre-existing clientside guards, zero failures against this branch's
production code, with exactly three files uncompilable -- each one of the
signature changes already enumerated, adapted by argument only with every
assertion byte-identical. Also notes that the app suite's green needs a local
MongoDB, so the next reader does not chase a Gradle worker dying on socket
timeouts.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
LOCKED and UNVERIFIABLE split out of Verdict.ERROR, plus the three ways a
dependency read as unavailable while being obtainable.

Read from the public grinder's 53 published ERROR rows on 2026-09-09: 17 were a
CurseForge allowModDistribution=false opt-out, ~18 a loader/Minecraft
combination nothing upstream ever published for, and 17 were resolution
defects. ERROR now means what its own doc promised -- an operator's problem --
because everything else has somewhere honest to go.

- LOCKED / UNVERIFIABLE, carried by a typed PreventionCause on
  StagingOutcome.Prevented and Prepared.Failed, with UnmetReason owning its own
  cause so a reason added later cannot reach a refusal unblamed.
- A dependency already in the pack can no longer refuse its own boot.
- LearnedModIds keeps every project that proves a mod id, not only the first.
- A dependency is staged from a neighbouring patch release of the same version
  line, nearest first, never across a line.

clientside 475 -> 515, grinder 509 -> 512, plugin-grinder 73, api 412, app 149.
Equivalence-checked against develop's unmodified test tree: 475 pre-existing
guards, zero failures.
Audit finding M-1 (claude-docs/ANALYSIS-AUDIT.md, 2026-09-09).

`refuseForSelfDeclaration` asks whether a jar's own descriptor accepts the
Minecraft being booted -- of the candidate only. Nothing asks it of the
dependencies: `DependencyBacktrack.conflicts` matches mod-id -> version
requirements and never reads `ScannedMod.minecraftConstraint`, although
`dependencyToDemote` already holds a `ScannedMod` for every staged jar.

That dimension used to be protected by the exact-Minecraft rule in
`pickDependencyFile` -- a dependency was never staged for another version, so
its descriptor could not disagree about one. The patch-version fallback
deliberately relaxed that and left the dimension ungated: a cobblemon Fabric
1.21.1 build now stages into a 1.21.11 pack, the loader refuses the pack, and
the candidate wears an INCONCLUSIVE that overwrites a decisive verdict. No
false CONFIRMED is reachable (a wrong-Minecraft library produces none of the
four decisive rungs), so the cost is a wasted container plus a downgraded
verdict.

The same gate closes the identical exposure in the cross-loader and untagged
fallbacks, both of which predate the patch fallback.

Red: `aDependencyWhoseDescriptorExcludesThePacksMinecraftIsDemoted` stages the
2.0.0 build that declares '~1.16.5' into a 26.2 pack. The other four pass and
are what keeps the gate narrow -- the fixture's range is asserted to really
exclude the release, silence and an unreadable range are both left alone, and
the candidate's own range never demotes a dependency.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Closes audit finding M-1. `dependencyToDemote` now demotes a staged dependency
whose own `minecraftConstraint` positively excludes the version being booted,
before the version-conflict pass and before any container is spent.

It is the dependency half of what `refuseForSelfDeclaration` does for the
candidate, and the gate the patch-version fallback needs: relaxing the
exact-Minecraft rule removed the only protection in that dimension. It also
closes the same exposure in the cross-loader and untagged fallbacks, which
predate it.

Everything uncertain accepts, which is what keeps it from becoming a
mass-demotion: an unreadable descriptor is already filtered by `descriptorRead`,
a jar declaring no range yields null, and VersionConstraint accepts any range it
cannot parse. It fires only on a positive, readable contradiction. The candidate
is excluded outright -- demoting it would verify a different mod, and dropping a
dependency over a range the candidate declared would blame the wrong jar.

Asked before the version conflicts deliberately: a jar naming another Minecraft
is one the loader refuses outright, where a version range is one mod's opinion
about another.

`UnmetReason.DROPPED_BY_BACKTRACK` now reads "every usable build was dropped
making the pack coherent" instead of naming a version conflict, because two
things reach it and the old sentence would be false for the new one. That is one
existing expectation edited in `UnmetDependencyReasonTest` -- a deliberate
behaviour change to a published refusal string, which is why this commit is
`fix:` and not `refactor:`.

Known residue, recorded rather than fixed: a project whose *every* build
declares the wrong Minecraft now ends as ERROR/DROPPED_BY_BACKTRACK rather than
UNVERIFIABLE, because the cause cannot tell "we dropped it" from "we dropped it
because upstream's builds do not fit" without a second exclusion channel.
Strictly better than the wasted boot it replaces.

clientside 520 tests, 0 failures.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Audit finding M-2. `preventionCauseFor` folds the causes present with
`first {}`, which throws `NoSuchElementException` on an empty map. Its only
caller guards it -- `refuseForMissingDependencies` returns null before reaching
it -- so it is unreachable today, which is precisely the shape this module has
paid for before: `UnmetReason.explain` returned null for a value no caller could
produce, and two log sites would have printed the literal `null` after some
later edit. An `internal` helper with no `require`, no doc saying "never empty"
and a name that reads total is a landmine.

Red with `NoSuchElementException: Collection contains no element matching the
predicate` -- the exception a second caller would get, from a grind worker,
naming an enum rather than a dependency.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Closes audit finding M-2. `firstOrNull { … } ?: PreventionCause.HOST` instead
of `first { … }`, so folding an empty unmet-dependency set answers rather than
throwing NoSuchElementException from a grind worker.

HOST is the answer for the same reason it is every other prevention default:
when nothing says whose problem it is, the loud and actionable reading is the
safe one. The KDoc now states the empty case, because a helper guarded only by
its caller is how `UnmetReason.explain` came to return null for a value two log
sites would have interpolated.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Four guards over code that is already correct. Green on arrival, and each
mutation-verified rather than assumed.

M-3 `onlyConfirmedIsEverPublished` — the publication gate was pinned by four
hand-written rows plus twenty ERRORs, so LOCKED and UNVERIFIABLE joined the
population it exists to protect against without appearing in one assertion.
Now driven over `Verdict.entries`, so a seventh verdict is covered with no edit.

M-4 `everyVerdictIsClassifiedForRetention` replaces
`everyVerdictButClearKeepsItsLogs`, whose assertions stayed green while its
*name* became false — LOCKED and UNVERIFIABLE discard too. Asserted as the
partition of `Verdict.entries`, so an unclassified verdict fails the build, with
the per-verdict reason in the doc. Worth recording: the obvious-looking rule,
partitioning on `grindRan`, is **wrong** — ERROR keeps its logs despite nothing
having run, because an admin has to diagnose the host. The first draft of this
guard asserted that invented rule and went red against correct code.
`VerdictColumnTest`'s half of the drift guard is derived the same way.

M-5 `concurrentLearnersKeepEveryRefExactlyOnce` — `LearnedModIds`' class doc
ends "thread-safe: the grinder shares one instance across its grind workers",
and nothing in either module started a second thread. The value behind an id
was an immutable String under `putIfAbsent` until this batch; it is now a
CopyOnWriteArrayList mutated after a `computeIfAbsent`. Sixteen writers off one
latch, each proving a distinct ref for the same id, twice each. Mutation:
`addIfAbsent` -> `add` fails it.

L-1 `anExactVersionTheConstraintRejectsBeatsANeighbourItAccepts` — the middle
rung of `preferenceLadder`'s three-way ordering was claimed by the KDoc and
asserted nowhere. Mutation: hoisting the constraint tier outside the version
loop fails it.

clientside 522, grinder 514 (29 skipped), 0 failures.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Audit finding L-2. `LearnedModIds.restore` claims the map entry with
`computeIfAbsent` before filtering the refs, so an id carrying a JSON null, a
number or an empty array leaves an empty list behind -- which `snapshot()` then
writes back as `"id": []`. The document accumulates entries that assert nothing
and grow on every restart, and it is harmless to read, which is exactly why
nothing would have noticed.

The fixture is a mixed-shape document -- the legacy bare-string form beside the
current list form -- because that is what a daemon mid-upgrade really holds, and
it covers the reader for both shapes at the same time.

Red: three junk ids survive the round trip as empty entries.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Closes audit finding L-2. `restore` filters the refs before claiming the map
entry, so an id whose stored value contributes nothing leaves nothing behind
and `snapshot()` no longer writes it back as an empty array.

`learn` never had this: its ref is non-blank-guarded at the top.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Closes audit finding L-3. The dedupe mapped the learned aliases a second time
inside its own filter and nested two `it`-shadowing lambdas; comparing against
the ref list directly says the same thing once.

Behaviour-preserving: same list, same order. `theRegistryDoesNotRepeatALearnedRef`
and the rest of the suite stay green with no assertion touched.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A-2 — three new `!!` in `PreventedGrindBlameTest` on
`refuseForMissingDependencies`' nullable return. The conventions forbid new `!!`
in refactored code and do not exempt tests; `requireNotNull` says the same thing
and fails with a sentence rather than a NullPointerException.

A-3 — `var refusal` in `planManifestDependency` now carries its reason. It is a
genuine accumulator: the loop stops at the first mapping that stages, so a
second project is never resolved for nothing, while remembering the first
alias's reason in case none does. Without the note the next reader sees only a
`var` where the conventions ask for a `val`.

A-5 — `[BootObservation]` in `Verdict.kt` linked a type that exists nowhere in
the repository, twice, so dokka resolved it to nothing. Pre-existing, but that
file was rewritten substantially in this batch, so the Boy-Scout rule reaches
it. Now `[BootResult]`, which is the type actually meant, and the sentence names
all three prevented verdicts rather than only ERROR.

Behaviour-preserving throughout: no assertion, argument or expected value
touched. clientside 522, 0 failures.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
- clientside: a new entry for `outsideThePacksMinecraft` — why the patch-version
  fallback needed it (the exact-Minecraft rule *was* the protection in that
  dimension), what keeps it from becoming a mass-demotion, why the candidate is
  excluded, why it is asked before the version conflicts, and the known residue
  where a project's every build declares the wrong Minecraft.
- clientside: the testing section's two stale figures are gone. It said "257
  tests" against 523 and "Four need a resource" against twelve, having already
  been wrong once the same way — both are now stated as the commands that answer
  them, with that history recorded so the next writer does not restate a number.
- root: clientside 515 -> 523, grinder 512 -> 514.
- ANALYSIS-AUDIT / REFACTOR-AUDIT: a resolution section each, every finding
  closed or explicitly declined with its reason.

Finding A-6 is fixed in the analysis section itself: every commit hash is
replaced by its subject, because that file accumulates and a rebase killed
thirteen hashes in its sibling on 2026-09-01. The same pass caught a `file:line`
citation the same convention forbids.

Two things the resolution records rather than hides. The M-4 guard's first
implementation asserted a rule invented for it -- retention partitioned on
`grindRan` -- and went red against correct code, because ERROR keeps its logs
despite nothing having run. And M-4 under-reported: the same hand-written-list
defect sat in `VerdictPublicationTest`, whose name this batch had made false.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
docs: the analysis and audit, in the refactor log
All checks were successful
Documentation / Writerside webhelp (push) Successful in 3m13s
Qodana / scan (push) Successful in 11m23s
Docker Test / build image (push) Successful in 17m42s
Continuous / Build JAR (push) Successful in 20m8s
Qodana / notify (push) Successful in 15s
Documentation / Help image (push) Successful in 3m57s
Continuous / Build AppImage (x86_64) (push) Successful in 3m5s
Continuous / Build AppImage (aarch64) (push) Successful in 4m9s
Test / build (push) Successful in 17m34s
Continuous / Build Install4J Media (push) Successful in 10m1s
Continuous / Continuous Pre-Release (push) Successful in 5m53s
Docker Test / build image (pull_request) Successful in 18m9s
Test / build (pull_request) Successful in 25m58s
86d3d441b7
The per-commit verification is the part worth quoting: each commit in its own
fresh worktree -- never a reused build directory, which is what reported
"No tests found" for a present class the day before -- with the whole clientside
suite run so a filter cannot silently match nothing. 10 red, 33 green across
nine commits, zero collateral failures, so `git checkout <fix>^` really does
show the missing implementation at all four test/fix pairs.

The analysis's headline finding is a consequence of the batch rather than a
pre-existing defect: relaxing the exact-Minecraft rule removed the only gate in
that dimension, and `outsideThePacksMinecraft` closes it.

Two lessons recorded for reuse. A guard can assert a rule invented for it -- the
retention drift-guard's first implementation partitioned on "did a container
run?" and went red against correct code, because ERROR keeps its logs despite
nothing having run. And a guard that enumerates the values it knows about stops
covering the vocabulary the moment it grows: `everyVerdictButClearKeepsItsLogs`
stayed green while its own name became false.

The equivalence note is corrected from three adapted files to four: the audit
fix changed `DROPPED_BY_BACKTRACK`'s sentence, which is the batch's one genuine
expectation change.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Griefed merged commit 02437974fd into alpha 2026-09-09 20:42:18 +02:00
Sign in to join this conversation.
No reviewers
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set

Reference
Griefed/ServerPackCreator!673
No description provided.