Eat the rich! #677
No reviewers
Labels
No labels
accepted
bug
dependencies
docker
documentation
duplicate
enhancement
github-actions
github_actions
good first issue
gradle
hacktoberfest-accepted
help wanted
invalid
javascript
not-an-issue
npm
question
rejected
wontfix
Working on it
No milestone
No project
No assignees
1 participant
Notifications
Due date
No due date set.
Dependencies
No dependencies set
Reference
Griefed/ServerPackCreator!677
Loading…
Reference in a new issue
No description provided.
Delete branch "develop"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
RED on purpose. Both guards fail against current code, and they fail for the missing implementation rather than for a fixture fault -- the latch simply never fires, because the worker thread is gone. TaskExecutionServiceImpl drains a LinkedBlockingDeque from one `while (true)` loop whose only catch is InterruptedException. checkModpack itself throws (`throw StorageException("ModPack-file for ... not found.")` when the archive a queued row names is absent), and checkConfiguration and ServerPackHandler.run can throw anything. Such a throw escapes processTask, escapes the loop, and terminates "GenerationThread" for the lifetime of the process: no supervisor, no restart, no QueueEvent, no status change. Every later upload then sits in QUEUED forever and the affected pack in CHECKING forever, and neither of them is reaped -- DatabaseCleanupSchedule only removes ERROR rows and rows whose file is gone. The class had no test of any kind, which is why a defect this size was invisible. Two asserts, both against the real thread: - a task queued behind a throwing one is still processed - a task that threw is reported as ERROR instead of vanishing Measured before committing: both fail after the full 10 s latch timeout. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>RED on purpose. Measured before committing: app suite 195 tests, these 3 failed, each with java.nio.file.NoSuchFileException: <root>/modpacks/1790107642029-orig-../../../escaped.zip which is the mechanism itself, not a fixture fault. StorageSystem.store(MultipartFile) builds its destination by concatenating getOriginalFilename() -- whatever the client put in Content-Disposition, which Spring hands over verbatim, separators included -- onto a "<millis>-orig-" prefix, with no sanitisation and no containment check, while FileSystemStorageService.store two calls downstream carries one on a path built from a generated ObjectId and comments it "This is a security check". The guard is on the internal path and absent from the external one. Measured against a real Tomcat + Spring 7.0.8 stack before writing these: the filename does arrive intact, and from three "../" up the resolved path does leave the storage root -- but nothing is ever written there. "-orig-" concatenates without a separator, so the first component is the literal name "<millis>-orig-..", which is not an existing directory, and the OS resolves ".." only through directories that exist. The open fails with ENOENT first. So this is hardening, not a live arbitrary write, and it is worth saying plainly: the only thing standing between client input and an arbitrary path is an accident of string concatenation that stops holding the moment the prefix changes. What *is* live is the third guard: that IOException is caught by nobody -- ModPackController catches StorageException only -- so any filename containing a separator is an unhandled 500, and the shipped application.properties sets server.error.include-stacktrace=ALWAYS on an endpoint with no auth and @CrossOrigin("*"). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>Turns the three guards from the previous commit green. The client's filename is now reduced to its base name before it can reach a File, and the destination is checked to sit directly under the storage root -- the same check FileSystemStorageService already applies to the internal path, now also on the external one, which is where the untrusted string actually is. That replaces a containment property that until now held only by accident: the "-orig-" prefix concatenates without a separator, so a leading ".." became part of a directory name that does not exist and the open failed with ENOENT. True today, and true only until the prefix changes. The live half of the defect is the error handling. transferTo throws IOException (and IllegalStateException), ModPackController catches StorageException only, so until now any filename carrying a separator produced an unhandled 500 with a full stack trace and absolute server paths -- the shipped application.properties sets server.error.include-stacktrace=ALWAYS, on an endpoint with no authentication and @CrossOrigin("*"). Both are now caught and reported as an empty result, and ModPackService turns that empty into a StorageException the controller already answers as a 400, instead of calling Optional.get() on it and raising NoSuchElementException -- another 500 by the same route. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>Two of the five are RED, and each fails for the missing implementation rather than for a fixture fault -- measured before committing: loadingAnIdThatGridFsDoesNotHoldIsEmptyRatherThanAThrow -> java.lang.NullPointerException: findOne(...) must not be null deletingRemovesBothCopiesOfTheFile -> Verification failed: GridFsTemplate.delete(any<Query>()) was not called The third guard is green and is the one the other two rest on. StorageSystem stores a file under the ObjectId GridFS minted and then passes that id around as a String, so every read and delete queries _id with a String against a field holding an ObjectId. Asked directly of Spring Data's own QueryMapper -- no database, the same approach this module already uses for its index and collection-name declarations -- a valid 24-hex String is converted to an ObjectId, so the lookup does match. Worth pinning precisely because the failure mode if it ever stops holding is silence: a query that matches nothing is indistinguishable from a file that is not there. Note this corrects an assumption made while reading the code: the query is not broken. What is broken is the miss, which returns Optional.of(Pair(null, ...)) and therefore an NPE -- reached whenever the filesystem copy is gone, which is the ordinary state after cleanup, turning an intended 404 into a 500 with a stack trace the shipped config sends to the client. deletingRemovesTheArchiveFromDiskButLeavesTheGridFsCopy, added an hour ago to characterize the leak, becomes deletingRemovesBothCopiesOfTheFile. Changing an existing expectation is the stop-and-flag signal for a refactor; this is a fix, and the expectation it asserted was the defect. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>All four RED, each for the missing implementation. Measured before committing: anUploadThatFailsValidationLeavesNothingUnderTheStorageRoot expected: <[]> but was: <[651f3c0e9a1b2c3d4e5f6071.zip, 1790108096710-orig-pack.zip]> anUploadThatFailsValidationIsNeverWrittenToGridFs Verification failed: GridFsTemplate.store(...) should not be called; Calls: 1) aDuplicateUploadIsNotStoredASecondTime Verification failed: GridFsTemplate.store(...) should not be called; Calls: 1) anAcceptedUploadLeavesExactlyOneArchiveAndNoLandingCopy expected: <[...zip]> but was: <[...zip, 1790108096912-orig-pack.zip]> saveUploadedFile stores before it validates: the landing copy, the GridFS document and the final archive are all written, and the file fully hashed, before checkZipArchive is consulted and before the duplicate check runs. Both rejection paths then throw with no cleanup. So a rejected upload costs exactly what an accepted one costs, and an anonymous caller can repeat it. The filesystem half is reclaimed at 00:30 by FileCleanupSchedule. The GridFS half is not reclaimed at all, and cannot be: the sweep works back from ModPack rows, and a rejected upload never gets one. The fourth guard is the one that keeps the fix honest -- it pins that an accepted upload leaves its archive and nothing else, so the landing copy stops being something a nightly cron has to mop up. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>Completes the GridFS lifecycle. delete() already reclaims both copies; the nightly file sweep did not, so an orphaned archive it unlinked left its database twin behind for good. It now routes any file named after a storage id -- 24 hex characters, i.e. an ObjectId -- through ModPackService/ServerPackService deleteStoredFile, and unlinks only what has no twin, such as a landing copy left by a crash. After the validate-before-store change this is a narrow window rather than the routine path: a rejected upload no longer reaches GridFS at all, so what is left is a crash between store() and save(). Narrow is not the same as closed, and a half-managed second tier is what produced the original leak. Also drops the non-null assertion on ModPack.fileID. The server-pack branch three lines below already filtered nulls first; the modpack branch did not, so a single row with a null fileID aborted the entire nightly pass with an NPE before anything was deleted. Both branches now use mapNotNull. The guards could not be committed red ahead of the change: expressing "the GridFS twin was reclaimed" requires the services the constructor did not yet take, and a guard that cannot compile is not a red pin. Teeth were instead verified by mutation, both reproduced against this commit: deleteStored(storageId) -> file.deleteQuietly() => anOrphanedArchiveIsDeletedThroughStorageSoItsGridFsTwinGoesWithIt FAILED mapNotNull { it.fileID } -> map { it.fileID!! } => aModpackRowWithoutAFileIdDoesNotAbortTheSweep FAILED => aServerPackRowWithoutAFileIdIsSkippedRatherThanFailingTheSweep FAILED The loop body is extracted to a private sweep() shared by both roots, which is what let the two branches stop disagreeing about null handling. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>All three RED, each for the missing implementation. Measured before committing: downloadingAModpackStreamsTheArchiveInsteadOfCopyingItIntoTheHeap the whole archive was read into memory to serve it ==> expected: <false> but was: <true> downloadingAServerPackStreamsTheArchiveInsteadOfCopyingItIntoTheHeap the whole archive was read into memory to serve it ==> expected: <false> but was: <true> rejectingAnEmptyUploadDoesNotReadTheUploadIntoMemory java.lang.AssertionError: the controller read the whole upload into memory to check it was not empty spring.servlet.multipart.max-file-size ships at 5000MB and a Java array cannot hold more than about 2 GB, so every one of these is an OutOfMemoryError on a large pack and on several concurrent medium ones. Both download handlers answer with ByteArrayResource(archive.readBytes()); the upload guard calls file.bytes.isEmpty() when file.size == 0L on the line above has already answered that, and the || short-circuit means it runs on every non-empty upload. The third guard works by overriding getBytes() to throw, so it fails if the controller so much as asks -- which is the only way to observe "did not materialise it", since a successful call looks identical either way. The controllers are called directly rather than through MockMvc for the same reason: the question is what the controller touches, not what the framework does around it. The first run of the server-pack guard failed for a fixture fault instead -- a justRun on updateDownloadStats, which returns Optional<ServerPack>, produced "ClassCastException: kotlin.Unit cannot be cast to Optional". Fixed and re-run before committing, because a guard that goes red for its own mistake pins nothing. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>All three RED, each for the missing implementation. Measured before committing: DatabaseCleanupScheduleTest.aModpackRowWithoutAFileIdDoesNotAbortTheSweep -> java.lang.NullPointerException DatabaseCleanupScheduleTest.aServerPackRowWithoutAFileIdDoesNotAbortTheSweep -> Unexpected exception thrown: java.lang.reflect.InvocationTargetException FileCleanupScheduleTest.aRepositoryThatReturnsNoRowsAtAllDoesNotWipeTheDirectory -> Verification failed: ModPackService.deleteStoredFile(any<String>()) should not be called; Calls: 1) DatabaseCleanupSchedule dereferences modpack.fileID!! and serverpack.fileID!!. A server pack has no fileID until its generation finishes, so any pack still in flight at midnight ends the pass -- and it ends it partway through, after some rows have already been deleted. FileCleanupSchedule deletes every file no row refers to, so a repository that returns nothing means every file is an orphan. Correct for a genuinely empty installation; catastrophic for one pointed at the wrong database, which this project has already shipped once -- Boot 4 retired spring.data.mongodb.uri and the app silently used Mongo's default `test` database. A destructive nightly job should not be how that gets discovered. The empty-repository guard first passed for the wrong reason: deleteStoredFile is mocked, so "the file is still there" was true whether or not the sweep had asked for it to go. Rewritten to assert on the delete calls. Asking why a guard passed is the same question as asking why it failed. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>Two RED, one green, and the green one is the point: it discriminates. Measured before committing: aModpackCarryingAManifestStillHasItsServerIconFound -> the icon was not found; serverIconPath was '' ==> expected: <true> but was: <false> aModpackCarryingAManifestStillHasItsServerPropertiesFound -> server.properties was not found; path was '' ==> expected: <true> but was: <false> aModpackWithoutAManifestKeepsFindingItsServerIcon -> PASS isZip ends by looking for server-icon.png and server.properties under `packName`, which is whatever checkManifests returned -- a display string such as "A Manifest Named Pack", not a path. updatePackName confirms it: the name comes straight out of the JSON, and its fallback is File(modpackDir).name, i.e. the bare directory name. So File(packName, "server-icon.png") resolves against the JVM's working directory and cannot exist. Only the no-manifest fallback, where packName is set to the extracted directory, actually works. That inverts which modpacks get the feature: a plain zip keeps its icon, and the CurseForge, GDLauncher and MultiMC exports -- the ones with manifests, i.e. most real modpacks -- silently lose both files from their server pack. Silently is the operative word, since an absent icon is indistinguishable from a modpack that never had one. Fixture is built in the test rather than added as a binary: the existing zips carry manifests but no icon, which is exactly the combination that cannot show this. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>Both RED, asked of Spring Data's mapping context rather than of a database. Measured before committing: aRecordedDownloadIsNotIdentifiedByItsTimestamp -> ModPackDownload is identified by its timestamp, so same-millisecond downloads overwrite each other theDownloadStatsSortNamesAFieldTheDocumentActuallyHas -> sorted on [date], but ModPackDownload only has [downloadedAt, modPack] Two defects, and neither reports itself. ModPackDownload and ServerPackDownload carry @MongoId on `downloadedAt`, a millisecond Date -- a global id, not a per-pack one, so any two downloads of any two packs in the same millisecond collide and save() overwrites the earlier row. The loss is invisible because the download *counters* live on the packs and are unaffected, so only the history thins out. DownloadStatsService then sorts all four of its queries on "date", which no download document has. MongoDB does not reject a sort on an absent field; it simply does not order, so the stats endpoints have been returning arbitrary order while looking sorted. Same shape as EventService.loadAll's "dateCreated", which QueueEvent also does not have. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>/api/v2/modpacks/upload has no authentication, no CSRF and @CrossOrigin("*"), and saveUploadedFile joins ConfigCheck's errors verbatim into the 400 body. Two of those errors embedded the absolute path of the archive on the server, so a caller learned the deployment's directory layout by uploading a file that is not a ZIP. They now name the file instead; the full path is still logged, where it is useful and not public. server.error.include-stacktrace goes from ALWAYS to NEVER for the same reason -- any unhandled exception returned a full stack trace, with package structure, versions and absolute paths, to whoever asked. include-message stays ALWAYS: those messages are ours and are what the SPA shows the user. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>Three RED, one green discriminator. Measured before committing: aTrailingDotIsTrimmedWithoutTouchingTheOthers expected: <My Pack v1.2> but was: <My Pack v12> aTrailingSpaceIsTrimmedWithoutTouchingTheOthers expected: <My Pack v1> but was: <MyPackv1> severalTrailingOffendersAreAllTrimmed expected: <All the Mods 9> but was: <AlltheMods9> theIllegalCharactersAreStillRemovedAndSeparatorsStillAreNot -> PASS StringUtilities.pathSecureTextAlternative strips a trailing "." or " " -- Windows rejects a file name ending in either -- by taking the last character and calling replace() with it, which removes *every* occurrence in the string. A trailing space therefore deletes all spaces, and a trailing dot deletes every separator in a version number. Published API, and it now has no call site left inside this repo: its only one was the server-icon lookup removed two commits ago. So an embedder is the only person who can reach it, which is precisely why it needs a guard rather than a deletion. The fourth case is there to keep the fix honest: this method deliberately does NOT strip / or \, which its own KDoc states and which a "tidy it up with a path sanitiser" fix would quietly change. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>Adds de.flapdoodle.embed.mongo.spring4x, which starts a real mongod in the test JVM. No Docker -- Docker's MongoDB images refuse to start on Linux kernels 6.19+ (SERVER-121912), which is what made a containerised database unusable here and left the end-to-end verification outstanding. H2 was asked about first and is not an option: the driver speaks the MongoDB wire protocol and ConnectionString accepts only mongodb:// and mongodb+srv://. That is precisely why the JPA-era `spring.data.mongodb.uri=jdbc:h2:mem:testdb` line this module's CLAUDE.md records was a hard startup failure rather than a fallback. WebPersistenceIT asks the five questions no mocked test can answer, all now verified against a real database rather than against Spring Data's machinery: - the sha256 index really is created by DeclaredIndexCreator on ApplicationReadyEvent, so the upload duplicate-check is not a collection scan; - an upload round-trips through GridFS *and* the filesystem, reports its size as the archive's real byte count, and delete() reclaims BOTH copies -- the leak this pass closed, now proven rather than argued; - a refused duplicate writes no GridFS document, which is the validate-before-store change proven end to end; - the RunConfiguration migration flattens a legacy DBRef array and leaves an already-migrated document alone; - a modPackDownload row written before the @MongoId change -- timestamp as _id, no downloadedAt field at all -- still reads back. That one was a real risk this pass introduced and could not be settled any other way. EmbeddedMongoAvailable skips a class, rather than failing it, where mongod cannot start: CI runs on ubuntu-latest and the kernel is not ours to pin. Verified by mutation -- forcing the probe to throw reports 11 skipped and a green build, not a red one. The KDoc says plainly that a skipped guard proves nothing, because a CI run that skips these has no database coverage and only the skip message says so. LANDMINE, found by this commit breaking four tests: flapdoodle's EmbeddedMongoAutoConfiguration activates for EVERY Spring context on the test classpath and throws "Set the de.flapdoodle.mongodb.embedded.version property" when it is absent. The dependency is not inert -- every @SpringBootTest must opt in or opt out. DatabaseUriPropertyTest and DeclaredIndexStartupTest opt out, and must: the first asserts that the *configured* URI reaches the driver, which an embedded server overrides (measured -- mongod bound port 56242 while the configured URI still said 27017 and the write went to 56242), and the second is literally named theContextStartsWithoutADatabase. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>WebServiceContextTest now starts with a real mongod rather than none. Measured on :serverpackcreator-app:test: WebServiceContextTest 60.37s -> 0.99s (and now WITH a database) app suite wall 147.6s -> 28.2s 220 tests, 0 failures Its bean-wiring coverage is unchanged; what is added is that the two ApplicationReadyEvent listeners -- DeclaredIndexCreator and the migration runner -- are actually exercised instead of merely constructed, and that startup has to work rather than merely survive a database that is not there. Its property set is kept identical to WebPersistenceIT's so Spring's context cache serves both from one boot: measured, WebPersistenceIT then runs in 0.37s, which is the cache hit. Also drops two `.filter { it.downloadedAt != null }` calls that Kotlin now flags as always-true. They were redundant once downloadedAt stopped being the @MongoId, and the reason it is safe to remove them rather than make the field nullable is measured, not assumed: WebPersistenceIT inserts a row in the old shape, with no downloadedAt field at all, and it still materialises. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>RED, and the failure shows the injection working: the name injected a second filename parameter: attachment; filename="evil".zip"; filename="other.exe" ModPack.name is the upload's own filename, kept verbatim on purpose -- landingName strips path separators and nothing else. Both download handlers interpolate it into a quoted Content-Disposition value, so a `"` closes the string early and a `;` appends a parameter. The route has no authentication and @CrossOrigin("*"). Tomcat rejects CR/LF, so this is header-parameter injection rather than response splitting. Analysis finding A-2, and notable because the line directly below it was edited by this pass when the body switched to FileSystemResource; the header was read past. The second guard is green and closes analysis finding A-4: ArchiveStreamingTest asserted the body's TYPE but never that the bytes served are the archive's or that contentLength is right, so a streaming change serving the wrong file would have passed. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>Analysis findings A-1, A-3, A-5. anUploadThatCannotBeWrittenIsReportedAsAnEmptyResultRatherThanThrowing was a real guard when written -- "sub/dir/pack.zip" made transferTo fail on the unsanitised path, measured red. Then the fix it guards reduced that name to "pack.zip" and the write started succeeding, so assertDoesNotThrow passed because nothing went wrong. It asserted nothing while carrying a comment claiming it covered the unhandled-500 path, and a stale one at that: it cited include-stacktrace=ALWAYS, which a later commit in the same pass set to NEVER. The failure is now produced by making the storage ROOT a regular file, so it does not depend on the filename at all, and the guard additionally asserts the result is empty rather than merely non-throwing. Teeth verified by mutation: letting IOException escape land() fails it. The embedded mongod version was spelled three ways -- a dead MONGOD_VERSION constant, "8.0.5" in two annotations, and Version.Main.V8_0 in the probe -- so the server the probe validated and the server the tests ran were not tied together. Now one const, referenced by both annotations through VERSION_PROPERTY and derived by the probe ("V" + MONGOD_VERSION.replace('.', '_')), which is flapdoodle's own spelling of the same number. WebPersistenceIT still runs 5 tests, 0 failures, 0 skipped -- i.e. the probe still starts a real mongod rather than silently skipping. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>Analysis finding A-6. One stored id appears as two entries under the modpacks root -- "<id>.zip" and the "<id>" directory it was extracted into -- and both match the ObjectId pattern after removeSuffix(".zip"). deleteStored removes the archive, the directory and the GridFS document in one call, so the second entry repeated the whole thing, including a Mongo round-trip that could only match nothing. Harmless, since delete is idempotent, but it made the sweep's cost per-entry rather than per-pack. A set of reclaimed ids fixes it. Teeth verified by mutation: removing the set fails anOrphanWithBothAnArchiveAndAnExtractedDirectoryIsReclaimedOnce. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>RED at the handler, and the failure names the defect: a scan finding was reported as a generation failure; errors was [Nekodetector infections found!, Stage 1 infections:, evil.jar] ServerPackHandler.run populates `errors` from exactly one thing -- the Nekodetector scan of the FINISHED pack -- and ServerPackGeneration.success is errors.isEmpty(). So `success` means "no malware found", not "the generation worked", and it is wrong in both directions: - a pack that built perfectly and contains an infected mod reports failure; - a copy that threw, a script that could not be written, a modloader server that did not install and a ZIP that was not created are all logged and nothing more, so they report success. Even serverPack.create swallows its IOException with a comment saying a real failure "would surface later when files are written" -- it does not, because nothing checks. Six call sites branch on it: the web queue (GENERATED vs ERROR), both CLI verbs, the GUI control panel and the grinder's VanillaPackGenerator. ServerPackGenerationOutcomeTest pins the contract at the type, which is what those six rely on; it is green already, because the previous commit's seam made the type capable of expressing it. The handler guard is the one that was red. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>RED, and the message is the defect: the exclusion-regex failure was reported as [Invalid inclusion-regex specified: [unclosed.], which names the wrong field InclusionsValidator validates both filters and reported both with configuration.log.error.checkcopydirs.inclusion, so a malformed exclusion-regex told the user their INCLUSION filter was wrong -- the field they did not touch. The dedicated configuration.log.error.checkcopydirs.exclusion key exists in all three locale files and is referenced by nothing. The inclusion case is green and stays, so the two discriminate: a fix that simply swapped the keys would fail it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>The 9.0.0-beta.2 release failed at :closeSonatypeStagingRepository. Every other job succeeded -- signMavenJavaPublication and both repository publishes ran -- and Sonatype then rejected the staging repository with HTTP 400: pkg:maven/de.griefed.serverpackcreator/serverpackcreator-api@9.0.0-beta.2 - Dependency version information is missing for dependency: org.jetbrains.kotlin:kotlin-stdlib - Dependency management dependency version information is missing for dependency: org.jetbrains.kotlin:kotlin-bom Reproduced locally from the generated POM, which carried exactly those two: a dependencyManagement BOM import with no <version>, and a runtime kotlin-stdlib with no <version>. Both came from serverpackcreator.kotlin-conventions declaring them as bare coordinates. A precompiled script plugin cannot read the version catalog, so bare is the only thing that compiles there and the version was left to the BOM -- which itself had none. They were redundant anyway: kotlin("jvm") adds a stdlib at the plugin's own version (kotlin.stdlib.default.dependency is unset, so it defaults to true), and a module wanting it explicitly uses libs.kotlinStdlib. Measured on :serverpackcreator-api:generatePomFileForMavenJavaPublication, which is how build logic gets verified here: dependencies without a <version> 2 -> 0 <dependencyManagement> block present -> absent kotlin-stdlib MISSING -> 2.4.10, scope runtime total <dependency> entries 20 -> 17 So consumers still get the stdlib; it simply has a version now. Full build green, 2003 JVM tests, 0 failures. spring-conventions still declares versionless dependencies that resolve through Boot's BOM. Left alone deliberately -- that is the documented pattern there and -app is not published -- but recorded in .claude/rules/build-layout.md as the thing to audit before any second module is ever published, together with the one-command check that would have caught this before the release. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>