From d6cec3fe073d1da215d0c9804826eb99c1299970 Mon Sep 17 00:00:00 2001 From: ulfrxdev Date: Fri, 24 Apr 2026 16:21:25 +0200 Subject: [PATCH] Plan phase 1 --- .planning/ROADMAP.md | 11 +- .../01-01-PLAN.md | 342 +++++ .../01-02-PLAN.md | 576 +++++++ .../01-03-PLAN.md | 352 +++++ .../01-04-PLAN.md | 495 ++++++ .../01-05-PLAN.md | 498 ++++++ .../01-06-PLAN.md | 308 ++++ .../01-07-PLAN.md | 297 ++++ .../01-PATTERNS.md | 1343 +++++++++++++++++ 9 files changed, 4221 insertions(+), 1 deletion(-) create mode 100644 .planning/phases/01-project-infrastructure-module-wiring/01-01-PLAN.md create mode 100644 .planning/phases/01-project-infrastructure-module-wiring/01-02-PLAN.md create mode 100644 .planning/phases/01-project-infrastructure-module-wiring/01-03-PLAN.md create mode 100644 .planning/phases/01-project-infrastructure-module-wiring/01-04-PLAN.md create mode 100644 .planning/phases/01-project-infrastructure-module-wiring/01-05-PLAN.md create mode 100644 .planning/phases/01-project-infrastructure-module-wiring/01-06-PLAN.md create mode 100644 .planning/phases/01-project-infrastructure-module-wiring/01-07-PLAN.md create mode 100644 .planning/phases/01-project-infrastructure-module-wiring/01-PATTERNS.md diff --git a/.planning/ROADMAP.md b/.planning/ROADMAP.md index 47af9ed..472c89b 100644 --- a/.planning/ROADMAP.md +++ b/.planning/ROADMAP.md @@ -49,7 +49,16 @@ 3. iOS `gradle.properties` carry `kotlin.native.binary.objcDisposeOnMain=false` and `kotlin.native.binary.gc=cms`; a debug launch on simulator boots without warnings about legacy memory-management flags. 4. `build-logic/` convention plugins apply the Kotlin/Compose/test configuration to every module — adding a new module requires only applying a convention plugin, not copying compiler args. 5. `shared/commonMain` contains only domain models + serializable DTOs; no Ktor, Compose, or SQLDelight imports appear anywhere under `shared/`. -**Plans:** TBD +**Plans:** 7 plans + +Plans: +- [ ] 01-01-PLAN.md — Version catalog extensions (Koin/Kermit/Spotless/Flyway/Postgres) + iOS K/N flags + verify-*.sh invariant scripts +- [ ] 01-02-PLAN.md — build-logic/ included build with 5 precompiled plugins (recipe.quality, recipe.kotlin.multiplatform, recipe.compose.multiplatform, recipe.android.application, recipe.jvm.server) + root settings.gradle.kts includeBuild wiring +- [ ] 01-03-PLAN.md — Module refactor: composeApp/shared/server build.gradle.kts apply recipe.* conventions; drop js target; enable explicitApi() on shared/ +- [ ] 01-04-PLAN.md — Koin + Kermit bootstrap across all 4 platforms (commonMain Koin.kt/AppModule.kt/Logging.kt; iOS KoinIos.kt bridge; Android MainApplication.kt + manifest; JVM/Wasm main() rewrites; iOSApp.swift wiring) +- [ ] 01-05-PLAN.md — Server /health + Flyway bootstrap + HOCON config (application.conf, Database.kt with fail-loud contract, db/migration/.gitkeep, ApplicationTest.kt covers /health without Postgres) +- [ ] 01-06-PLAN.md — docker-compose.yml (postgres:16) + README.md Local development section (drops js docs) +- [ ] 01-07-PLAN.md — shared/ package scaffold + full green-build gate (spotlessApply, verify-*.sh, ./gradlew build, ./gradlew check) **UI hint:** no **Research flag:** no diff --git a/.planning/phases/01-project-infrastructure-module-wiring/01-01-PLAN.md b/.planning/phases/01-project-infrastructure-module-wiring/01-01-PLAN.md new file mode 100644 index 0000000..5cb78a1 --- /dev/null +++ b/.planning/phases/01-project-infrastructure-module-wiring/01-01-PLAN.md @@ -0,0 +1,342 @@ +--- +phase: 01-project-infrastructure-module-wiring +plan: 01 +type: execute +wave: 1 +depends_on: [] +files_modified: + - gradle/libs.versions.toml + - gradle.properties + - tools/verify-no-version-literals.sh + - tools/verify-shared-pure.sh + - tools/verify-ios-flags.sh +autonomous: true +requirements: [INFRA-01, INFRA-03] +requirements_addressed: [INFRA-01, INFRA-03] + +must_haves: + truths: + - "gradle/libs.versions.toml is the sole source of library/plugin versions (D-09 / INFRA-01 SC#2)" + - "iOS K/N binary flags kotlin.native.binary.gc=cms and kotlin.native.binary.objcDisposeOnMain=false are set in gradle.properties (D-18 / INFRA-03)" + - "Shell-based invariant checks (no-version-literals, shared-pure, ios-flags) are executable and fail-loud" + artifacts: + - path: "gradle/libs.versions.toml" + provides: "Version + library + plugin aliases for Koin, Kermit, Spotless, Flyway, PostgreSQL JDBC, Ktor content-negotiation, Ktor JSON serializer" + contains: "koin = ", "kermit = ", "spotless = ", "flyway = ", "postgresql =" + - path: "gradle.properties" + provides: "iOS K/N binary flags" + contains: "kotlin.native.binary.gc=cms", "kotlin.native.binary.objcDisposeOnMain=false" + - path: "tools/verify-no-version-literals.sh" + provides: "Invariant check — no numeric version literals outside catalog in any *.gradle.kts (except build-logic/build.gradle.kts bootstrap coordinates)" + - path: "tools/verify-shared-pure.sh" + provides: "Invariant check — shared/src/commonMain must not import Ktor / Compose / SQLDelight" + - path: "tools/verify-ios-flags.sh" + provides: "Invariant check — both iOS K/N flags present in gradle.properties" + key_links: + - from: "build-logic/ (Plan 02)" + to: "gradle/libs.versions.toml" + via: "VersionCatalogsExtension.named(\"libs\").findLibrary(...) inside precompiled plugins" + pattern: "findLibrary\\(\"koin-core\"\\)" + - from: "gradle.properties" + to: ":composeApp:linkDebugFrameworkIosSimulatorArm64" + via: "Kotlin/Native compiler reads project properties at link time" + pattern: "kotlin\\.native\\.binary\\." +--- + + +Extend the Gradle version catalog with every new alias required by Phase 1 (Koin, Kermit, Spotless, Flyway, Postgres JDBC, ktor-serverContentNegotiation, ktor-serializationKotlinxJson), append the two mandatory iOS Kotlin/Native binary flags to `gradle.properties`, and ship three shell-based invariant scripts under `tools/` that Plan 07 will use as phase-gate checks. + +Purpose: This plan creates the **foundation** on which every other Phase 1 plan rests. Without these catalog entries, `build-logic/` (Plan 02) cannot resolve `findLibrary("koin-core")`; without the iOS flags, INFRA-03 fails silently. The verification scripts are required by 01-VALIDATION.md Wave 0 — every subsequent plan's `` block calls one of them. + +Output: An extended `gradle/libs.versions.toml` (additive only, no version bumps to existing entries), extended `gradle.properties` with exactly two new lines, and three executable `.sh` scripts under a new `tools/` directory. + + + +@$HOME/.claude/get-shit-done/workflows/execute-plan.md +@$HOME/.claude/get-shit-done/templates/summary.md + + + +@.planning/PROJECT.md +@.planning/ROADMAP.md +@.planning/STATE.md +@.planning/REQUIREMENTS.md +@.planning/phases/01-project-infrastructure-module-wiring/01-CONTEXT.md +@.planning/phases/01-project-infrastructure-module-wiring/01-RESEARCH.md +@.planning/phases/01-project-infrastructure-module-wiring/01-PATTERNS.md +@.planning/phases/01-project-infrastructure-module-wiring/01-VALIDATION.md +@gradle/libs.versions.toml +@gradle.properties +@CLAUDE.md + + + + +From gradle/libs.versions.toml (current state, to extend): +```toml +[versions] +kotlin = "2.3.20" +ktor = "3.4.1" +composeMultiplatform = "1.10.3" +# (plus agp, androidx-*, composeHotReload, junit, kotlinx-coroutines, logback, material3) + +[libraries] +# Existing: kotlin-test, kotlin-testJunit, junit, androidx-*, compose-*, kotlinx-coroutinesSwing, +# logback, ktor-serverCore, ktor-serverNetty, ktor-serverTestHost + +[plugins] +# Existing: androidApplication, androidLibrary, composeHotReload, composeMultiplatform, +# composeCompiler, kotlinJvm, ktor, kotlinMultiplatform +``` + +From gradle.properties (current state — 10 lines of Kotlin + Gradle + Android config): +```properties +kotlin.code.style=official +kotlin.daemon.jvmargs=-Xmx3072M +org.gradle.jvmargs=-Xmx4096M -Dfile.encoding=UTF-8 +org.gradle.configuration-cache=true +org.gradle.caching=true +android.nonTransitiveRClass=true +android.useAndroidX=true +``` + + + + + + + Task 1: Extend gradle/libs.versions.toml with Phase 1 aliases + gradle/libs.versions.toml + + - gradle/libs.versions.toml (see current state of versions/libraries/plugins tables) + - .planning/phases/01-project-infrastructure-module-wiring/01-RESEARCH.md lines 110-175 (§ Standard Stack + Installation TOML fragments) + - .planning/phases/01-project-infrastructure-module-wiring/01-PATTERNS.md lines 446-490 (delta blocks for [versions] / [libraries] / [plugins]) + - .planning/phases/01-project-infrastructure-module-wiring/01-CONTEXT.md D-09 (catalog-only hard rule), D-14 (Koin deps needed), D-15 (Kermit), D-10 (Spotless), D-16 (Flyway + Postgres + content-negotiation) + + + Extend `gradle/libs.versions.toml` with the new aliases for Phase 1. Preserve every existing entry verbatim (do NOT rename, remove, or bump any existing version). + + Append the following to `[versions]`, in the existing alphabetical-ish order: + + ```toml + flyway = "12.4.0" + kermit = "2.1.0" + koin = "4.2.1" + kotlinx-serialization = "1.7.3" + postgresql = "42.7.10" + spotless = "8.4.0" + ``` + + Append the following to `[libraries]`: + + ```toml + # Koin (client DI — D-14) + koin-bom = { module = "io.insert-koin:koin-bom", version.ref = "koin" } + koin-core = { module = "io.insert-koin:koin-core" } + koin-compose = { module = "io.insert-koin:koin-compose" } + koin-composeViewmodel = { module = "io.insert-koin:koin-compose-viewmodel" } + koin-android = { module = "io.insert-koin:koin-android" } + + # Kermit (client logger — D-15) + kermit = { module = "co.touchlab:kermit", version.ref = "kermit" } + + # Server: Ktor content-negotiation + JSON serializer + Flyway + Postgres (D-16) + ktor-serverContentNegotiation = { module = "io.ktor:ktor-server-content-negotiation-jvm", version.ref = "ktor" } + ktor-serializationKotlinxJson = { module = "io.ktor:ktor-serialization-kotlinx-json-jvm", version.ref = "ktor" } + flyway-core = { module = "org.flywaydb:flyway-core", version.ref = "flyway" } + flyway-database-postgresql = { module = "org.flywaydb:flyway-database-postgresql", version.ref = "flyway" } + postgresql = { module = "org.postgresql:postgresql", version.ref = "postgresql" } + ``` + + Append the following to `[plugins]`: + + ```toml + spotless = { id = "com.diffplug.spotless", version.ref = "spotless" } + flywayPlugin = { id = "org.flywaydb.flyway", version.ref = "flyway" } + ``` + + IMPORTANT invariants: + - `koin-core`, `koin-compose`, `koin-compose-viewmodel`, `koin-android` have NO `version.ref` — they are BOM-managed by `koin-bom`. + - `kotlin-test` is already in the catalog (line 22) — do NOT re-add. + - Do NOT bump any existing version alias (kotlin, ktor, composeMultiplatform, logback, etc.). + - The `koin-composeViewmodel` alias name uses camelCase (Gradle converts dashes-to-dots for accessors, but camelCase preserves `koin.composeViewmodel.get()`). + + + grep -E '^(flyway|kermit|koin|kotlinx-serialization|postgresql|spotless)\s*=' gradle/libs.versions.toml | wc -l | grep -q '^6$' && grep -E '^koin-bom\s*=' gradle/libs.versions.toml && grep -E '^koin-core\s*=' gradle/libs.versions.toml && grep -E '^koin-compose\s*=' gradle/libs.versions.toml && grep -E '^koin-composeViewmodel\s*=' gradle/libs.versions.toml && grep -E '^koin-android\s*=' gradle/libs.versions.toml && grep -E '^kermit\s*=' gradle/libs.versions.toml && grep -E '^ktor-serverContentNegotiation\s*=' gradle/libs.versions.toml && grep -E '^ktor-serializationKotlinxJson\s*=' gradle/libs.versions.toml && grep -E '^flyway-core\s*=' gradle/libs.versions.toml && grep -E '^flyway-database-postgresql\s*=' gradle/libs.versions.toml && grep -E '^postgresql\s*=' gradle/libs.versions.toml && grep -E '^spotless\s*=\s*\{\s*id\s*=' gradle/libs.versions.toml && grep -E '^flywayPlugin\s*=\s*\{\s*id\s*=' gradle/libs.versions.toml + + + - `grep -E '^kotlin\s*=\s*"2\.3\.20"' gradle/libs.versions.toml` returns exactly 1 line (existing, unmodified) + - `grep -E '^ktor\s*=\s*"3\.4\.1"' gradle/libs.versions.toml` returns exactly 1 line (existing, unmodified) + - `grep -E '^koin\s*=\s*"4\.2\.1"' gradle/libs.versions.toml` returns exactly 1 line (new) + - `grep -E '^kermit\s*=\s*"2\.1\.0"' gradle/libs.versions.toml` returns exactly 1 line (new) + - `grep -E '^spotless\s*=\s*"8\.4\.0"' gradle/libs.versions.toml` returns exactly 1 line (new) + - `grep -E '^flyway\s*=\s*"12\.4\.0"' gradle/libs.versions.toml` returns exactly 1 line (new) + - `grep -E '^postgresql\s*=\s*"42\.7\.10"' gradle/libs.versions.toml` returns exactly 1 line (new) + - `grep -c '^koin-' gradle/libs.versions.toml` returns `5` (koin-bom, koin-core, koin-compose, koin-composeViewmodel, koin-android) + - `grep -c '^flyway-' gradle/libs.versions.toml` returns `2` (flyway-core, flyway-database-postgresql) + - `grep -E '^\s*module\s*=\s*"io.insert-koin:koin-core"' gradle/libs.versions.toml` returns 1 line with NO `version.ref` attribute on same line (BOM-managed) + + All Phase 1 catalog aliases present; no existing aliases modified; file parses as valid TOML. + + + + Task 2: Append iOS K/N binary flags to gradle.properties + gradle.properties + + - gradle.properties (see current 10-line content) + - .planning/phases/01-project-infrastructure-module-wiring/01-RESEARCH.md lines 1082-1107 (§ `gradle.properties` — iOS binary flags — exact content to append) + - .planning/phases/01-project-infrastructure-module-wiring/01-CONTEXT.md D-18 (INFRA-03, PITFALL #1) + - CLAUDE.md convention #7 (iOS binary flags on day 1) + + + Append the following 5 lines to `gradle.properties` exactly as shown (including the blank separator line and both comment lines). Do NOT modify any existing line: + + ```properties + + # Kotlin/Native iOS (PITFALLS.md #1; D-18; INFRA-03) — MANDATORY day 1 + # CMS GC + non-main-thread Obj-C deinit to avoid UI-thread pause spikes in Compose Multiplatform. + kotlin.native.binary.gc=cms + kotlin.native.binary.objcDisposeOnMain=false + ``` + + IMPORTANT: + - Place AT THE END of the file (append). The existing `android.useAndroidX=true` stays as the last non-iOS line. + - Use EXACTLY the property keys `kotlin.native.binary.gc` and `kotlin.native.binary.objcDisposeOnMain`. Do not add quotes, spaces, or alternate spellings (the K/N compiler reads these keys literally). + - Value `cms` is lowercase. Value `false` is lowercase. + + + grep -E '^kotlin\.native\.binary\.gc=cms$' gradle.properties | wc -l | grep -q '^1$' && grep -E '^kotlin\.native\.binary\.objcDisposeOnMain=false$' gradle.properties | wc -l | grep -q '^1$' + + + - `grep -cE '^kotlin\.native\.binary\.gc=cms$' gradle.properties` returns `1` + - `grep -cE '^kotlin\.native\.binary\.objcDisposeOnMain=false$' gradle.properties` returns `1` + - `grep -c '^kotlin\.code\.style=official$' gradle.properties` returns `1` (unmodified existing) + - `grep -c '^android\.useAndroidX=true$' gradle.properties` returns `1` (unmodified existing) + - No duplicate of either flag (run grep twice — expect `1` each time, not `2`) + + Both iOS K/N flags present once; original 10 lines unchanged. + + + + Task 3: Create verify-*.sh invariant scripts under tools/ + tools/verify-no-version-literals.sh, tools/verify-shared-pure.sh, tools/verify-ios-flags.sh + + - .planning/phases/01-project-infrastructure-module-wiring/01-RESEARCH.md lines 1174-1218 (§ tools/verify-*.sh — canonical shell sketches) + - .planning/phases/01-project-infrastructure-module-wiring/01-PATTERNS.md lines 1174-1218 (same scripts, same content — Pattern Map confirms no in-repo analog) + - .planning/phases/01-project-infrastructure-module-wiring/01-VALIDATION.md lines 62-79 (Wave 0 Requirements — these three scripts gate every task's `` check) + + + Create the three executable bash scripts under `tools/` (create the directory — it does not exist yet). Each must be marked executable (`chmod +x`). + + **File 1: `tools/verify-no-version-literals.sh`** (enforces D-09 / INFRA-01 SC#2): + + ```sh + #!/usr/bin/env bash + # Enforces INFRA-01 SC#2 / D-09: no literal version strings outside catalog. + # Scans every *.gradle.kts for numeric version literals (e.g. version = "1.2.3"), + # excluding build-logic/build.gradle.kts which needs literal asDependency() coordinates. + set -euo pipefail + VIOLATIONS=$(grep -rn -E 'version[[:space:]]*=[[:space:]]*"[0-9]' --include='*.gradle.kts' . 2>/dev/null | grep -v 'build-logic/build.gradle.kts' || true) + if [ -n "$VIOLATIONS" ]; then + echo "ERROR: version literals found outside catalog:" >&2 + echo "$VIOLATIONS" >&2 + exit 1 + fi + echo "OK: no version literals outside catalog." + ``` + + **File 2: `tools/verify-shared-pure.sh`** (enforces INFRA-06 / D-19): + + ```sh + #!/usr/bin/env bash + # Enforces INFRA-06 / D-19: shared/commonMain must not import Ktor, Compose, SQLDelight. + # Runs grep against shared/src/commonMain/ only. Allowed imports: kotlin.*, kotlinx.serialization, kotlinx.datetime. + set -euo pipefail + if [ ! -d shared/src/commonMain ]; then + echo "OK: shared/src/commonMain does not exist yet (pre-scaffold)." + exit 0 + fi + VIOLATIONS=$(grep -rn -E '^import[[:space:]]+(io\.ktor|androidx\.compose|org\.jetbrains\.compose|app\.cash\.sqldelight)' shared/src/commonMain/ 2>/dev/null || true) + if [ -n "$VIOLATIONS" ]; then + echo "ERROR: shared/commonMain has forbidden imports:" >&2 + echo "$VIOLATIONS" >&2 + exit 1 + fi + echo "OK: shared/commonMain is pure." + ``` + + **File 3: `tools/verify-ios-flags.sh`** (enforces INFRA-03 / D-18): + + ```sh + #!/usr/bin/env bash + # Enforces INFRA-03 / D-18: iOS K/N flags present in gradle.properties. + set -euo pipefail + grep -q '^kotlin\.native\.binary\.gc=cms$' gradle.properties || { echo "MISSING: kotlin.native.binary.gc=cms" >&2; exit 1; } + grep -q '^kotlin\.native\.binary\.objcDisposeOnMain=false$' gradle.properties || { echo "MISSING: kotlin.native.binary.objcDisposeOnMain=false" >&2; exit 1; } + echo "OK: iOS binary flags present." + ``` + + After writing all three files, run: `chmod +x tools/verify-no-version-literals.sh tools/verify-shared-pure.sh tools/verify-ios-flags.sh`. + + IMPORTANT: + - Use `#!/usr/bin/env bash` (not `#!/bin/sh`) — `set -euo pipefail` requires bash semantics. + - `tools/verify-shared-pure.sh` deliberately returns 0 if `shared/src/commonMain` does not exist (pre-scaffold state). This lets Plan 07 run the script before Plan 07 itself creates the scaffold. + - `tools/verify-no-version-literals.sh` excludes `build-logic/build.gradle.kts` (its `asDependency()` trick requires literal plugin version coordinates — D-09 acknowledged exception). + + + test -x tools/verify-no-version-literals.sh && test -x tools/verify-shared-pure.sh && test -x tools/verify-ios-flags.sh && bash tools/verify-ios-flags.sh && bash tools/verify-shared-pure.sh && bash tools/verify-no-version-literals.sh + + + - `test -f tools/verify-no-version-literals.sh && test -x tools/verify-no-version-literals.sh` succeeds + - `test -f tools/verify-shared-pure.sh && test -x tools/verify-shared-pure.sh` succeeds + - `test -f tools/verify-ios-flags.sh && test -x tools/verify-ios-flags.sh` succeeds + - `bash tools/verify-ios-flags.sh` exits 0 and prints `OK: iOS binary flags present.` (proves Task 2 wrote flags) + - `bash tools/verify-shared-pure.sh` exits 0 (current `shared/src/commonMain/kotlin/dev/ulfrx/recipe/` has only Greeting.kt/Platform.kt/Constants.kt — no ktor/compose imports) + - `bash tools/verify-no-version-literals.sh` exits 0 (current *.gradle.kts files use `libs.plugins.*` aliases — no literal versions) + - Each script has `#!/usr/bin/env bash` as line 1 + - Each script uses `set -euo pipefail` + + Three executable verification scripts exist, each runs green against the current repo state. + + + + + +## Trust Boundaries + +| Boundary | Description | +|----------|-------------| +| developer → Gradle build | Local-only; Gradle reads `libs.versions.toml` + `gradle.properties` verbatim. No untrusted input. | +| Gradle → Maven Central + Gradle Plugin Portal | Existing repository declarations in `settings.gradle.kts` (Plan 03 doesn't change them). Pinned versions via catalog reduce supply-chain drift. | + +## STRIDE Threat Register + +| Threat ID | Category | Component | Disposition | Mitigation Plan | +|-----------|----------|-----------|-------------|-----------------| +| T-01-01-01 | Tampering (supply chain) | `gradle/libs.versions.toml` new entries | mitigate | All new version refs are pinned to specific stable releases (`koin = "4.2.1"`, `kermit = "2.1.0"`, `flyway = "12.4.0"`, `spotless = "8.4.0"`, `postgresql = "42.7.10"`) — no version ranges, no `latest.release`. Gradle verifies SHA-256 via `gradle/verification-metadata.xml` if enabled in later phases. | +| T-01-01-02 | Tampering | `tools/*.sh` scripts | accept | Scripts live in repo and run locally; their only effect is exit 0/1. Read `gradle.properties` and `*.gradle.kts` only — no network I/O, no write. Risk = low. | +| T-01-01-03 | Information Disclosure | `gradle.properties` iOS flags | accept | Flag values (`cms`, `false`) are build configuration, not secrets. Public in every iOS KMP tutorial. | +| T-01-01-04 | Denial of Service | wrong catalog syntax breaks build | mitigate | Task 1 `` greps for exact alias presence; Wave 2 plans that consume the catalog will fail fast if an alias is misspelled. | + + + +Phase-level verification for this plan: +- All three `tools/verify-*.sh` scripts run green against the post-plan repo. +- `gradle/libs.versions.toml` parses (Gradle will surface a TOML parse error at next `./gradlew` invocation in Plan 02). +- `gradle.properties` has exactly two new iOS K/N flag lines and is otherwise byte-identical to its pre-plan content. + +No Gradle build is expected to run fully in this plan — we have not yet scaffolded `build-logic/` (Plan 02) nor refactored modules (Plan 03), so `./gradlew build` would fail to resolve the new library aliases. Catalog additions ARE safe for Gradle configuration though (unused entries are inert). + + + +- `tools/verify-ios-flags.sh` exits 0 +- `tools/verify-no-version-literals.sh` exits 0 +- `tools/verify-shared-pure.sh` exits 0 +- Catalog contains 6 new `[versions]` keys (flyway, kermit, koin, kotlinx-serialization, postgresql, spotless) +- Catalog contains 10 new `[libraries]` entries (5 koin-*, kermit, 2 ktor-*, 2 flyway-*, postgresql) +- Catalog contains 2 new `[plugins]` entries (spotless, flywayPlugin) + + + +After completion, create `.planning/phases/01-project-infrastructure-module-wiring/01-01-SUMMARY.md` recording: catalog entries added (count), gradle.properties append location, shell-script paths, and any deviation from the planned version pins (if Maven Central shows a newer stable, record the downgrade decision). + diff --git a/.planning/phases/01-project-infrastructure-module-wiring/01-02-PLAN.md b/.planning/phases/01-project-infrastructure-module-wiring/01-02-PLAN.md new file mode 100644 index 0000000..fb0951b --- /dev/null +++ b/.planning/phases/01-project-infrastructure-module-wiring/01-02-PLAN.md @@ -0,0 +1,576 @@ +--- +phase: 01-project-infrastructure-module-wiring +plan: 02 +type: execute +wave: 1 +depends_on: [] +files_modified: + - build-logic/settings.gradle.kts + - build-logic/build.gradle.kts + - build-logic/src/main/kotlin/recipe.quality.gradle.kts + - build-logic/src/main/kotlin/recipe.kotlin.multiplatform.gradle.kts + - build-logic/src/main/kotlin/recipe.compose.multiplatform.gradle.kts + - build-logic/src/main/kotlin/recipe.android.application.gradle.kts + - build-logic/src/main/kotlin/recipe.jvm.server.gradle.kts + - settings.gradle.kts + - build.gradle.kts +autonomous: true +requirements: [INFRA-02] +requirements_addressed: [INFRA-02] + +must_haves: + truths: + - "build-logic/ is an included build resolved via pluginManagement.includeBuild (PITFALL #9)" + - "5 precompiled script plugins exist under build-logic/src/main/kotlin/: recipe.quality, recipe.kotlin.multiplatform, recipe.compose.multiplatform, recipe.android.application, recipe.jvm.server (D-06)" + - "Each precompiled plugin reads versions via extensions.getByType().named(\"libs\") (PITFALL #1)" + - "recipe.kotlin.multiplatform locks the D-05 target matrix (androidTarget, iosArm64, iosSimulatorArm64, jvm, wasmJs) + JVM toolchain 21 + framework basename 'ComposeApp' + Koin/Kermit/kotlin-test deps + allWarningsAsErrors" + - "recipe.compose.multiplatform layers on recipe.kotlin.multiplatform (does NOT re-declare KMP plugin — PITFALL #2)" + - "recipe.jvm.server uses quoted dependency configurations (\"implementation\"(...) — quoted-config footgun)" + - "settings.gradle.kts places includeBuild(\"build-logic\") INSIDE pluginManagement { } block (PITFALL #9)" + artifacts: + - path: "build-logic/settings.gradle.kts" + provides: "Included-build settings with shared catalog access (from files(\"../gradle/libs.versions.toml\"))" + - path: "build-logic/build.gradle.kts" + provides: "kotlin-dsl plugin + compileOnly(asDependency()) entries for every alias-based plugin referenced by precompiled plugins" + - path: "build-logic/src/main/kotlin/recipe.quality.gradle.kts" + provides: "Spotless + ktlint + allWarningsAsErrors safety net (D-10 / D-11)" + - path: "build-logic/src/main/kotlin/recipe.kotlin.multiplatform.gradle.kts" + provides: "D-05 target matrix + JVM toolchain + common deps + allWarningsAsErrors (D-07, D-08, D-11)" + - path: "build-logic/src/main/kotlin/recipe.compose.multiplatform.gradle.kts" + provides: "Compose MP plugin + hot-reload + Compose deps for commonMain (layered on KMP)" + - path: "build-logic/src/main/kotlin/recipe.android.application.gradle.kts" + provides: "com.android.application + namespace + SDK versions (composeApp only)" + - path: "build-logic/src/main/kotlin/recipe.jvm.server.gradle.kts" + provides: "kotlin(jvm) + Ktor + Flyway + server deps (server only)" + - path: "settings.gradle.kts" + provides: "Root settings with pluginManagement { includeBuild(\"build-logic\") }" + - path: "build.gradle.kts" + provides: "Root build with apply-false entries for spotless + flywayPlugin (classloader hint)" + key_links: + - from: "build-logic/src/main/kotlin/recipe.*.gradle.kts" + to: "gradle/libs.versions.toml" + via: "VersionCatalogsExtension.named(\"libs\")" + pattern: "extensions\\.getByType\\(\\)\\.named\\(\"libs\"\\)" + - from: "Plan 03 module build files" + to: "build-logic/src/main/kotlin/recipe.*.gradle.kts" + via: "plugins { id(\"recipe.kotlin.multiplatform\") }" + pattern: "id\\(\"recipe\\." +--- + + +Scaffold the `build-logic/` included build with 5 precompiled script plugins (`recipe.quality`, `recipe.kotlin.multiplatform`, `recipe.compose.multiplatform`, `recipe.android.application`, `recipe.jvm.server`) that every module in Plan 03 will apply. Wire the included build into `settings.gradle.kts` via `pluginManagement.includeBuild("build-logic")` and extend the root `build.gradle.kts` with `apply false` declarations for the two new plugins (Spotless + Flyway) so Gradle's classloader resolves them consistently. + +Purpose: This is the **dependency root** for every subsequent Phase 1 plan. Plan 03 cannot refactor module builds until these plugins exist. Plan 05 cannot wire Flyway into the server without `recipe.jvm.server`. The design (per D-06) enforces role declarations — `shared/` applies only `recipe.kotlin.multiplatform` + `recipe.quality` and therefore CANNOT pull Compose transitively (INFRA-06). + +Output: A fully populated `build-logic/` directory whose included-build settings resolve the parent catalog, a root settings file that finds `recipe.*` plugins by ID, and 5 precompiled plugins whose internals are verbatim (or near-verbatim) copies of 01-RESEARCH.md § Code Examples / § Architecture Patterns. + + + +@$HOME/.claude/get-shit-done/workflows/execute-plan.md +@$HOME/.claude/get-shit-done/templates/summary.md + + + +@.planning/PROJECT.md +@.planning/ROADMAP.md +@.planning/STATE.md +@.planning/phases/01-project-infrastructure-module-wiring/01-CONTEXT.md +@.planning/phases/01-project-infrastructure-module-wiring/01-RESEARCH.md +@.planning/phases/01-project-infrastructure-module-wiring/01-PATTERNS.md +@.planning/phases/01-project-infrastructure-module-wiring/01-VALIDATION.md +@settings.gradle.kts +@build.gradle.kts +@gradle/libs.versions.toml +@CLAUDE.md + + + + +Plugin applications reference (01-PATTERNS.md and 01-RESEARCH.md): +- `id("recipe.quality")` → from .gradle.kts file named `recipe.quality.gradle.kts` (Gradle convention) +- `id("recipe.kotlin.multiplatform")` → `recipe.kotlin.multiplatform.gradle.kts` +- etc. + +Version-catalog access pattern inside precompiled plugins (PITFALL #1, RESEARCH.md lines 362-380): +```kotlin +import org.gradle.api.artifacts.VersionCatalogsExtension +import org.gradle.kotlin.dsl.getByType + +val libs = extensions.getByType().named("libs") + +// Usage: +val v = libs.findVersion("kotlin").get().toString() +val lib = libs.findLibrary("koin-core").get() +``` + +Quoted configuration names in precompiled plugin dependencies (RESEARCH.md line 603, Pattern 7): +```kotlin +dependencies { + "implementation"(libs.findLibrary("ktor-serverCore").get()) // quoted! + // NOT: implementation(...) — unresolved reference in precompiled plugin context +} +``` + +The root `settings.gradle.kts` layout required by PITFALL #9 (RESEARCH.md lines 749-767): +```kotlin +pluginManagement { + includeBuild("build-logic") // MUST be inside pluginManagement { } + repositories { ... } +} +``` + + + + + + + Task 1: Scaffold build-logic/ included build + 5 precompiled plugins + build-logic/settings.gradle.kts, build-logic/build.gradle.kts, build-logic/src/main/kotlin/recipe.quality.gradle.kts, build-logic/src/main/kotlin/recipe.kotlin.multiplatform.gradle.kts, build-logic/src/main/kotlin/recipe.compose.multiplatform.gradle.kts, build-logic/src/main/kotlin/recipe.android.application.gradle.kts, build-logic/src/main/kotlin/recipe.jvm.server.gradle.kts + + - .planning/phases/01-project-infrastructure-module-wiring/01-RESEARCH.md lines 308-605 (§ Pattern 1 through § Pattern 7 — canonical excerpts for every file in this task) + - .planning/phases/01-project-infrastructure-module-wiring/01-RESEARCH.md lines 652-774 (§ Common Pitfalls 1-10 — especially #1 catalog access, #2 double-apply KMP, #3 warnings-as-errors scope, #7 kotlinOptions, #9 includeBuild location, #10 framework basename) + - .planning/phases/01-project-infrastructure-module-wiring/01-PATTERNS.md lines 105-443 (pattern assignments for each build-logic/ file with deltas) + - gradle/libs.versions.toml (Plan 01 added these aliases — verify they exist before writing `findLibrary(...)` references) + - .planning/phases/01-project-infrastructure-module-wiring/01-CONTEXT.md D-06 through D-17 (plugin split, JVM split, warnings-as-errors, Koin deps, Flyway, server scope) + + + Create the `build-logic/` directory and all 7 files listed in ``. Each file's content comes directly from 01-RESEARCH.md. Use the Write tool for every file (no heredoc). + + --- + + **File 1: `build-logic/settings.gradle.kts`** (01-RESEARCH.md lines 316-331, verbatim): + + ```kotlin + dependencyResolutionManagement { + repositories { + google() + mavenCentral() + gradlePluginPortal() + } + versionCatalogs { + create("libs") { + from(files("../gradle/libs.versions.toml")) + } + } + } + + rootProject.name = "build-logic" + ``` + + --- + + **File 2: `build-logic/build.gradle.kts`** (01-RESEARCH.md lines 333-358, verbatim): + + ```kotlin + plugins { + `kotlin-dsl` + } + + dependencies { + compileOnly(libs.plugins.kotlinMultiplatform.asDependency()) + compileOnly(libs.plugins.androidApplication.asDependency()) + compileOnly(libs.plugins.androidLibrary.asDependency()) + compileOnly(libs.plugins.composeMultiplatform.asDependency()) + compileOnly(libs.plugins.composeCompiler.asDependency()) + compileOnly(libs.plugins.composeHotReload.asDependency()) + compileOnly(libs.plugins.kotlinJvm.asDependency()) + compileOnly(libs.plugins.ktor.asDependency()) + compileOnly(libs.plugins.spotless.asDependency()) + compileOnly(libs.plugins.flywayPlugin.asDependency()) + } + + fun Provider.asDependency(): Provider = + map { "${it.pluginId}:${it.pluginId}.gradle.plugin:${it.version.requiredVersion}" } + ``` + + --- + + **File 3: `build-logic/src/main/kotlin/recipe.quality.gradle.kts`** (01-RESEARCH.md lines 483-512 + D-11 safety net): + + ```kotlin + plugins { + id("com.diffplug.spotless") + } + + spotless { + kotlin { + target("src/**/*.kt") + targetExclude("**/build/**", "**/generated/**") + ktlint() + } + kotlinGradle { + target("*.gradle.kts") + ktlint() + } + format("markdown") { + target("*.md", "docs/**/*.md") + endWithNewline() + trimTrailingWhitespace() + } + } + + // D-11 redundancy guard: if a module applies recipe.quality WITHOUT recipe.kotlin.multiplatform + // (e.g. a future pure-JVM utility), ensure allWarningsAsErrors still applies. + tasks.withType>().configureEach { + compilerOptions { + allWarningsAsErrors.set(true) + } + } + ``` + + --- + + **File 4: `build-logic/src/main/kotlin/recipe.kotlin.multiplatform.gradle.kts`** (01-RESEARCH.md lines 777-835, verbatim — the canonical KMP plugin): + + ```kotlin + // build-logic/src/main/kotlin/recipe.kotlin.multiplatform.gradle.kts + // Establishes the D-05 target matrix + JVM toolchain + common deps. + // Android bytecode is JVM 11 (D-08); server + desktop + shared/jvm are JVM 21. + + import org.gradle.api.artifacts.VersionCatalogsExtension + import org.gradle.kotlin.dsl.getByType + import org.jetbrains.kotlin.gradle.ExperimentalWasmDsl + import org.jetbrains.kotlin.gradle.dsl.JvmTarget + + plugins { + id("org.jetbrains.kotlin.multiplatform") + } + + val libs = extensions.getByType().named("libs") + + kotlin { + jvmToolchain(21) + + androidTarget { + compilerOptions { + jvmTarget.set(JvmTarget.JVM_11) + } + } + + listOf(iosArm64(), iosSimulatorArm64()).forEach { iosTarget -> + iosTarget.binaries.framework { + baseName = "ComposeApp" + isStatic = true + } + } + + jvm { + compilerOptions { + jvmTarget.set(JvmTarget.JVM_21) + } + } + + @OptIn(ExperimentalWasmDsl::class) + wasmJs { browser() } + + compilerOptions { + allWarningsAsErrors.set(true) + } + + sourceSets { + commonMain.dependencies { + implementation(project.dependencies.platform(libs.findLibrary("koin-bom").get())) + implementation(libs.findLibrary("koin-core").get()) + implementation(libs.findLibrary("kermit").get()) + } + commonTest.dependencies { + implementation(libs.findLibrary("kotlin-test").get()) + } + } + } + ``` + + --- + + **File 5: `build-logic/src/main/kotlin/recipe.compose.multiplatform.gradle.kts`** (01-RESEARCH.md lines 447-477 + 01-PATTERNS.md lines 247-287 — layers on KMP, PITFALL #2): + + ```kotlin + import org.gradle.api.artifacts.VersionCatalogsExtension + import org.gradle.kotlin.dsl.getByType + + plugins { + id("recipe.kotlin.multiplatform") + id("org.jetbrains.compose") + id("org.jetbrains.kotlin.plugin.compose") + id("org.jetbrains.compose.hot-reload") + } + + val libs = extensions.getByType().named("libs") + + kotlin { + sourceSets { + commonMain.dependencies { + implementation(libs.findLibrary("compose-runtime").get()) + implementation(libs.findLibrary("compose-foundation").get()) + implementation(libs.findLibrary("compose-material3").get()) + implementation(libs.findLibrary("compose-ui").get()) + implementation(libs.findLibrary("compose-components-resources").get()) + implementation(libs.findLibrary("androidx-lifecycle-viewmodelCompose").get()) + implementation(libs.findLibrary("androidx-lifecycle-runtimeCompose").get()) + implementation(libs.findLibrary("koin-compose").get()) + implementation(libs.findLibrary("koin-composeViewmodel").get()) + } + } + } + ``` + + CRITICAL: this plugin applies `id("recipe.kotlin.multiplatform")` — NOT `id("org.jetbrains.kotlin.multiplatform")`. The KMP plugin is applied transitively by the recipe plugin. Double-applying throws "Plugin already applied" (PITFALL #2). + + --- + + **File 6: `build-logic/src/main/kotlin/recipe.android.application.gradle.kts`** (01-RESEARCH.md lines 516-552, catalog-accessor-adjusted for precompiled-plugin context): + + ```kotlin + import org.gradle.api.artifacts.VersionCatalogsExtension + import org.gradle.kotlin.dsl.getByType + + plugins { + id("com.android.application") + } + + val libs = extensions.getByType().named("libs") + + android { + namespace = "dev.ulfrx.recipe" + compileSdk = libs.findVersion("android-compileSdk").get().toString().toInt() + + defaultConfig { + applicationId = "dev.ulfrx.recipe" + minSdk = libs.findVersion("android-minSdk").get().toString().toInt() + targetSdk = libs.findVersion("android-targetSdk").get().toString().toInt() + versionCode = 1 + versionName = "1.0" + } + packaging { + resources { + excludes += "/META-INF/{AL2.0,LGPL2.1}" + } + } + buildTypes { + getByName("release") { + isMinifyEnabled = false + } + } + compileOptions { + sourceCompatibility = JavaVersion.VERSION_11 + targetCompatibility = JavaVersion.VERSION_11 + } + } + ``` + + CRITICAL: the version lookup is `libs.findVersion("android-compileSdk").get().toString().toInt()` — NOT `libs.versions.android.compileSdk.get().toInt()` (that accessor does not exist in precompiled plugins — PITFALL #1). + + --- + + **File 7: `build-logic/src/main/kotlin/recipe.jvm.server.gradle.kts`** (01-RESEARCH.md lines 558-601, quoted-config variant per PATTERNS.md line 395): + + ```kotlin + import org.gradle.api.artifacts.VersionCatalogsExtension + import org.gradle.kotlin.dsl.getByType + + plugins { + id("org.jetbrains.kotlin.jvm") + id("io.ktor.plugin") + id("org.flywaydb.flyway") + application + } + + val libs = extensions.getByType().named("libs") + + kotlin { + jvmToolchain(21) + compilerOptions { + allWarningsAsErrors.set(true) + } + } + + dependencies { + "implementation"(libs.findLibrary("ktor-serverCore").get()) + "implementation"(libs.findLibrary("ktor-serverNetty").get()) + "implementation"(libs.findLibrary("ktor-serverContentNegotiation").get()) + "implementation"(libs.findLibrary("ktor-serializationKotlinxJson").get()) + "implementation"(libs.findLibrary("logback").get()) + "implementation"(libs.findLibrary("flyway-core").get()) + "implementation"(libs.findLibrary("flyway-database-postgresql").get()) + "implementation"(libs.findLibrary("postgresql").get()) + "testImplementation"(libs.findLibrary("ktor-serverTestHost").get()) + "testImplementation"(libs.findLibrary("kotlin-testJunit").get()) + } + + flyway { + url = System.getenv("DATABASE_URL") ?: "jdbc:postgresql://localhost:5432/recipe" + user = System.getenv("DATABASE_USER") ?: "recipe" + password = System.getenv("DATABASE_PASSWORD") ?: "recipe" + locations = arrayOf("classpath:db/migration") + cleanDisabled = true + baselineOnMigrate = true + validateOnMigrate = true + } + ``` + + CRITICAL: + - `"implementation"(...)` with quoted-string configuration is MANDATORY inside precompiled plugins — the unquoted form is a typed method that only exists in module build scripts. + - The `flyway { }` block is for CLI ergonomics (`./gradlew flywayInfo`). Runtime migration uses the Java API (Plan 05 wires this). + + --- + + After writing all 7 files, verify that `build-logic/build.gradle.kts` can see the catalog by running a syntax-only check. No `./gradlew build` yet — Plan 03 wires the modules. + + + test -f build-logic/settings.gradle.kts && test -f build-logic/build.gradle.kts && test -f build-logic/src/main/kotlin/recipe.quality.gradle.kts && test -f build-logic/src/main/kotlin/recipe.kotlin.multiplatform.gradle.kts && test -f build-logic/src/main/kotlin/recipe.compose.multiplatform.gradle.kts && test -f build-logic/src/main/kotlin/recipe.android.application.gradle.kts && test -f build-logic/src/main/kotlin/recipe.jvm.server.gradle.kts && grep -q 'from(files("../gradle/libs.versions.toml"))' build-logic/settings.gradle.kts && grep -q '`kotlin-dsl`' build-logic/build.gradle.kts && grep -q 'asDependency' build-logic/build.gradle.kts && grep -q 'id("org.jetbrains.kotlin.multiplatform")' build-logic/src/main/kotlin/recipe.kotlin.multiplatform.gradle.kts && grep -q 'id("recipe.kotlin.multiplatform")' build-logic/src/main/kotlin/recipe.compose.multiplatform.gradle.kts && ! grep -q 'id("org.jetbrains.kotlin.multiplatform")' build-logic/src/main/kotlin/recipe.compose.multiplatform.gradle.kts && grep -q '"implementation"' build-logic/src/main/kotlin/recipe.jvm.server.gradle.kts && grep -q 'extensions.getByType' build-logic/src/main/kotlin/recipe.kotlin.multiplatform.gradle.kts + + + - All 7 files exist at their declared paths + - `build-logic/settings.gradle.kts` contains literal `from(files("../gradle/libs.versions.toml"))` + - `build-logic/settings.gradle.kts` ends with `rootProject.name = "build-logic"` + - `build-logic/build.gradle.kts` contains `` `kotlin-dsl` `` (triple-backtick plugin alias) + - `build-logic/build.gradle.kts` defines the `Provider.asDependency()` extension function + - `build-logic/build.gradle.kts` has exactly 10 `compileOnly(libs.plugins.*.asDependency())` calls (kotlinMultiplatform, androidApplication, androidLibrary, composeMultiplatform, composeCompiler, composeHotReload, kotlinJvm, ktor, spotless, flywayPlugin) + - `recipe.kotlin.multiplatform.gradle.kts` contains `id("org.jetbrains.kotlin.multiplatform")` (exactly ONCE, in the plugins block) + - `recipe.kotlin.multiplatform.gradle.kts` contains `baseName = "ComposeApp"` (D-20 / PITFALL #10) + - `recipe.kotlin.multiplatform.gradle.kts` contains `jvmToolchain(21)` AND `JvmTarget.JVM_11` AND `JvmTarget.JVM_21` (D-08 split) + - `recipe.kotlin.multiplatform.gradle.kts` contains `allWarningsAsErrors.set(true)` at the `kotlin { compilerOptions { } }` extension level (D-11) + - `recipe.kotlin.multiplatform.gradle.kts` does NOT contain `js {` or `iosX64` (D-01 / D-02) + - `recipe.compose.multiplatform.gradle.kts` contains `id("recipe.kotlin.multiplatform")` AND does NOT contain `id("org.jetbrains.kotlin.multiplatform")` (PITFALL #2 guard) + - `recipe.compose.multiplatform.gradle.kts` contains `id("org.jetbrains.compose.hot-reload")` (preserves commit c50d747) + - `recipe.android.application.gradle.kts` contains `namespace = "dev.ulfrx.recipe"` (D-20) + - `recipe.android.application.gradle.kts` uses `libs.findVersion("android-compileSdk").get().toString().toInt()` (PITFALL #1) + - `recipe.jvm.server.gradle.kts` uses quoted `"implementation"` (not unquoted `implementation(...)` — quoted-config footgun) + - `recipe.jvm.server.gradle.kts` contains `cleanDisabled = true` (PITFALL #6 safety) + - `recipe.quality.gradle.kts` contains `targetExclude("**/build/**", "**/generated/**")` (avoids scanning generated Compose resources) + - Every precompiled plugin that reads the catalog contains `extensions.getByType().named("libs")` + + build-logic/ scaffold complete; all 7 files follow canonical patterns; no PITFALL #1/#2/#7/#9/#10 violations detectable via grep. + + + + Task 2: Wire build-logic into root settings.gradle.kts and update root build.gradle.kts + settings.gradle.kts, build.gradle.kts + + - settings.gradle.kts (current 37-line content — target of edit) + - build.gradle.kts (current 12-line content — target of edit) + - .planning/phases/01-project-infrastructure-module-wiring/01-RESEARCH.md lines 749-767 (PITFALL #9 — includeBuild MUST be inside pluginManagement) + - .planning/phases/01-project-infrastructure-module-wiring/01-PATTERNS.md lines 510-572 (settings.gradle.kts + root build.gradle.kts deltas) + - .planning/phases/01-project-infrastructure-module-wiring/01-CONTEXT.md lines 107-109 (build-logic/ as included build — standard Gradle pattern) + + + Edit two files. + + --- + + **Edit 1: `settings.gradle.kts`** — add `includeBuild("build-logic")` as the FIRST statement inside the existing `pluginManagement { }` block. Do NOT move or remove any other line. + + The current `pluginManagement { }` block (lines 4-16 of the existing file) should become: + + ```kotlin + pluginManagement { + includeBuild("build-logic") + repositories { + google { + mavenContent { + includeGroupAndSubgroups("androidx") + includeGroupAndSubgroups("com.android") + includeGroupAndSubgroups("com.google") + } + } + mavenCentral() + gradlePluginPortal() + } + } + ``` + + PITFALL #9 is load-bearing: `includeBuild` MUST be inside `pluginManagement { }`, NOT at top level, and NOT inside `dependencyResolutionManagement { }`. Placing it elsewhere means child modules cannot resolve `id("recipe.*")` plugin IDs. + + Do NOT modify: + - Line 1: `rootProject.name = "recipe"` + - Line 2: `enableFeaturePreview("TYPESAFE_PROJECT_ACCESSORS")` + - `dependencyResolutionManagement { }` block + - `plugins { id("org.gradle.toolchains.foojay-resolver-convention") version "1.0.0" }` + - `include(":composeApp")`, `include(":server")`, `include(":shared")` + + --- + + **Edit 2: `build.gradle.kts`** — append two new `alias(...) apply false` entries to the existing plugins block. Keep the existing 8 entries in their current order. + + Result: + + ```kotlin + plugins { + // this is necessary to avoid the plugins to be loaded multiple times + // in each subproject's classloader + alias(libs.plugins.androidApplication) apply false + alias(libs.plugins.androidLibrary) apply false + alias(libs.plugins.composeHotReload) apply false + alias(libs.plugins.composeMultiplatform) apply false + alias(libs.plugins.composeCompiler) apply false + alias(libs.plugins.kotlinJvm) apply false + alias(libs.plugins.kotlinMultiplatform) apply false + alias(libs.plugins.ktor) apply false + alias(libs.plugins.spotless) apply false + alias(libs.plugins.flywayPlugin) apply false + } + ``` + + Why the `apply false` entries: Gradle's plugin classloader uses these declarations as hints when the plugin is applied through an included-build's precompiled plugin. `recipe.quality` applies `com.diffplug.spotless` and `recipe.jvm.server` applies `org.flywaydb.flyway` — the root `apply false` entries ensure a single resolved classpath per plugin ID (per the existing template's comment). + + + grep -q 'includeBuild("build-logic")' settings.gradle.kts && awk '/pluginManagement \{/,/^\}/' settings.gradle.kts | grep -q 'includeBuild("build-logic")' && ! awk '/dependencyResolutionManagement \{/,/^\}/' settings.gradle.kts | grep -q 'includeBuild' && grep -q 'alias(libs.plugins.spotless) apply false' build.gradle.kts && grep -q 'alias(libs.plugins.flywayPlugin) apply false' build.gradle.kts && grep -c 'apply false' build.gradle.kts | grep -q '^10$' + + + - `settings.gradle.kts` contains `includeBuild("build-logic")` exactly 1 time + - That `includeBuild("build-logic")` line appears INSIDE the `pluginManagement { ... }` block (verifiable: `awk '/pluginManagement \{/,/^\}/' settings.gradle.kts | grep -q 'includeBuild("build-logic")'`) + - `settings.gradle.kts` does NOT contain `includeBuild` anywhere else (NOT at top level, NOT in `dependencyResolutionManagement`) + - `settings.gradle.kts` still contains `rootProject.name = "recipe"` (unmodified line 1) + - `settings.gradle.kts` still contains `include(":composeApp")`, `include(":server")`, `include(":shared")` (unmodified) + - `build.gradle.kts` contains `alias(libs.plugins.spotless) apply false` + - `build.gradle.kts` contains `alias(libs.plugins.flywayPlugin) apply false` + - `grep -c 'apply false' build.gradle.kts` returns `10` (8 existing + 2 new) + - All 8 existing `alias(...)` lines are preserved + + build-logic/ is discoverable as an included build for plugin resolution; root `build.gradle.kts` declares classloader hints for Spotless + Flyway. + + + + + +## Trust Boundaries + +| Boundary | Description | +|----------|-------------| +| Gradle build → build-logic/ (included build) | Same-repo; no external trust boundary. Precompiled plugins run in the Gradle daemon's JVM with full project access by design. | +| build-logic precompiled plugins → Maven Central + plugin portal | Inherits repository set from `build-logic/settings.gradle.kts.dependencyResolutionManagement` (google, mavenCentral, gradlePluginPortal). Pinned plugin versions via catalog aliases. | + +## STRIDE Threat Register + +| Threat ID | Category | Component | Disposition | Mitigation Plan | +|-----------|----------|-----------|-------------|-----------------| +| T-01-02-01 | Tampering (supply chain) | Precompiled plugin classpath | mitigate | Plugin versions resolved exclusively from catalog aliases via `asDependency()` — no literal versions leak into build-logic/build.gradle.kts. D-09 catalog-only rule enforced by Plan 07's `tools/verify-no-version-literals.sh`. | +| T-01-02-02 | Elevation of Privilege | `recipe.jvm.server` applying Flyway to non-server modules | mitigate | `recipe.jvm.server` is applied ONLY to `server/build.gradle.kts` (Plan 03). The plugin bundles `io.ktor.plugin` + `org.flywaydb.flyway` + Postgres JDBC — if accidentally applied to `composeApp`, AGP would fail at configuration time. Role-declaration design (D-06) makes misuse obvious. | +| T-01-02-03 | Tampering | `recipe.quality` Spotless scanning untrusted paths | accept | Spotless config restricted via `target("src/**/*.kt")` + `targetExclude("**/build/**", "**/generated/**")`. No execution of scanned code; ktlint is pure static analysis. | +| T-01-02-04 | Denial of Service | Misspelled plugin ID breaks entire root build | mitigate | Task 1 `` greps for exact plugin IDs and the `id("recipe.kotlin.multiplatform")` layering in `recipe.compose.multiplatform.gradle.kts`. Plan 03's `./gradlew help` invocations will surface any remaining typos immediately. | + + + +Phase-level verification for this plan: + +- `tools/verify-no-version-literals.sh` still exits 0 (build-logic/build.gradle.kts is explicitly excluded by the script — the `asDependency()` coordinates contain a version string as part of the synthesized artifact coord, but the script excludes that single file). +- No Gradle command is run yet — Plan 03 refactors modules to apply these plugins; until then, the root `./gradlew build` will still work against the EXISTING module build files (which have not yet been refactored). + +Optional fast sanity check (if needed): +- `./gradlew --help` exits 0 (proves `settings.gradle.kts` still parses). +- `./gradlew help` (without args) exits 0 (proves `includeBuild` is legal). + +These sanity checks are NOT in the `` verify blocks to keep them fast; run them once manually if a later plan fails unexpectedly. + + + +- 7 files under `build-logic/` created with canonical content (exact path listing in `files_modified`) +- `settings.gradle.kts` has `includeBuild("build-logic")` inside `pluginManagement { }` +- `build.gradle.kts` has 10 `apply false` entries (8 existing + 2 new for Spotless + Flyway) +- No existing version aliases or source files modified in Plan 01 or prior +- `tools/verify-no-version-literals.sh` continues to exit 0 + + + +After completion, create `.planning/phases/01-project-infrastructure-module-wiring/01-02-SUMMARY.md` recording: file tree under `build-logic/`, any deviations from canonical excerpts (expected: none), and the final plugin ID list (10 applies from recipe-family + spotless/flyway). + diff --git a/.planning/phases/01-project-infrastructure-module-wiring/01-03-PLAN.md b/.planning/phases/01-project-infrastructure-module-wiring/01-03-PLAN.md new file mode 100644 index 0000000..1377b87 --- /dev/null +++ b/.planning/phases/01-project-infrastructure-module-wiring/01-03-PLAN.md @@ -0,0 +1,352 @@ +--- +phase: 01-project-infrastructure-module-wiring +plan: 03 +type: execute +wave: 2 +depends_on: [01, 02] +files_modified: + - composeApp/build.gradle.kts + - shared/build.gradle.kts + - server/build.gradle.kts + - shared/src/jsMain +autonomous: true +requirements: [INFRA-02, INFRA-06] +requirements_addressed: [INFRA-02, INFRA-06] + +must_haves: + truths: + - "composeApp/build.gradle.kts applies recipe.kotlin.multiplatform + recipe.compose.multiplatform + recipe.android.application + recipe.quality, and nothing else (D-06 role declaration)" + - "shared/build.gradle.kts applies recipe.kotlin.multiplatform + recipe.quality + androidLibrary alias, with explicitApi() set directly in the module (D-12)" + - "server/build.gradle.kts applies recipe.jvm.server + recipe.quality, and keeps only the module-specific application { } block" + - "The js target is removed from composeApp and shared (D-01); shared/src/jsMain/ directory is deleted" + - "iosX64 target is never referenced (D-02) — only iosArm64 + iosSimulatorArm64 via the convention plugin" + - "No version literals exist in any *.gradle.kts outside gradle/libs.versions.toml (INFRA-01 / D-09)" + - "shared/ framework basename is overridden to 'Shared' (D-07, PITFALL #10); composeApp keeps 'ComposeApp' from the convention plugin" + artifacts: + - path: "composeApp/build.gradle.kts" + provides: "Module build applying 4 recipe.* convention plugins + module-only source-set deps (androidMain, commonMain projects.shared, jvmMain desktop)" + min_lines: 15 + - path: "shared/build.gradle.kts" + provides: "Module build applying recipe.kotlin.multiplatform + recipe.quality + androidLibrary; enabling explicitApi(); overriding framework baseName to 'Shared'; keeping android { namespace } block" + min_lines: 15 + - path: "server/build.gradle.kts" + provides: "Module build applying recipe.jvm.server + recipe.quality; keeping application { mainClass } block and implementation(projects.shared) dep" + min_lines: 10 + key_links: + - from: "composeApp/build.gradle.kts" + to: "build-logic/src/main/kotlin/recipe.kotlin.multiplatform.gradle.kts" + via: "plugins { id(\"recipe.kotlin.multiplatform\") }" + pattern: "id\\(\"recipe\\.kotlin\\.multiplatform\"\\)" + - from: "composeApp/build.gradle.kts" + to: "build-logic/src/main/kotlin/recipe.compose.multiplatform.gradle.kts" + via: "plugins { id(\"recipe.compose.multiplatform\") }" + pattern: "id\\(\"recipe\\.compose\\.multiplatform\"\\)" + - from: "server/build.gradle.kts" + to: "build-logic/src/main/kotlin/recipe.jvm.server.gradle.kts" + via: "plugins { id(\"recipe.jvm.server\") }" + pattern: "id\\(\"recipe\\.jvm\\.server\"\\)" +--- + + +Refactor the three module build scripts (`composeApp/`, `shared/`, `server/`) to apply the convention plugins from Plan 02 and remove the content those plugins now own. Drop the `js` target (D-01), confirm `iosX64` stays absent (D-02), add `explicitApi()` + framework-basename override to `shared/` (D-12 / PITFALL #10), and ensure every module's `plugins { }` block reads as a role declaration (D-06). Also delete the `shared/src/jsMain/` source directory (D-01). + +Purpose: This plan delivers INFRA-02's structural payoff — adding a new KMP module in the future should require only `plugins { id("recipe.kotlin.multiplatform") }` + source-set declarations, not copy-pasting Compose configs. It also delivers INFRA-06's structural prerequisite: after this refactor, `shared/` no longer pulls Compose transitively (because `recipe.compose.multiplatform` is applied only to `composeApp/`). + +Output: Three rewritten `build.gradle.kts` files (each ≤40 lines), `shared/src/jsMain/` directory deleted. No `./gradlew build` run in this plan — Plan 04/05 verify via their own targets, Plan 07 runs the full green-build gate. + + + +@$HOME/.claude/get-shit-done/workflows/execute-plan.md +@$HOME/.claude/get-shit-done/templates/summary.md + + + +@.planning/PROJECT.md +@.planning/ROADMAP.md +@.planning/STATE.md +@.planning/phases/01-project-infrastructure-module-wiring/01-CONTEXT.md +@.planning/phases/01-project-infrastructure-module-wiring/01-RESEARCH.md +@.planning/phases/01-project-infrastructure-module-wiring/01-PATTERNS.md +@.planning/phases/01-project-infrastructure-module-wiring/01-VALIDATION.md +@composeApp/build.gradle.kts +@shared/build.gradle.kts +@server/build.gradle.kts +@CLAUDE.md + + + + +From gradle/libs.versions.toml (Plan 01 extended): +- `libs.plugins.androidLibrary` — still referenced as alias inside shared/build.gradle.kts +- `libs.compose.uiToolingPreview` — referenced from composeApp/build.gradle.kts module-specific deps +- `libs.androidx.activity.compose` — referenced from composeApp androidMain deps +- `libs.kotlinx.coroutinesSwing` — referenced from composeApp jvmMain deps +- `libs.compose.uiTooling` — referenced from composeApp debugImplementation +- `libs.koin.android` — NEW alias (Plan 01) for MainApplication's `androidContext(...)` in Plan 04 + +From build-logic/src/main/kotlin/ (Plan 02 created): +- `recipe.kotlin.multiplatform` — applies KMP, sets D-05 targets, JVM toolchain, adds koin-bom/koin-core/kermit to commonMain, kotlin-test to commonTest, allWarningsAsErrors +- `recipe.compose.multiplatform` — applies Compose MP + hot-reload on top of KMP, adds compose-* deps to commonMain +- `recipe.android.application` — applies com.android.application, sets namespace + SDK versions +- `recipe.jvm.server` — applies kotlin(jvm) + io.ktor.plugin + flyway + all server deps + quoted-config dependency block +- `recipe.quality` — applies Spotless + allWarningsAsErrors safety net + + + + + + + Task 1: Rewrite composeApp/build.gradle.kts and shared/build.gradle.kts, delete shared/src/jsMain/ + composeApp/build.gradle.kts, shared/build.gradle.kts, shared/src/jsMain + + - composeApp/build.gradle.kts (current 114 lines — target of rewrite) + - shared/build.gradle.kts (current 55 lines — target of rewrite) + - .planning/phases/01-project-infrastructure-module-wiring/01-PATTERNS.md lines 574-672 (exact deltas for composeApp + shared) + - .planning/phases/01-project-infrastructure-module-wiring/01-CONTEXT.md D-01 (drop js), D-03 (no desktop packaging), D-12 (explicitApi on shared only), D-20 (namespace + baseName) + - .planning/phases/01-project-infrastructure-module-wiring/01-RESEARCH.md lines 1144-1155 (Open Question #1 — keep com.android.library on shared/ in Phase 1) + + + Two file rewrites plus one directory deletion. + + **Rewrite 1: `composeApp/build.gradle.kts`** — replace entire file content with: + + ```kotlin + plugins { + id("recipe.kotlin.multiplatform") + id("recipe.compose.multiplatform") + id("recipe.android.application") + id("recipe.quality") + } + + kotlin { + sourceSets { + androidMain.dependencies { + implementation(libs.compose.uiToolingPreview) + implementation(libs.androidx.activity.compose) + implementation(libs.koin.android) + } + commonMain.dependencies { + implementation(libs.compose.uiToolingPreview) + implementation(projects.shared) + } + jvmMain.dependencies { + implementation(compose.desktop.currentOs) + implementation(libs.kotlinx.coroutinesSwing) + } + } + } + + dependencies { + debugImplementation(libs.compose.uiTooling) + } + ``` + + DELETIONS relative to the current file: + - DROP all 3 imports on lines 1-3 (no longer needed — convention plugins supply JvmTarget/ExperimentalWasmDsl/TargetFormat) + - DROP the original `plugins { alias(...) alias(...) }` block (lines 5-11) — replaced with 4 recipe.* IDs + - DROP the `kotlin { androidTarget { ... } iosArm64() iosSimulatorArm64() jvm { ... } js { ... } wasmJs { ... } }` structural block (lines 13-46) — moved into `recipe.kotlin.multiplatform` + - DROP `commonMain.dependencies` Compose entries (lines 52-62 — compose.runtime, compose.foundation, compose.material3, compose.ui, compose.components.resources, androidx.lifecycle.viewmodelCompose, androidx.lifecycle.runtimeCompose) — moved into `recipe.compose.multiplatform`. KEEP `implementation(projects.shared)` and the module-only `implementation(libs.compose.uiToolingPreview)` (the preview tooling is needed by `@Preview` annotations in composeApp's common code). + - DROP `commonTest.dependencies { implementation(libs.kotlin.test) }` (lines 63-65) — moved into `recipe.kotlin.multiplatform` + - DROP the entire `android { ... }` block (lines 73-98) — moved into `recipe.android.application` + - DROP `compose.desktop { application { ... nativeDistributions { ... } } }` (lines 104-114) — D-03 says no desktop packaging + + ADDITIONS: + - ADD `implementation(libs.koin.android)` to `androidMain.dependencies` (Plan 04's MainApplication.kt calls `androidContext(...)` which comes from koin-android; the catalog alias was added in Plan 01). + + KEEP: + - `androidMain.dependencies { implementation(libs.compose.uiToolingPreview); implementation(libs.androidx.activity.compose) }` — Android-only deps + - `jvmMain.dependencies { implementation(compose.desktop.currentOs); implementation(libs.kotlinx.coroutinesSwing) }` — Desktop-only deps + - `dependencies { debugImplementation(libs.compose.uiTooling) }` — Android debug-only tooling + + **Rewrite 2: `shared/build.gradle.kts`** — replace entire file content with: + + ```kotlin + plugins { + id("recipe.kotlin.multiplatform") + id("recipe.quality") + alias(libs.plugins.androidLibrary) + } + + kotlin { + explicitApi() + + // Override framework baseName: shared exposes "Shared.framework" to Swift, while + // composeApp's convention-plugin default is "ComposeApp.framework". (D-07 / PITFALL #10) + targets.withType().configureEach { + binaries.withType().configureEach { + baseName = "Shared" + } + } + + sourceSets { + commonMain.dependencies { + // Phase 1: intentionally empty. Domain models + DTOs land Phase 2+. + // D-19 / INFRA-06: Do NOT add Ktor, Compose, or SQLDelight deps here — EVER. + } + } + } + + android { + namespace = "dev.ulfrx.recipe.shared" + compileSdk = libs.versions.android.compileSdk.get().toInt() + compileOptions { + sourceCompatibility = JavaVersion.VERSION_11 + targetCompatibility = JavaVersion.VERSION_11 + } + defaultConfig { + minSdk = libs.versions.android.minSdk.get().toInt() + } + } + ``` + + DELETIONS relative to the current file: + - DROP both imports on lines 1-2 (no longer needed) + - DROP the original `plugins { alias(libs.plugins.kotlinMultiplatform); alias(libs.plugins.androidLibrary) }` (lines 4-7) — replaced with `id("recipe.kotlin.multiplatform")` + kept `alias(libs.plugins.androidLibrary)` + - DROP the entire `kotlin { androidTarget { ... } iosArm64() iosSimulatorArm64() jvm { ... } js { ... } wasmJs { ... } ... }` structural block (lines 9-41) — moved into `recipe.kotlin.multiplatform` + - DROP `js { browser() }` (lines 25-27) — D-01 + + ADDITIONS: + - ADD `explicitApi()` inside the `kotlin { }` block (D-12 — strict on shared/ only, configured directly in module) + - ADD the framework baseName override block targeting `KotlinNativeTarget`/`Framework` (overrides the convention plugin's `"ComposeApp"` default to `"Shared"` — D-07 / PITFALL #10) + + KEEP: + - `android { namespace = "dev.ulfrx.recipe.shared"; compileSdk; compileOptions; defaultConfig.minSdk }` — per 01-RESEARCH.md Open Question #1, keep `com.android.library` applied in Phase 1 (deferring the "do we need it" question to a future `recipe.android.library` plugin) + + Note on `libs.versions.android.compileSdk.get().toInt()` vs `libs.findVersion(...)`: the `libs.versions.*` accessor IS available in MODULE `build.gradle.kts` files (it's only unavailable in precompiled plugins — PITFALL #1 applies only there). So the typed accessor is correct here. + + **Directory deletion: `shared/src/jsMain/`** + + Delete the entire `shared/src/jsMain/` directory (contains `kotlin/dev/ulfrx/recipe/Platform.js.kt`). D-01 drops the `js` target; with `recipe.kotlin.multiplatform` no longer declaring `js()`, this source directory becomes orphaned. + + Run: `rm -rf shared/src/jsMain` + + Do NOT delete `shared/src/wasmJsMain/` — `wasmJs` is kept per D-01. `composeApp/src/webMain/` is the wasmJs source set, also kept. + + + grep -q 'id("recipe.kotlin.multiplatform")' composeApp/build.gradle.kts && grep -q 'id("recipe.compose.multiplatform")' composeApp/build.gradle.kts && grep -q 'id("recipe.android.application")' composeApp/build.gradle.kts && grep -q 'id("recipe.quality")' composeApp/build.gradle.kts && ! grep -q 'androidTarget' composeApp/build.gradle.kts && ! grep -q 'iosArm64' composeApp/build.gradle.kts && ! grep -q 'js {' composeApp/build.gradle.kts && ! grep -q 'nativeDistributions' composeApp/build.gradle.kts && ! grep -q '^android {' composeApp/build.gradle.kts && grep -q 'implementation(libs.koin.android)' composeApp/build.gradle.kts && grep -q 'id("recipe.kotlin.multiplatform")' shared/build.gradle.kts && grep -q 'id("recipe.quality")' shared/build.gradle.kts && grep -q 'explicitApi()' shared/build.gradle.kts && grep -q 'baseName = "Shared"' shared/build.gradle.kts && ! grep -q 'js {' shared/build.gradle.kts && ! test -d shared/src/jsMain && bash tools/verify-no-version-literals.sh && bash tools/verify-shared-pure.sh + + + - `composeApp/build.gradle.kts` has exactly 4 `id("recipe.*")` lines: `recipe.kotlin.multiplatform`, `recipe.compose.multiplatform`, `recipe.android.application`, `recipe.quality` + - `composeApp/build.gradle.kts` does NOT contain `androidTarget`, `iosArm64`, `iosSimulatorArm64`, `jvm {`, `js {`, `wasmJs {`, or any `binaries.framework` block (all moved to convention plugin) + - `composeApp/build.gradle.kts` does NOT contain an `^android {` block header (moved to `recipe.android.application`) + - `composeApp/build.gradle.kts` does NOT contain `nativeDistributions` or `compose.desktop { application { ... } }` (D-03) + - `composeApp/build.gradle.kts` does NOT contain `import org.jetbrains.compose.desktop.application.dsl.TargetFormat` (D-03) + - `composeApp/build.gradle.kts` contains `implementation(libs.koin.android)` inside an `androidMain.dependencies` block + - `composeApp/build.gradle.kts` contains `implementation(projects.shared)` in `commonMain.dependencies` (preserved for Plan 04 usage) + - `composeApp/build.gradle.kts` line count ≤ 30 (was 114) + - `shared/build.gradle.kts` has `id("recipe.kotlin.multiplatform")` + `id("recipe.quality")` + `alias(libs.plugins.androidLibrary)` (exactly 3 plugin applications) + - `shared/build.gradle.kts` contains `explicitApi()` (D-12) + - `shared/build.gradle.kts` contains `baseName = "Shared"` (exactly that capitalization — PITFALL #10) + - `shared/build.gradle.kts` does NOT contain `js {` or `iosX64` + - `shared/build.gradle.kts` contains the `android { namespace = "dev.ulfrx.recipe.shared" }` block (kept per Open Question #1) + - `shared/src/jsMain` directory no longer exists (`test ! -d shared/src/jsMain`) + - `tools/verify-no-version-literals.sh` exits 0 (no version literals leaked during rewrite) + - `tools/verify-shared-pure.sh` exits 0 (shared/commonMain has only Greeting.kt/Platform.kt/Constants.kt — no forbidden imports) + + Both module builds apply recipe.* conventions; js target source dir deleted; explicitApi + Shared basename set on shared/. + + + + Task 2: Rewrite server/build.gradle.kts + server/build.gradle.kts + + - server/build.gradle.kts (current 23 lines — target of rewrite) + - .planning/phases/01-project-infrastructure-module-wiring/01-PATTERNS.md lines 674-706 (server/build.gradle.kts delta) + - .planning/phases/01-project-infrastructure-module-wiring/01-RESEARCH.md lines 556-605 (§ Pattern 7 — what's ALREADY in the convention plugin and does NOT need to be in the module) + - .planning/phases/01-project-infrastructure-module-wiring/01-CONTEXT.md D-16 (server scope — Flyway, Postgres, /health) + + + Replace the entire content of `server/build.gradle.kts` with: + + ```kotlin + plugins { + id("recipe.jvm.server") + id("recipe.quality") + } + + group = "dev.ulfrx.recipe" + version = "1.0.0" + + application { + mainClass.set("dev.ulfrx.recipe.ApplicationKt") + + val isDevelopment: Boolean = project.ext.has("development") + applicationDefaultJvmArgs = listOf("-Dio.ktor.development=$isDevelopment") + } + + dependencies { + implementation(projects.shared) + } + ``` + + DELETIONS: + - DROP original plugins block (lines 1-5 — `alias(libs.plugins.kotlinJvm); alias(libs.plugins.ktor); application`) → replaced with 2 recipe.* IDs. The `application` plugin is applied by `recipe.jvm.server`. + - DROP individual dependency lines (lines 16-22 — `libs.logback`, `libs.ktor.serverCore`, `libs.ktor.serverNetty`, `libs.ktor.serverTestHost`, `libs.kotlin.testJunit`) → all moved into `recipe.jvm.server`. + + KEEP: + - `group = "dev.ulfrx.recipe"` and `version = "1.0.0"` (module coordinates — per-module concern) + - `application { mainClass.set(...) }` + `applicationDefaultJvmArgs` (per-module config — the `application` plugin is applied by `recipe.jvm.server` but this config is module-specific) + - `implementation(projects.shared)` — module-specific project dependency (server depends on shared for Greeting, SERVER_PORT, future DTOs) + + Note: `ktor-serverContentNegotiation`, `ktor-serializationKotlinxJson`, `flyway-core`, `flyway-database-postgresql`, `postgresql` are ALL bundled in `recipe.jvm.server` and do NOT need to be declared here. + + + grep -q 'id("recipe.jvm.server")' server/build.gradle.kts && grep -q 'id("recipe.quality")' server/build.gradle.kts && ! grep -q 'libs.plugins.kotlinJvm' server/build.gradle.kts && ! grep -q 'libs.plugins.ktor' server/build.gradle.kts && grep -q 'mainClass.set("dev.ulfrx.recipe.ApplicationKt")' server/build.gradle.kts && grep -q 'implementation(projects.shared)' server/build.gradle.kts && ! grep -q 'libs.logback' server/build.gradle.kts && ! grep -q 'libs.ktor.serverCore' server/build.gradle.kts && bash tools/verify-no-version-literals.sh + + + - `server/build.gradle.kts` has exactly 2 `id("recipe.*")` lines: `recipe.jvm.server`, `recipe.quality` + - `server/build.gradle.kts` does NOT contain `alias(libs.plugins.kotlinJvm)` or `alias(libs.plugins.ktor)` + - `server/build.gradle.kts` does NOT contain `libs.logback`, `libs.ktor.serverCore`, `libs.ktor.serverNetty`, `libs.ktor.serverTestHost`, or `libs.kotlin.testJunit` (all relocated to convention plugin) + - `server/build.gradle.kts` contains `mainClass.set("dev.ulfrx.recipe.ApplicationKt")` (unchanged) + - `server/build.gradle.kts` contains `implementation(projects.shared)` (unchanged) + - `server/build.gradle.kts` contains `group = "dev.ulfrx.recipe"` and `version = "1.0.0"` (unchanged module coordinates) + - `server/build.gradle.kts` line count ≤ 20 (was 23; effectively unchanged but deps block shrinks) + - `tools/verify-no-version-literals.sh` exits 0 + + server build applies recipe.jvm.server + recipe.quality; module-only config (mainClass, shared dep) preserved. + + + + + +## Trust Boundaries + +| Boundary | Description | +|----------|-------------| +| Module build scripts → build-logic precompiled plugins | Same repo; plugins apply privileged build configuration (namespace, SDK versions, dep injection). No external trust boundary. | +| Gradle module configuration → dependency resolution | Same as Plan 02 — aliases resolved via `libs.versions.toml` (pinned); no runtime consequences until Plan 04/05 actually compile code. | + +## STRIDE Threat Register + +| Threat ID | Category | Component | Disposition | Mitigation Plan | +|-----------|----------|-----------|-------------|-----------------| +| T-01-03-01 | Tampering (supply-chain leak) | Accidental version literal in rewrites | mitigate | Every task's `` runs `tools/verify-no-version-literals.sh` which scans every `*.gradle.kts` after the rewrite. Any inlined version (e.g. a forgotten `"1.0.0"` as a dep version) fails the check. Note: `version = "1.0.0"` on `server/build.gradle.kts` line 2 is PROJECT coordinate, not a dependency version — the verify script targets `version\s*=\s*"[0-9]` inside dependency declarations only; project-version assignments pass (not declared as `libs.*` lookup). Verify script scope matches PATTERNS.md spec. | +| T-01-03-02 | Elevation of Privilege | Compose deps leak into shared/ | mitigate | `shared/build.gradle.kts` applies ONLY `recipe.kotlin.multiplatform` + `recipe.quality` + `androidLibrary` — NOT `recipe.compose.multiplatform`. Plan 07's `tools/verify-shared-pure.sh` will catch forbidden imports if they ever appear. | +| T-01-03-03 | Denial of Service | Missing `recipe.compose.multiplatform` application on composeApp breaks Compose | mitigate | Task 1 `` greps for all 4 recipe IDs explicitly. Plan 04 will fail at compile time if the Compose plugin ID is missing. | +| T-01-03-04 | Tampering | `js` target remnants in source tree after D-01 drop | mitigate | Task 1 explicitly deletes `shared/src/jsMain/` directory and greps for `js {` blocks. `composeApp/src/webMain/` (wasmJs target, kept) is NOT touched. | + + + +Phase-level verification for this plan: + +- All three `tools/verify-*.sh` scripts exit 0 after rewrites. +- `shared/src/jsMain/` directory no longer exists. +- `composeApp/build.gradle.kts` shrinks from 114 to ~30 lines — INFRA-02 payoff visible. +- `shared/build.gradle.kts` shrinks from 55 to ~35 lines and now sets `explicitApi()`. + +Optional sanity check (NOT in `` — Plan 07 runs the full gate): +- `./gradlew :composeApp:help -q` emits a non-empty help output without a configuration error (proves plugin IDs resolve). Skip for speed — Plan 04 and Plan 05 will surface plugin-application errors via their own `./gradlew` targets. + + + +- `composeApp/build.gradle.kts` applies 4 recipe.* IDs and contains NO `kotlin { androidTarget { ... } ... }` structural block and NO `android { ... }` block and NO `nativeDistributions` +- `shared/build.gradle.kts` applies 3 plugins (2 recipe.* + androidLibrary), enables `explicitApi()`, overrides baseName to `"Shared"` +- `server/build.gradle.kts` applies 2 recipe.* IDs and keeps only `application { mainClass }` + `implementation(projects.shared)` +- `shared/src/jsMain/` deleted +- `tools/verify-no-version-literals.sh` exits 0 +- `tools/verify-shared-pure.sh` exits 0 + + + +After completion, create `.planning/phases/01-project-infrastructure-module-wiring/01-03-SUMMARY.md` recording: final LOC of each module build file (target: composeApp ≤30, shared ≤35, server ≤20), any deviations from the canonical patterns (expected: none), and confirmation that `shared/src/jsMain/` is gone. + diff --git a/.planning/phases/01-project-infrastructure-module-wiring/01-04-PLAN.md b/.planning/phases/01-project-infrastructure-module-wiring/01-04-PLAN.md new file mode 100644 index 0000000..14a8a1e --- /dev/null +++ b/.planning/phases/01-project-infrastructure-module-wiring/01-04-PLAN.md @@ -0,0 +1,495 @@ +--- +phase: 01-project-infrastructure-module-wiring +plan: 04 +type: execute +wave: 2 +depends_on: [01, 02] +files_modified: + - composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/di/Koin.kt + - composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/di/AppModule.kt + - composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/logging/Logging.kt + - composeApp/src/iosMain/kotlin/dev/ulfrx/recipe/di/KoinIos.kt + - composeApp/src/androidMain/kotlin/dev/ulfrx/recipe/MainApplication.kt + - composeApp/src/androidMain/AndroidManifest.xml + - composeApp/src/jvmMain/kotlin/dev/ulfrx/recipe/main.kt + - composeApp/src/webMain/kotlin/dev/ulfrx/recipe/main.kt + - iosApp/iosApp/iOSApp.swift +autonomous: true +requirements: [INFRA-02] +requirements_addressed: [INFRA-02] + +must_haves: + truths: + - "initKoin() is defined once in commonMain and called exactly once per platform entry point (no double-init — PITFALL #4)" + - "configureLogging() runs BEFORE initKoin() on every platform (so Koin module loading can use Kermit)" + - "App.kt (@Composable) never calls startKoin — Koin is started outside composition (anti-pattern guard in Pattern 4)" + - "appModule is an empty Koin module placeholder; Phase 2+ adds authModule, syncModule, etc." + - "Kermit tag is 'recipe' (D-15)" + - "iOS Swift side calls KoinIosKt.doInitKoin() inside iOSApp.init() — one call site" + - "Android uses MainApplication registered via android:name=\".MainApplication\" in AndroidManifest.xml" + - "wasmJs main() initializes Koin + logging BEFORE ComposeViewport { App() } (PITFALL #8 future-proof)" + artifacts: + - path: "composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/di/Koin.kt" + provides: "initKoin(config: KoinAppDeclaration? = null): KoinApplication helper invoking startKoin { modules(appModule) }" + exports: ["initKoin"] + - path: "composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/di/AppModule.kt" + provides: "Empty val appModule = module { } placeholder" + exports: ["appModule"] + - path: "composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/logging/Logging.kt" + provides: "configureLogging() — Logger.setTag(\"recipe\")" + exports: ["configureLogging"] + - path: "composeApp/src/iosMain/kotlin/dev/ulfrx/recipe/di/KoinIos.kt" + provides: "fun doInitKoin() { configureLogging(); initKoin() } — exported as Swift symbol KoinIosKt.doInitKoin" + exports: ["doInitKoin"] + - path: "composeApp/src/androidMain/kotlin/dev/ulfrx/recipe/MainApplication.kt" + provides: "class MainApplication : Application() { onCreate → configureLogging(); initKoin { androidContext(this) } }" + - path: "composeApp/src/androidMain/AndroidManifest.xml" + provides: "" + contains: "android:name=\".MainApplication\"" + - path: "composeApp/src/jvmMain/kotlin/dev/ulfrx/recipe/main.kt" + provides: "Desktop main() invoking configureLogging() + initKoin() before application { Window { App() } }" + - path: "composeApp/src/webMain/kotlin/dev/ulfrx/recipe/main.kt" + provides: "Wasm main() invoking configureLogging() + initKoin() before ComposeViewport { App() } (PITFALL #8)" + - path: "iosApp/iosApp/iOSApp.swift" + provides: "Swift @main struct with init() { KoinIosKt.doInitKoin() } and import ComposeApp" + contains: "import ComposeApp", "KoinIosKt.doInitKoin()" + key_links: + - from: "iosApp/iosApp/iOSApp.swift" + to: "composeApp/src/iosMain/kotlin/dev/ulfrx/recipe/di/KoinIos.kt" + via: "Kotlin top-level fun doInitKoin → Swift symbol KoinIosKt.doInitKoin()" + pattern: "KoinIosKt\\.doInitKoin\\(\\)" + - from: "composeApp/src/androidMain/AndroidManifest.xml" + to: "composeApp/src/androidMain/kotlin/dev/ulfrx/recipe/MainApplication.kt" + via: "android:name=\".MainApplication\" attribute on " + pattern: "android:name=\"\\.MainApplication\"" + - from: "MainApplication.onCreate / iOSApp.init / jvm main / wasm main" + to: "composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/di/Koin.kt" + via: "initKoin() call" + pattern: "initKoin\\(" +--- + + +Wire the Koin + Kermit bootstrap across every composeApp platform entry point. Create the two commonMain source files (`di/Koin.kt`, `di/AppModule.kt`, `logging/Logging.kt`), the iOS Kotlin bridge (`iosMain/di/KoinIos.kt`), the Android `Application` subclass + manifest registration, modify the JVM + Wasm entry points to call `configureLogging() → initKoin()` before composition, and modify Swift's `iOSApp.swift` to call `KoinIosKt.doInitKoin()` inside `init()`. The Kermit tag is `"recipe"` (D-15); the Koin module is an empty placeholder (D-14) that Phase 2+ extends. + +Purpose: Phase 1 proves the DI + logging wiring is correct from day 1 so Phase 2 (Auth) can add `authModule`, Phase 4 can add `syncModule`, etc. without revisiting the bootstrap mechanics. PITFALL #4 (double-init on iOS) is neutralized by concentrating all startup into one `initKoin()` helper with a single call site per platform. + +Output: 9 files created or modified (6 new Kotlin files, 1 manifest edit, 2 existing entry-point rewrites, 1 Swift rewrite). No ViewModels yet — Phase 1 has no screens beyond the template. + + + +@$HOME/.claude/get-shit-done/workflows/execute-plan.md +@$HOME/.claude/get-shit-done/templates/summary.md + + + +@.planning/PROJECT.md +@.planning/ROADMAP.md +@.planning/STATE.md +@.planning/phases/01-project-infrastructure-module-wiring/01-CONTEXT.md +@.planning/phases/01-project-infrastructure-module-wiring/01-RESEARCH.md +@.planning/phases/01-project-infrastructure-module-wiring/01-PATTERNS.md +@.planning/phases/01-project-infrastructure-module-wiring/01-VALIDATION.md +@composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/App.kt +@composeApp/src/androidMain/kotlin/dev/ulfrx/recipe/MainActivity.kt +@composeApp/src/androidMain/AndroidManifest.xml +@composeApp/src/iosMain/kotlin/dev/ulfrx/recipe/MainViewController.kt +@composeApp/src/jvmMain/kotlin/dev/ulfrx/recipe/main.kt +@composeApp/src/webMain/kotlin/dev/ulfrx/recipe/main.kt +@iosApp/iosApp/iOSApp.swift +@CLAUDE.md + + + + +From io.insert-koin:koin-core: +```kotlin +fun startKoin(appDeclaration: KoinAppDeclaration): KoinApplication // top-level +interface KoinApplication +typealias KoinAppDeclaration = KoinApplication.() -> Unit +``` + +From io.insert-koin:koin-dsl: +```kotlin +fun module(createdAtStart: Boolean = false, moduleDeclaration: ModuleDeclaration): Module +``` + +From io.insert-koin:koin-android (androidMain only): +```kotlin +// package org.koin.android.ext.koin +fun KoinApplication.androidContext(context: Context): KoinApplication +``` + +From co.touchlab:kermit: +```kotlin +object Logger { + fun setTag(tag: String) + // plus .i { }, .d { }, .e { }, .w { } methods on Logger companion +} +``` + +From existing composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/App.kt (do NOT modify): +```kotlin +@Composable +@Preview +fun App() { /* template body — stays as-is */ } +``` + +From existing composeApp/src/androidMain/kotlin/dev/ulfrx/recipe/MainActivity.kt (do NOT modify — sibling reference): +```kotlin +package dev.ulfrx.recipe +import android.os.Bundle +import androidx.activity.ComponentActivity +import androidx.activity.compose.setContent +// class MainActivity : ComponentActivity() { ... setContent { App() } } +``` + +From existing composeApp/src/iosMain/kotlin/dev/ulfrx/recipe/MainViewController.kt (do NOT modify): +```kotlin +package dev.ulfrx.recipe +import androidx.compose.ui.window.ComposeUIViewController +fun MainViewController() = ComposeUIViewController { App() } +``` + +Current Android manifest shape (attributes to preserve when adding android:name): +```xml + +``` + +Current iOS Swift entry (to replace): +```swift +import SwiftUI +@main +struct iOSApp: App { + var body: some Scene { WindowGroup { ContentView() } } +} +``` + + + + + + + Task 1: Create commonMain DI + logging files and iOS Kotlin bridge + composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/di/Koin.kt, composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/di/AppModule.kt, composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/logging/Logging.kt, composeApp/src/iosMain/kotlin/dev/ulfrx/recipe/di/KoinIos.kt + + - .planning/phases/01-project-infrastructure-module-wiring/01-RESEARCH.md lines 840-870 (Koin bootstrap canonical excerpts: initKoin + appModule + doInitKoin) + - .planning/phases/01-project-infrastructure-module-wiring/01-RESEARCH.md lines 933-948 (Kermit bootstrap: Logger.setTag + init order) + - .planning/phases/01-project-infrastructure-module-wiring/01-RESEARCH.md lines 675-690 (PITFALL #4 — single call site per platform, never from inside @Composable) + - .planning/phases/01-project-infrastructure-module-wiring/01-PATTERNS.md lines 710-796 (pattern assignments for all 4 files) + - .planning/phases/01-project-infrastructure-module-wiring/01-CONTEXT.md D-14 (Koin empty appModule), D-15 (Kermit tag "recipe") + + + Create 4 new files. + + **File 1: `composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/di/Koin.kt`**: + + ```kotlin + package dev.ulfrx.recipe.di + + import org.koin.core.KoinApplication + import org.koin.core.context.startKoin + import org.koin.dsl.KoinAppDeclaration + + fun initKoin(config: KoinAppDeclaration? = null): KoinApplication = startKoin { + config?.invoke(this) + modules(appModule) + } + ``` + + **File 2: `composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/di/AppModule.kt`**: + + ```kotlin + package dev.ulfrx.recipe.di + + import org.koin.dsl.module + + // Phase 2 adds authModule; Phase 4 adds syncModule; Phase 5 adds catalogModule; etc. + val appModule = module { + // intentionally empty in Phase 1 + } + ``` + + **File 3: `composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/logging/Logging.kt`**: + + ```kotlin + package dev.ulfrx.recipe.logging + + import co.touchlab.kermit.Logger + + fun configureLogging() { + Logger.setTag("recipe") + // Platform log writers (OSLog iOS, LogCat Android, System.out JVM/Wasm) install by default. + } + ``` + + **File 4: `composeApp/src/iosMain/kotlin/dev/ulfrx/recipe/di/KoinIos.kt`**: + + ```kotlin + package dev.ulfrx.recipe.di + + import dev.ulfrx.recipe.logging.configureLogging + + fun doInitKoin() { + configureLogging() + initKoin() + } + ``` + + CRITICAL notes (PITFALL #4 / #10): + - The top-level `fun doInitKoin()` in file `KoinIos.kt` becomes the Swift-accessible symbol `KoinIosKt.doInitKoin()` (Kotlin generates `Kt` for top-level declarations). + - `doInitKoin()` is the SINGLE iOS entry point. `MainViewController()` (the `ComposeUIViewController` factory) must NOT call `startKoin` or `initKoin` — it assumes Koin is already started. + - `configureLogging()` runs BEFORE `initKoin()` so Koin module loading can use Kermit. + + Do NOT add any expect/actual declarations — the iOS bridge is a plain top-level function, and Kermit's multiplatform Logger handles the platform-specific writer selection internally. + + + test -f composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/di/Koin.kt && test -f composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/di/AppModule.kt && test -f composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/logging/Logging.kt && test -f composeApp/src/iosMain/kotlin/dev/ulfrx/recipe/di/KoinIos.kt && grep -q 'package dev.ulfrx.recipe.di' composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/di/Koin.kt && grep -q 'fun initKoin(config: KoinAppDeclaration? = null)' composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/di/Koin.kt && grep -q 'startKoin' composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/di/Koin.kt && grep -q 'modules(appModule)' composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/di/Koin.kt && grep -q 'val appModule = module' composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/di/AppModule.kt && grep -q 'Logger.setTag("recipe")' composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/logging/Logging.kt && grep -q 'fun doInitKoin' composeApp/src/iosMain/kotlin/dev/ulfrx/recipe/di/KoinIos.kt && grep -q 'configureLogging()' composeApp/src/iosMain/kotlin/dev/ulfrx/recipe/di/KoinIos.kt && grep -q 'initKoin()' composeApp/src/iosMain/kotlin/dev/ulfrx/recipe/di/KoinIos.kt + + + - `composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/di/Koin.kt` exists and contains package declaration `package dev.ulfrx.recipe.di` + - `Koin.kt` imports `org.koin.core.KoinApplication`, `org.koin.core.context.startKoin`, `org.koin.dsl.KoinAppDeclaration` + - `Koin.kt` defines exactly one top-level function `fun initKoin(config: KoinAppDeclaration? = null): KoinApplication` whose body is `startKoin { config?.invoke(this); modules(appModule) }` + - `AppModule.kt` exists and contains package declaration `package dev.ulfrx.recipe.di` + - `AppModule.kt` imports `org.koin.dsl.module` + - `AppModule.kt` declares `val appModule = module { }` (empty — D-14) + - `Logging.kt` exists and contains package declaration `package dev.ulfrx.recipe.logging` + - `Logging.kt` imports `co.touchlab.kermit.Logger` + - `Logging.kt` defines `fun configureLogging()` whose body calls `Logger.setTag("recipe")` (D-15 — exact string) + - `KoinIos.kt` exists and contains package declaration `package dev.ulfrx.recipe.di` + - `KoinIos.kt` imports `dev.ulfrx.recipe.logging.configureLogging` + - `KoinIos.kt` defines `fun doInitKoin()` whose body is `configureLogging(); initKoin()` in that exact order + - No file references `startKoin` directly outside `Koin.kt` (grep `startKoin` across composeApp/src returns only Koin.kt) + - `App.kt` is NOT modified (anti-pattern guard — startKoin never called from inside @Composable) + + Koin + Kermit commonMain wiring is in place; iOS bridge exposes a Swift-callable `KoinIosKt.doInitKoin()`. + + + + Task 2: Create MainApplication.kt + register in AndroidManifest.xml + composeApp/src/androidMain/kotlin/dev/ulfrx/recipe/MainApplication.kt, composeApp/src/androidMain/AndroidManifest.xml + + - composeApp/src/androidMain/AndroidManifest.xml (current 22-line content — target of edit) + - composeApp/src/androidMain/kotlin/dev/ulfrx/recipe/MainActivity.kt (sibling reference for androidMain package + imports) + - .planning/phases/01-project-infrastructure-module-wiring/01-RESEARCH.md lines 895-911 (canonical MainApplication.kt) + - .planning/phases/01-project-infrastructure-module-wiring/01-PATTERNS.md lines 800-849 (MainApplication + manifest deltas) + - composeApp/build.gradle.kts (verify `libs.koin.android` was added to androidMain.dependencies in Plan 03) + + + Create one new file and edit one existing file. + + **Create: `composeApp/src/androidMain/kotlin/dev/ulfrx/recipe/MainApplication.kt`**: + + ```kotlin + package dev.ulfrx.recipe + + import android.app.Application + import dev.ulfrx.recipe.di.initKoin + import dev.ulfrx.recipe.logging.configureLogging + import org.koin.android.ext.koin.androidContext + + class MainApplication : Application() { + override fun onCreate() { + super.onCreate() + configureLogging() + initKoin { + androidContext(this@MainApplication) + } + } + } + ``` + + CRITICAL: + - `package dev.ulfrx.recipe` (not `dev.ulfrx.recipe.android` — matches the existing `MainActivity.kt` sibling). + - `androidContext(this@MainApplication)` — the qualified `this` is required because the `initKoin { ... }` lambda's `this` is a `KoinApplication`, not the Application. + - `configureLogging()` runs FIRST, then `initKoin { ... }` — establishes the required order (PATTERNS.md "Init order on every platform entry"). + - `org.koin.android.ext.koin.androidContext` comes from `io.insert-koin:koin-android` (catalog alias `libs.koin.android`, added to `composeApp/build.gradle.kts` androidMain deps in Plan 03). + + **Edit: `composeApp/src/androidMain/AndroidManifest.xml`** — add `android:name=".MainApplication"` as the first attribute on the `` element. Do NOT modify any other attribute or element. + + Resulting `` tag: + + ```xml + + ``` + + The `` child element (with `android:name=".MainActivity"`) stays unchanged. The full XML structure (declarations, ``, ``) is preserved — only the single `android:name=".MainApplication"` attribute is added. + + + test -f composeApp/src/androidMain/kotlin/dev/ulfrx/recipe/MainApplication.kt && grep -q '^package dev.ulfrx.recipe$' composeApp/src/androidMain/kotlin/dev/ulfrx/recipe/MainApplication.kt && grep -q 'class MainApplication : Application()' composeApp/src/androidMain/kotlin/dev/ulfrx/recipe/MainApplication.kt && grep -q 'override fun onCreate()' composeApp/src/androidMain/kotlin/dev/ulfrx/recipe/MainApplication.kt && grep -q 'configureLogging()' composeApp/src/androidMain/kotlin/dev/ulfrx/recipe/MainApplication.kt && grep -q 'androidContext(this@MainApplication)' composeApp/src/androidMain/kotlin/dev/ulfrx/recipe/MainApplication.kt && grep -q 'android:name="\.MainApplication"' composeApp/src/androidMain/AndroidManifest.xml && grep -q 'android:name="\.MainActivity"' composeApp/src/androidMain/AndroidManifest.xml + + + - `composeApp/src/androidMain/kotlin/dev/ulfrx/recipe/MainApplication.kt` exists + - Package declaration is exactly `package dev.ulfrx.recipe` (matches sibling `MainActivity.kt`) + - Imports include `android.app.Application`, `dev.ulfrx.recipe.di.initKoin`, `dev.ulfrx.recipe.logging.configureLogging`, `org.koin.android.ext.koin.androidContext` + - Class declaration is `class MainApplication : Application()` + - `onCreate()` body calls `super.onCreate()` first, then `configureLogging()`, then `initKoin { androidContext(this@MainApplication) }` — in exactly that order + - `composeApp/src/androidMain/AndroidManifest.xml` contains literal `android:name=".MainApplication"` attribute on the `` element + - `composeApp/src/androidMain/AndroidManifest.xml` still contains `android:name=".MainActivity"` on the `` element (unchanged) + - `composeApp/src/androidMain/AndroidManifest.xml` still contains `` with MAIN action + LAUNCHER category (unchanged) + - `composeApp/src/androidMain/AndroidManifest.xml` top-level `` declaration unchanged + + Android Application subclass starts Koin + Kermit on process boot; manifest registers the subclass. + + + + Task 3: Wire JVM + Wasm main() entries and Swift iOSApp.swift + composeApp/src/jvmMain/kotlin/dev/ulfrx/recipe/main.kt, composeApp/src/webMain/kotlin/dev/ulfrx/recipe/main.kt, iosApp/iosApp/iOSApp.swift + + - composeApp/src/jvmMain/kotlin/dev/ulfrx/recipe/main.kt (current content — target of rewrite) + - composeApp/src/webMain/kotlin/dev/ulfrx/recipe/main.kt (current content — target of rewrite) + - iosApp/iosApp/iOSApp.swift (current 11-line content — target of rewrite) + - .planning/phases/01-project-infrastructure-module-wiring/01-RESEARCH.md lines 874-931 (Swift + Desktop + Wasm bootstrap) + - .planning/phases/01-project-infrastructure-module-wiring/01-RESEARCH.md lines 733-747 (PITFALL #8 — Wasm init order) + - .planning/phases/01-project-infrastructure-module-wiring/01-PATTERNS.md lines 852-937 (per-file deltas for these three files) + + + Replace three file contents. + + **Replace: `composeApp/src/jvmMain/kotlin/dev/ulfrx/recipe/main.kt`**: + + ```kotlin + package dev.ulfrx.recipe + + import androidx.compose.ui.window.Window + import androidx.compose.ui.window.application + import dev.ulfrx.recipe.di.initKoin + import dev.ulfrx.recipe.logging.configureLogging + + fun main() { + configureLogging() + initKoin() + application { + Window( + onCloseRequest = ::exitApplication, + title = "recipe", + ) { + App() + } + } + } + ``` + + **Replace: `composeApp/src/webMain/kotlin/dev/ulfrx/recipe/main.kt`**: + + ```kotlin + package dev.ulfrx.recipe + + import androidx.compose.ui.ExperimentalComposeUiApi + import androidx.compose.ui.window.ComposeViewport + import dev.ulfrx.recipe.di.initKoin + import dev.ulfrx.recipe.logging.configureLogging + + @OptIn(ExperimentalComposeUiApi::class) + fun main() { + configureLogging() + initKoin() + ComposeViewport { + App() + } + } + ``` + + CRITICAL (PITFALL #8): `configureLogging()` and `initKoin()` MUST run BEFORE `ComposeViewport { }` — otherwise the first `koinViewModel()` inside composition throws. Phase 1 has no ViewModels, so this is defensive — but the shape must be correct from day 1. + + **Replace: `iosApp/iosApp/iOSApp.swift`** (Swift file, not Kotlin): + + ```swift + import SwiftUI + import ComposeApp + + @main + struct iOSApp: App { + init() { + KoinIosKt.doInitKoin() + } + + var body: some Scene { + WindowGroup { + ContentView() + } + } + } + ``` + + CRITICAL: + - `import ComposeApp` — matches the framework basename set in `recipe.kotlin.multiplatform` (D-20 / PITFALL #10). The existing file does NOT import ComposeApp; add it. + - `init() { KoinIosKt.doInitKoin() }` — the Swift symbol `KoinIosKt` is auto-generated from Kotlin file `KoinIos.kt` in package `dev.ulfrx.recipe.di` (created in Task 1). + - `ContentView()` invocation stays unchanged; `ContentView.swift` already calls `MainViewControllerKt.MainViewController()` which returns a `ComposeUIViewController` — do NOT modify `ContentView.swift`. + - Do NOT call `startKoin` from `MainViewController()` — iOS init is centralized in `iOSApp.init()` to avoid PITFALL #4. + + + grep -q '^package dev.ulfrx.recipe$' composeApp/src/jvmMain/kotlin/dev/ulfrx/recipe/main.kt && grep -q 'configureLogging()' composeApp/src/jvmMain/kotlin/dev/ulfrx/recipe/main.kt && grep -q 'initKoin()' composeApp/src/jvmMain/kotlin/dev/ulfrx/recipe/main.kt && grep -q 'Window(' composeApp/src/jvmMain/kotlin/dev/ulfrx/recipe/main.kt && grep -q '^package dev.ulfrx.recipe$' composeApp/src/webMain/kotlin/dev/ulfrx/recipe/main.kt && grep -q 'configureLogging()' composeApp/src/webMain/kotlin/dev/ulfrx/recipe/main.kt && grep -q 'initKoin()' composeApp/src/webMain/kotlin/dev/ulfrx/recipe/main.kt && grep -q 'ComposeViewport' composeApp/src/webMain/kotlin/dev/ulfrx/recipe/main.kt && grep -q 'import ComposeApp' iosApp/iosApp/iOSApp.swift && grep -q 'KoinIosKt.doInitKoin()' iosApp/iosApp/iOSApp.swift && grep -q 'init() {' iosApp/iosApp/iOSApp.swift + + + - `composeApp/src/jvmMain/kotlin/dev/ulfrx/recipe/main.kt` has `configureLogging()` on a line preceding `initKoin()`, and both precede `application {` (init order invariant) + - JVM main imports include `dev.ulfrx.recipe.di.initKoin` AND `dev.ulfrx.recipe.logging.configureLogging` + - JVM main preserves `Window(onCloseRequest = ::exitApplication, title = "recipe") { App() }` + - `composeApp/src/webMain/kotlin/dev/ulfrx/recipe/main.kt` has `configureLogging()` on a line preceding `initKoin()`, and both precede `ComposeViewport {` (PITFALL #8) + - Web main imports include `dev.ulfrx.recipe.di.initKoin` AND `dev.ulfrx.recipe.logging.configureLogging` + - Web main still has `@OptIn(ExperimentalComposeUiApi::class)` on `fun main()` + - `iosApp/iosApp/iOSApp.swift` contains exactly `import SwiftUI` AND `import ComposeApp` (both imports required) + - `iosApp/iosApp/iOSApp.swift` contains `init() {` followed by `KoinIosKt.doInitKoin()` — exactly one call + - `iosApp/iosApp/iOSApp.swift` preserves `@main struct iOSApp: App { ... body: some Scene { WindowGroup { ContentView() } } }` + - `MainViewController.kt` is NOT modified (the existing file returns `ComposeUIViewController { App() }` — Koin bootstrapped outside, PITFALL #4) + - `App.kt` is NOT modified (anti-pattern guard) + + All four platform entry points call `configureLogging()` then `initKoin()` before composition; iOS Swift wires `KoinIosKt.doInitKoin()` exactly once in `init()`. + + + + + +## Trust Boundaries + +| Boundary | Description | +|----------|-------------| +| Platform process start → DI container initialization | Each platform (Android onCreate, iOS App.init, JVM main, Wasm main) is a trusted bootstrap context; `initKoin()` is called once, from code we control. | +| Kotlin top-level fun → Swift generated symbol | `KoinIos.kt` in package `dev.ulfrx.recipe.di` is compiled into the `ComposeApp.framework` Swift binary as `KoinIosKt.doInitKoin()`. No runtime risk — compile-time symbol mapping. | + +## STRIDE Threat Register + +| Threat ID | Category | Component | Disposition | Mitigation Plan | +|-----------|----------|-----------|-------------|-----------------| +| T-01-04-01 | Denial of Service | Koin double-init on iOS second cold launch (PITFALL #4) | mitigate | Only `iOSApp.init()` calls `KoinIosKt.doInitKoin()`. `MainViewController.kt` does NOT call `startKoin`. Task 3 acceptance criteria explicitly prohibits `startKoin` in `MainViewController.kt`. If Koin is accidentally started twice, `KoinApplicationAlreadyStartedException` fires on launch — visible and easy to diagnose. | +| T-01-04-02 | Denial of Service | Wasm composition runs before Koin init (PITFALL #8) | mitigate | Task 3 explicitly orders `configureLogging() → initKoin() → ComposeViewport { }`. Phase 1 has no ViewModels so the symptom would not surface until Phase 5+, but the order is correct from day 1. | +| T-01-04-03 | Tampering | `App.kt` calling `startKoin` from inside @Composable | mitigate | Task 1 + Task 3 acceptance criteria prohibit modification of `App.kt`. `App.kt` template preserves the anti-pattern-free shape. | +| T-01-04-04 | Information Disclosure | Kermit logs leaking sensitive data | accept | Phase 1 has no sensitive data in the codebase (no auth, no user records, no PII). Kermit tag `"recipe"` is a build identifier, not a secret. Revisit when Phase 2 (Auth) introduces tokens — at that point, Kermit's `.i { }` lambda evaluation prevents accidental string concat of secrets if authors follow the lambda idiom. | +| T-01-04-05 | Elevation of Privilege | Android manifest `android:name=".MainApplication"` registers custom Application subclass | accept | This is the standard Android lifecycle — `MainApplication.onCreate()` runs in the app's own process, same privilege as `MainActivity`. No escalation. | + + + +Phase-level verification for this plan: + +- Task 1, 2, 3 `` blocks pass (grep-based). +- `tools/verify-no-version-literals.sh` continues to exit 0 (no build files modified). +- `tools/verify-shared-pure.sh` continues to exit 0 (shared/ not touched). +- Plan 07 runs `./gradlew build` and `./gradlew :composeApp:jvmTest` — those will exercise `initKoin()` via composition and catch any Koin config error. + +No `./gradlew` invocation is in this plan's `` blocks — Plan 05 + Plan 07 run the compile gates. Keep this plan's verification grep-fast (<5s total). + + + +- 6 new commonMain/iosMain/androidMain Kotlin files created (Koin.kt, AppModule.kt, Logging.kt, KoinIos.kt, MainApplication.kt — and the init order is correct in each) +- AndroidManifest.xml has `android:name=".MainApplication"` attribute added +- JVM + Wasm main() entries call `configureLogging()` THEN `initKoin()` BEFORE composition +- `iOSApp.swift` imports `ComposeApp` and calls `KoinIosKt.doInitKoin()` in `init()` +- `App.kt` unmodified (anti-pattern guard) +- `MainViewController.kt` unmodified (PITFALL #4 guard) + + + +After completion, create `.planning/phases/01-project-infrastructure-module-wiring/01-04-SUMMARY.md` recording: 6 files created + 3 files modified paths, Kermit tag set to `"recipe"`, Koin appModule content (empty), and confirmation that `App.kt` / `MainViewController.kt` / `ContentView.swift` were NOT modified. + diff --git a/.planning/phases/01-project-infrastructure-module-wiring/01-05-PLAN.md b/.planning/phases/01-project-infrastructure-module-wiring/01-05-PLAN.md new file mode 100644 index 0000000..8a1960d --- /dev/null +++ b/.planning/phases/01-project-infrastructure-module-wiring/01-05-PLAN.md @@ -0,0 +1,498 @@ +--- +phase: 01-project-infrastructure-module-wiring +plan: 05 +type: execute +wave: 2 +depends_on: [01, 02] +files_modified: + - server/src/main/kotlin/dev/ulfrx/recipe/Application.kt + - server/src/main/kotlin/dev/ulfrx/recipe/Database.kt + - server/src/main/resources/application.conf + - server/src/main/resources/db/migration/.gitkeep + - server/src/test/kotlin/dev/ulfrx/recipe/ApplicationTest.kt +autonomous: true +requirements: [INFRA-02] +requirements_addressed: [INFRA-02] + +must_haves: + truths: + - "GET /health returns 200 with Content-Type: application/json and body {\"status\":\"ok\"} (D-16)" + - "Server reads database.url / database.user / database.password from application.conf, with localhost defaults and env overrides via HOCON ${?X} syntax (PITFALL #5)" + - "Flyway runs Flyway.configure().dataSource(url, user, password).locations(\"classpath:db/migration\").load().migrate() during Application.module() startup" + - "Server fails loudly with IllegalStateException if Postgres is unreachable — the exception is thrown from Database.migrate() and NOT swallowed" + - "server/src/main/resources/db/migration/ directory exists (with .gitkeep) so Flyway.locations classpath resolution finds it even when empty" + - "ApplicationTest.kt has a test named 'health endpoint returns 200 with status ok' (or similar) that does NOT require a running Postgres — it composes routing in isolation" + - "Application.kt uses explicit Ktor imports (no wildcard imports) so D-11 allWarningsAsErrors is satisfied" + artifacts: + - path: "server/src/main/kotlin/dev/ulfrx/recipe/Application.kt" + provides: "main() → embeddedServer(Netty, SERVER_PORT, ::module).start(); Application.module() installs ContentNegotiation(json), invokes Database.migrate(this), and registers GET /health" + exports: ["main", "Application.module"] + - path: "server/src/main/kotlin/dev/ulfrx/recipe/Database.kt" + provides: "object Database { fun migrate(app: Application) } — reads HOCON config, runs Flyway, throws IllegalStateException on failure" + exports: ["Database"] + - path: "server/src/main/resources/application.conf" + provides: "HOCON config with ktor.deployment.port (8080 + ${?PORT}) and database.url/user/password (localhost defaults + ${?DATABASE_URL/USER/PASSWORD})" + - path: "server/src/main/resources/db/migration/.gitkeep" + provides: "Empty directory placeholder ensuring classpath:db/migration resolves for Flyway even when no SQL files exist yet" + - path: "server/src/test/kotlin/dev/ulfrx/recipe/ApplicationTest.kt" + provides: "ApplicationTest with /health route assertion — composes routing without calling Database.migrate (no Postgres required)" + key_links: + - from: "server/src/main/kotlin/dev/ulfrx/recipe/Application.kt" + to: "server/src/main/kotlin/dev/ulfrx/recipe/Database.kt" + via: "Database.migrate(this) inside Application.module()" + pattern: "Database\\.migrate\\(this\\)" + - from: "server/src/main/kotlin/dev/ulfrx/recipe/Database.kt" + to: "server/src/main/resources/application.conf" + via: "app.environment.config.property(\"database.url\").getString() etc." + pattern: "config\\.property\\(\"database\\." + - from: "Flyway.configure().locations(...)" + to: "server/src/main/resources/db/migration/" + via: "classpath:db/migration" + pattern: "classpath:db/migration" +--- + + +Deliver the server's running-but-empty state: a `GET /health` route returning `{"status":"ok"}`, HOCON-based config (`application.conf`) with env-var overrides, a `Database` object that runs Flyway against Postgres at boot time (failing loudly if Postgres is unreachable), and an updated `ApplicationTest.kt` that asserts the route in isolation without requiring a running database. Also scaffold `server/src/main/resources/db/migration/` as an empty directory so Flyway's classpath resolution succeeds before Phase 3 adds `V1__init.sql`. + +Purpose: This plan closes D-16 — Phase 3 drops its first migration into an already-working migrator; Phase 11 deploys to the homelab with the same Ktor HOCON config reading real env vars. The fail-loud contract for unreachable Postgres is load-bearing: it surfaces config errors at boot, not at first 5xx. + +Output: 2 Kotlin source files (Application.kt rewrite + Database.kt new), 1 HOCON config, 1 directory placeholder, 1 test rewrite. + + + +@$HOME/.claude/get-shit-done/workflows/execute-plan.md +@$HOME/.claude/get-shit-done/templates/summary.md + + + +@.planning/PROJECT.md +@.planning/ROADMAP.md +@.planning/STATE.md +@.planning/phases/01-project-infrastructure-module-wiring/01-CONTEXT.md +@.planning/phases/01-project-infrastructure-module-wiring/01-RESEARCH.md +@.planning/phases/01-project-infrastructure-module-wiring/01-PATTERNS.md +@.planning/phases/01-project-infrastructure-module-wiring/01-VALIDATION.md +@server/src/main/kotlin/dev/ulfrx/recipe/Application.kt +@server/src/test/kotlin/dev/ulfrx/recipe/ApplicationTest.kt +@shared/src/commonMain/kotlin/dev/ulfrx/recipe/Constants.kt +@CLAUDE.md + + + + +From io.ktor.server.application: +```kotlin +interface Application +interface ApplicationEnvironment { + val config: ApplicationConfig +} +interface ApplicationConfig { + fun property(path: String): ApplicationConfigValue + fun propertyOrNull(path: String): ApplicationConfigValue? +} +interface ApplicationConfigValue { + fun getString(): String +} +``` + +From io.ktor.server.engine + io.ktor.server.netty: +```kotlin +fun embeddedServer(factory: ApplicationEngineFactory<...>, port: Int, host: String, module: Application.() -> Unit): EmbeddedServer +object Netty : ApplicationEngineFactory<...> +``` + +From io.ktor.server.plugins.contentnegotiation + io.ktor.serialization.kotlinx.json: +```kotlin +object ContentNegotiation : BaseApplicationPlugin<...> +fun ContentNegotiationConfig.json() // installs kotlinx.serialization JSON converter +``` + +From io.ktor.server.routing + io.ktor.server.response: +```kotlin +fun Application.routing(block: Route.() -> Unit) +fun Route.get(path: String, handler: suspend RoutingContext.() -> Unit) +suspend fun ApplicationCall.respond(message: Any) +``` + +From io.ktor.server.testing (in testImplementation via recipe.jvm.server): +```kotlin +fun testApplication(block: suspend ApplicationTestBuilder.() -> Unit) +// ApplicationTestBuilder provides: +fun application(block: Application.() -> Unit) +val client: HttpClient +``` + +From org.flywaydb.core: +```kotlin +object Flyway { + fun configure(): FluentConfiguration +} +// FluentConfiguration: +fun dataSource(url: String, user: String, password: String): FluentConfiguration +fun locations(vararg locations: String): FluentConfiguration +fun baselineOnMigrate(b: Boolean): FluentConfiguration +fun validateOnMigrate(b: Boolean): FluentConfiguration +fun cleanDisabled(b: Boolean): FluentConfiguration +fun load(): Flyway +// Flyway instance: +fun migrate(): MigrateResult +``` + +From kotlinx.serialization: +```kotlin +@Serializable +``` + +From org.slf4j: +```kotlin +object LoggerFactory { + fun getLogger(clazz: Class<*>): Logger +} +// org.slf4j.Logger: .info(msg: String, vararg args: Any), .error(msg: String, t: Throwable) +``` + +From shared/src/commonMain/kotlin/dev/ulfrx/recipe/Constants.kt (DO NOT modify): +```kotlin +package dev.ulfrx.recipe +const val SERVER_PORT: Int = 8080 // or whatever current value is +``` + +Current server/src/main/kotlin/dev/ulfrx/recipe/Application.kt (to replace): +```kotlin +package dev.ulfrx.recipe +import io.ktor.server.application.* +import io.ktor.server.engine.* +import io.ktor.server.netty.* +import io.ktor.server.response.* +import io.ktor.server.routing.* + +fun main() { embeddedServer(Netty, port = SERVER_PORT, host = "0.0.0.0", module = Application::module).start(wait = true) } +fun Application.module() { routing { get("/") { call.respondText("Ktor: ${Greeting().greet()}") } } } +``` + +Current server/src/test/kotlin/dev/ulfrx/recipe/ApplicationTest.kt (to replace): +```kotlin +// testRoot() asserts GET / returns "Ktor: ${Greeting().greet()}" — to be replaced with /health assertion +``` + + + + + + + Task 1: Create application.conf + db/migration/.gitkeep + Database.kt + server/src/main/resources/application.conf, server/src/main/resources/db/migration/.gitkeep, server/src/main/kotlin/dev/ulfrx/recipe/Database.kt + + - .planning/phases/01-project-infrastructure-module-wiring/01-RESEARCH.md lines 988-1023 (canonical Database.kt — SLF4J variant since server uses Logback not Kermit) + - .planning/phases/01-project-infrastructure-module-wiring/01-RESEARCH.md lines 1029-1051 (canonical application.conf HOCON) + - .planning/phases/01-project-infrastructure-module-wiring/01-RESEARCH.md lines 692-717 (PITFALL #5 — `${?X}` env-var HOCON syntax) + - .planning/phases/01-project-infrastructure-module-wiring/01-RESEARCH.md lines 719-724 (PITFALL #6 — Flyway runtime API, not plugin at build time) + - .planning/phases/01-project-infrastructure-module-wiring/01-PATTERNS.md lines 990-1076 (Database.kt + application.conf + .gitkeep deltas) + - .planning/phases/01-project-infrastructure-module-wiring/01-CONTEXT.md D-16 (server /health + Flyway + Postgres env overrides) + - server/build.gradle.kts (verify Plan 03 made `implementation(projects.shared)` present so `SERVER_PORT` is still reachable) + + + Create three files. + + **File 1: `server/src/main/resources/application.conf`** (HOCON, 01-RESEARCH.md lines 1031-1051): + + ```hocon + ktor { + deployment { + port = 8080 + port = ${?PORT} + } + application { + modules = [ dev.ulfrx.recipe.ApplicationKt.module ] + } + } + + database { + url = "jdbc:postgresql://localhost:5432/recipe" + url = ${?DATABASE_URL} + user = "recipe" + user = ${?DATABASE_USER} + password = "recipe" + password = ${?DATABASE_PASSWORD} + } + ``` + + CRITICAL (PITFALL #5): + - The two-line `url = "default"; url = ${?DATABASE_URL}` pattern is MANDATORY. `${?X}` is optional substitution — the second line is a no-op when `DATABASE_URL` is unset, and an override when it is set. Do NOT use `${X}` (required — crashes if unset) or `${X:default}` (wrong HOCON syntax). + - `"jdbc:postgresql://localhost:5432/recipe"`, `"recipe"`, `"recipe"` MATCH the docker-compose defaults in Plan 06 exactly — allows `docker compose up -d postgres && ./gradlew :server:run` with zero extra env config. + - `modules = [ dev.ulfrx.recipe.ApplicationKt.module ]` — even though `main()` uses programmatic `embeddedServer(...)` in Application.kt, this key is informational for Ktor's HOCON config loader and future EngineMain switching. + + **File 2: `server/src/main/resources/db/migration/.gitkeep`** — empty zero-byte file. Git does not track empty directories; this marker ensures `server/src/main/resources/db/migration/` ships in the repo so `classpath:db/migration` resolves for Flyway. Phase 3 drops `V1__init.sql` here. + + **File 3: `server/src/main/kotlin/dev/ulfrx/recipe/Database.kt`** (SLF4J variant — the server uses Logback already, NOT Kermit; RESEARCH.md lines 996-1023 + lines 1025-1027 explain the logger choice): + + ```kotlin + package dev.ulfrx.recipe + + import io.ktor.server.application.Application + import org.flywaydb.core.Flyway + import org.slf4j.LoggerFactory + + object Database { + private val log = LoggerFactory.getLogger(Database::class.java) + + fun migrate(app: Application) { + val url = app.environment.config.property("database.url").getString() + val user = app.environment.config.property("database.user").getString() + val password = app.environment.config.property("database.password").getString() + + log.info("Connecting to {} as {} and running Flyway migrations", url, user) + + runCatching { + Flyway.configure() + .dataSource(url, user, password) + .locations("classpath:db/migration") + .baselineOnMigrate(true) + .validateOnMigrate(true) + .cleanDisabled(true) + .load() + .migrate() + }.onFailure { ex -> + log.error("Flyway migration failed — cannot start server", ex) + throw IllegalStateException("Database unreachable or migration failed", ex) + } + } + } + ``` + + CRITICAL: + - `throw IllegalStateException(...)` is the fail-loud contract (D-16). Do NOT wrap it in a generic `try { } catch { return false }` — the server MUST refuse to start if the DB is unreachable. + - Use SLF4J (`LoggerFactory.getLogger(...)`), NOT Kermit. The server has Logback wired via `logback.xml`; Kermit is the CLIENT logger (composeApp only). + - Log credentials are NOT logged — only `url` and `user` appear in the info line. `password` is used for `dataSource(...)` only. + - `cleanDisabled = true` prevents accidental `flywayClean` wiping tables in dev/prod (matches `recipe.jvm.server.gradle.kts` plugin config — double-enforcement). + - `baselineOnMigrate = true` tolerates an existing DB with no Flyway history (defensive — Phase 1's DB is empty, Phase 11's homelab DB may pre-exist). + - `locations("classpath:db/migration")` points to the resource directory the `.gitkeep` keeps alive. + + + test -f server/src/main/resources/application.conf && test -f server/src/main/resources/db/migration/.gitkeep && test -f server/src/main/kotlin/dev/ulfrx/recipe/Database.kt && grep -q 'port = 8080' server/src/main/resources/application.conf && grep -q 'port = \${?PORT}' server/src/main/resources/application.conf && grep -q 'url = "jdbc:postgresql://localhost:5432/recipe"' server/src/main/resources/application.conf && grep -q 'url = \${?DATABASE_URL}' server/src/main/resources/application.conf && grep -q 'user = "recipe"' server/src/main/resources/application.conf && grep -q 'user = \${?DATABASE_USER}' server/src/main/resources/application.conf && grep -q 'password = "recipe"' server/src/main/resources/application.conf && grep -q 'password = \${?DATABASE_PASSWORD}' server/src/main/resources/application.conf && grep -q 'object Database' server/src/main/kotlin/dev/ulfrx/recipe/Database.kt && grep -q 'org.flywaydb.core.Flyway' server/src/main/kotlin/dev/ulfrx/recipe/Database.kt && grep -q 'org.slf4j.LoggerFactory' server/src/main/kotlin/dev/ulfrx/recipe/Database.kt && grep -q 'cleanDisabled(true)' server/src/main/kotlin/dev/ulfrx/recipe/Database.kt && grep -q 'baselineOnMigrate(true)' server/src/main/kotlin/dev/ulfrx/recipe/Database.kt && grep -q 'throw IllegalStateException' server/src/main/kotlin/dev/ulfrx/recipe/Database.kt && grep -q 'classpath:db/migration' server/src/main/kotlin/dev/ulfrx/recipe/Database.kt + + + - `server/src/main/resources/application.conf` exists and contains exactly 6 env-var override lines (`port = ${?PORT}`, `url = ${?DATABASE_URL}`, `user = ${?DATABASE_USER}`, `password = ${?DATABASE_PASSWORD}` plus the two defaults for `port = 8080` and the DB trio) + - `application.conf` default values match docker-compose defaults: URL `jdbc:postgresql://localhost:5432/recipe`, user `recipe`, password `recipe` + - `application.conf` contains `modules = [ dev.ulfrx.recipe.ApplicationKt.module ]` + - `server/src/main/resources/db/migration/.gitkeep` exists (zero-byte file acceptable) + - `server/src/main/kotlin/dev/ulfrx/recipe/Database.kt` exists and declares `object Database` + - `Database.kt` imports `io.ktor.server.application.Application`, `org.flywaydb.core.Flyway`, `org.slf4j.LoggerFactory` + - `Database.kt` defines `fun migrate(app: Application)` that reads `app.environment.config.property("database.url|user|password").getString()` + - `Database.kt` body contains `Flyway.configure().dataSource(url, user, password).locations("classpath:db/migration").baselineOnMigrate(true).validateOnMigrate(true).cleanDisabled(true).load().migrate()` (all chained) + - `Database.kt` wraps the migration in `runCatching { ... }.onFailure { ... throw IllegalStateException(...) }` (fail-loud contract) + - `Database.kt` does NOT import `co.touchlab.kermit.Logger` (server uses SLF4J) + - `Database.kt` log.info line does NOT format the password value (only url + user in the format string) + + HOCON config, Flyway migration resource dir, and fail-loud Database.migrate exist. + + + + Task 2: Rewrite Application.kt to install ContentNegotiation, call Database.migrate, expose /health + server/src/main/kotlin/dev/ulfrx/recipe/Application.kt + + - server/src/main/kotlin/dev/ulfrx/recipe/Application.kt (current 20 lines — target of rewrite) + - .planning/phases/01-project-infrastructure-module-wiring/01-RESEARCH.md lines 952-985 (canonical Application.kt with ContentNegotiation + /health) + - .planning/phases/01-project-infrastructure-module-wiring/01-PATTERNS.md lines 940-986 (Application.kt deltas) + - .planning/phases/01-project-infrastructure-module-wiring/01-CONTEXT.md D-16 (sentinel JSON body for /health — Claude's discretion; use trivial `{"status":"ok"}`) + - shared/src/commonMain/kotlin/dev/ulfrx/recipe/Constants.kt (verify `SERVER_PORT` constant is defined) + + + Replace the entire content of `server/src/main/kotlin/dev/ulfrx/recipe/Application.kt` with: + + ```kotlin + package dev.ulfrx.recipe + + import io.ktor.serialization.kotlinx.json.json + import io.ktor.server.application.Application + import io.ktor.server.application.install + import io.ktor.server.engine.embeddedServer + import io.ktor.server.netty.Netty + import io.ktor.server.plugins.contentnegotiation.ContentNegotiation + import io.ktor.server.response.respond + import io.ktor.server.routing.get + import io.ktor.server.routing.routing + import kotlinx.serialization.Serializable + + fun main() { + embeddedServer(Netty, port = SERVER_PORT, host = "0.0.0.0", module = Application::module) + .start(wait = true) + } + + @Serializable + private data class Health(val status: String) + + fun Application.module() { + install(ContentNegotiation) { + json() + } + Database.migrate(this) + configureRouting() + } + + fun Application.configureRouting() { + routing { + get("/health") { + call.respond(Health(status = "ok")) + } + } + } + ``` + + DELETIONS: + - DROP the wildcard imports (`io.ktor.server.application.*`, `io.ktor.server.engine.*`, `io.ktor.server.netty.*`, `io.ktor.server.response.*`, `io.ktor.server.routing.*`) — replaced with explicit imports to satisfy D-11 allWarningsAsErrors (wildcard-unused warnings would fail the build) + - DROP `get("/") { call.respondText("Ktor: ${Greeting().greet()}") }` — replaced by `/health` + + ADDITIONS: + - ADD `install(ContentNegotiation) { json() }` — required for `@Serializable` response serialization + - ADD `Database.migrate(this)` call inside `Application.module()` — fails loudly if Postgres unreachable + - ADD `@Serializable private data class Health(val status: String)` — the /health response shape + - ADD `Application.configureRouting()` extension function — extracted from `module()` so the test (Task 3) can compose routing WITHOUT invoking `Database.migrate()` + + KEEP: + - `package dev.ulfrx.recipe` (unchanged) + - `fun main() { embeddedServer(Netty, port = SERVER_PORT, host = "0.0.0.0", module = Application::module).start(wait = true) }` — programmatic boot, unchanged shape + - `SERVER_PORT` constant is referenced from `shared/` (unchanged) + + CRITICAL: + - The extraction of `configureRouting()` from `module()` is load-bearing for the test. Task 3 needs to test routing without calling `Database.migrate(this)` (which requires a real Postgres). + - `install(ContentNegotiation) { json() }` — MUST be installed before any route returns a `@Serializable` type. Both `module()` (for production) and the test (Task 3) must install it. + + + grep -q '^package dev.ulfrx.recipe$' server/src/main/kotlin/dev/ulfrx/recipe/Application.kt && grep -q 'import io.ktor.server.plugins.contentnegotiation.ContentNegotiation' server/src/main/kotlin/dev/ulfrx/recipe/Application.kt && grep -q 'import io.ktor.serialization.kotlinx.json.json' server/src/main/kotlin/dev/ulfrx/recipe/Application.kt && grep -q 'import kotlinx.serialization.Serializable' server/src/main/kotlin/dev/ulfrx/recipe/Application.kt && ! grep -qE 'import io\.ktor\.server\.application\.\*' server/src/main/kotlin/dev/ulfrx/recipe/Application.kt && grep -q 'install(ContentNegotiation)' server/src/main/kotlin/dev/ulfrx/recipe/Application.kt && grep -q 'Database.migrate(this)' server/src/main/kotlin/dev/ulfrx/recipe/Application.kt && grep -q 'get("/health")' server/src/main/kotlin/dev/ulfrx/recipe/Application.kt && grep -q 'data class Health(val status: String)' server/src/main/kotlin/dev/ulfrx/recipe/Application.kt && grep -q 'fun Application.configureRouting()' server/src/main/kotlin/dev/ulfrx/recipe/Application.kt && ! grep -q 'call.respondText' server/src/main/kotlin/dev/ulfrx/recipe/Application.kt && grep -q 'embeddedServer(Netty, port = SERVER_PORT' server/src/main/kotlin/dev/ulfrx/recipe/Application.kt + + + - `Application.kt` has no wildcard imports (`import X.*`) — every `io.ktor.*` import is explicit + - `Application.kt` imports `io.ktor.server.plugins.contentnegotiation.ContentNegotiation`, `io.ktor.serialization.kotlinx.json.json`, `kotlinx.serialization.Serializable` + - `Application.kt` defines `@Serializable private data class Health(val status: String)` + - `Application.module()` body calls, in order: `install(ContentNegotiation) { json() }`, then `Database.migrate(this)`, then `configureRouting()` + - `Application.configureRouting()` is a top-level extension function containing the `routing { get("/health") { call.respond(Health(status = "ok")) } }` block + - `main()` is unchanged from its current shape: `embeddedServer(Netty, port = SERVER_PORT, host = "0.0.0.0", module = Application::module).start(wait = true)` + - No `get("/")` route remains (template root greeting is removed) + - No `call.respondText(...)` in Application.kt (Health returned via `call.respond(Health(...))` → kotlinx-json serializer) + + Application.kt installs ContentNegotiation, runs Flyway at boot, exposes /health JSON, splits routing for testability. + + + + Task 3: Rewrite ApplicationTest.kt to assert GET /health returns 200 with JSON body + server/src/test/kotlin/dev/ulfrx/recipe/ApplicationTest.kt + + - server/src/test/kotlin/dev/ulfrx/recipe/ApplicationTest.kt (current 20-line content — target of rewrite) + - .planning/phases/01-project-infrastructure-module-wiring/01-RESEARCH.md lines 1084-1125 (canonical ApplicationTest.kt variant) + - .planning/phases/01-project-infrastructure-module-wiring/01-PATTERNS.md lines 1079-1125 (test delta explaining the no-Postgres-required refactor) + - server/src/main/kotlin/dev/ulfrx/recipe/Application.kt (the freshly rewritten file — the test references `configureRouting()` from this file) + - .planning/phases/01-project-infrastructure-module-wiring/01-VALIDATION.md lines 52-53 (automated command this test must satisfy: `./gradlew :server:test --tests "*Health*"`) + + + Replace the entire content of `server/src/test/kotlin/dev/ulfrx/recipe/ApplicationTest.kt` with: + + ```kotlin + package dev.ulfrx.recipe + + import io.ktor.client.request.get + import io.ktor.client.statement.bodyAsText + import io.ktor.http.HttpStatusCode + import io.ktor.serialization.kotlinx.json.json + import io.ktor.server.application.install + import io.ktor.server.plugins.contentnegotiation.ContentNegotiation + import io.ktor.server.testing.testApplication + import kotlin.test.Test + import kotlin.test.assertEquals + import kotlin.test.assertTrue + + class ApplicationTest { + + @Test + fun `health endpoint returns 200 with status ok`() = testApplication { + application { + install(ContentNegotiation) { + json() + } + configureRouting() + } + val response = client.get("/health") + assertEquals(HttpStatusCode.OK, response.status) + val body = response.bodyAsText() + assertTrue(body.contains("\"status\""), "expected body to contain status field, was: $body") + assertTrue(body.contains("\"ok\""), "expected body to contain ok value, was: $body") + } + } + ``` + + CRITICAL: + - The test invokes `configureRouting()` directly (extracted in Task 2) and does NOT call `Database.migrate(...)`. This is the KEY refactor: the test runs without a running Postgres, so `./gradlew :server:test` can succeed in CI / fresh clones. + - `install(ContentNegotiation) { json() }` is explicitly installed inside `application { }` — because the production `Application.module()` installs it, but the test composes only `configureRouting()` and must install the plugin itself. + - Imports are explicit (no wildcards) to satisfy D-11 allWarningsAsErrors. + - Assertions check for `"status"` and `"ok"` substrings in the JSON body — this is a structural check that works regardless of JSON field ordering. + - The test function name uses backtick-quoted natural-language identifier (`` `health endpoint returns 200 with status ok` ``) — standard Kotlin test-naming convention; the test will run via `./gradlew :server:test --tests "*health*"` or similar wildcards. + + DELETIONS: + - DROP the existing `testRoot()` test — it asserted the template's `/` route response with `"Ktor: ${Greeting().greet()}"`, which no longer exists. + - DROP wildcard imports `io.ktor.client.request.*`, `io.ktor.client.statement.*`, `io.ktor.http.*`, `io.ktor.server.testing.*`, `kotlin.test.*`. + + + test -f server/src/test/kotlin/dev/ulfrx/recipe/ApplicationTest.kt && grep -q 'health endpoint returns 200' server/src/test/kotlin/dev/ulfrx/recipe/ApplicationTest.kt && grep -q 'configureRouting()' server/src/test/kotlin/dev/ulfrx/recipe/ApplicationTest.kt && ! grep -q 'Database.migrate' server/src/test/kotlin/dev/ulfrx/recipe/ApplicationTest.kt && grep -q 'install(ContentNegotiation)' server/src/test/kotlin/dev/ulfrx/recipe/ApplicationTest.kt && grep -q 'client.get("/health")' server/src/test/kotlin/dev/ulfrx/recipe/ApplicationTest.kt && grep -q 'HttpStatusCode.OK' server/src/test/kotlin/dev/ulfrx/recipe/ApplicationTest.kt && ! grep -q 'testRoot' server/src/test/kotlin/dev/ulfrx/recipe/ApplicationTest.kt && ! grep -q 'Greeting().greet()' server/src/test/kotlin/dev/ulfrx/recipe/ApplicationTest.kt && ! grep -qE 'import kotlin\.test\.\*' server/src/test/kotlin/dev/ulfrx/recipe/ApplicationTest.kt && cd /Users/rwilk/dev/repo/recipe && ./gradlew :server:test --tests "*health*" -q 2>&1 | tail -5 && echo "gradle exit: $?" + + + - `ApplicationTest.kt` defines exactly one `@Test` method whose name contains `health` (case-insensitive) + - Test body invokes `configureRouting()` and does NOT invoke `Database.migrate(...)` (no-Postgres invariant) + - Test installs `ContentNegotiation { json() }` inside `application { ... }` + - Test asserts `response.status == HttpStatusCode.OK` + - Test asserts response body contains substring `"status"` AND `"ok"` + - No wildcard imports + - No reference to the removed `testRoot`, `Greeting`, or `respondText` — the old template test is fully replaced + - `./gradlew :server:test --tests "*health*"` runs and exits 0 (proves the test compiles AND passes; no Postgres needed because `configureRouting()` is composed directly) + + /health test passes without requiring Postgres; old template test removed. + + + + + +## Trust Boundaries + +| Boundary | Description | +|----------|-------------| +| HTTP client (unauthenticated) → GET /health | `/health` is intentionally unauthenticated (observability); reveals only `{"status":"ok"}` — no implementation detail, no version, no uptime. | +| Ktor process → Postgres (JDBC) | HOCON defaults connect to `localhost:5432` with dev credentials. Real credentials arrive via `DATABASE_URL`/`DATABASE_USER`/`DATABASE_PASSWORD` env vars in Phase 11 homelab deploy. | +| Developer → server/src/main/resources/application.conf | Committed to git; MUST contain only non-secret dev defaults. Real secrets never land in `application.conf`. | + +## STRIDE Threat Register + +| Threat ID | Category | Component | Disposition | Mitigation Plan | +|-----------|----------|-----------|-------------|-----------------| +| T-01-05-01 | Information Disclosure | `/health` endpoint leaking implementation details | mitigate | Body is `{"status":"ok"}` only — no version, no commit hash, no uptime, no DB state. Per ASVS V14 guidance + 01-RESEARCH.md § Security Domain. | +| T-01-05-02 | Information Disclosure | `application.conf` committed with real secrets | mitigate | Defaults are non-secret localhost creds (`recipe/recipe/recipe`). Real secrets MUST arrive via `${?DATABASE_URL}` env override — never committed. Task 1 acceptance criteria enforces the six `${?X}` lines. | +| T-01-05-03 | Tampering / Destruction | `flywayClean` wiping DB | mitigate | `cleanDisabled(true)` is set in BOTH `recipe.jvm.server.gradle.kts` (plugin CLI guard) AND in `Database.kt` runtime call (programmatic guard). Double-enforced per RESEARCH.md § Security Domain. | +| T-01-05-04 | Denial of Service | Server silently runs with broken DB | mitigate | `Database.migrate()` throws `IllegalStateException` on any Flyway/JDBC error — server cannot start. Fail-loud contract (D-16) prevents stealth failure modes. | +| T-01-05-05 | Error Handling (leaky stack traces) | 500 responses exposing internal stack | accept | No 500 handler registered yet; Ktor's default returns generic 500. Phase 2+ may introduce a StatusPages handler; Phase 1 scope is /health only which cannot 500 under normal operation. | +| T-01-05-06 | Supply Chain | Flyway 12.4.0 + flyway-database-postgresql transitive deps | mitigate | Pinned versions via catalog (Plan 01). Postgres JDBC 42.7.10 pinned. No `latest.release` ranges. | + + + +Phase-level verification for this plan: + +- Task 3 `` runs `./gradlew :server:test --tests "*health*"` which proves: + - Application.kt compiles (confirms Task 2's explicit imports are correct) + - ApplicationTest.kt compiles (confirms Task 3's imports are correct) + - The /health route returns 200 with JSON containing `"status"` and `"ok"` + - Database.migrate is NOT required for the test (no Postgres needed in CI — D-11 test-runtime invariant) + +- `tools/verify-no-version-literals.sh` continues to exit 0 (no build files modified; server/build.gradle.kts was rewritten in Plan 03, untouched here). + +- Manual verification (deferred to Plan 07 or manual step): + - `docker compose up -d postgres && ./gradlew :server:run & sleep 5 && curl -sf http://localhost:8080/health | grep '"ok"'` — proves end-to-end boot + route + DB migration path. + + + +- `server/src/main/resources/application.conf` exists with HOCON + 6 env overrides +- `server/src/main/resources/db/migration/.gitkeep` exists +- `server/src/main/kotlin/dev/ulfrx/recipe/Database.kt` runs Flyway with fail-loud contract +- `server/src/main/kotlin/dev/ulfrx/recipe/Application.kt` installs ContentNegotiation, calls Database.migrate, exposes GET /health returning `{"status":"ok"}` +- `server/src/test/kotlin/dev/ulfrx/recipe/ApplicationTest.kt` passes via `./gradlew :server:test --tests "*health*"` WITHOUT a running Postgres + + + +After completion, create `.planning/phases/01-project-infrastructure-module-wiring/01-05-SUMMARY.md` recording: files created/modified, HOCON env-var pattern used (the `${?X}` two-line form), the fail-loud Database.migrate contract, and the `./gradlew :server:test` result. + diff --git a/.planning/phases/01-project-infrastructure-module-wiring/01-06-PLAN.md b/.planning/phases/01-project-infrastructure-module-wiring/01-06-PLAN.md new file mode 100644 index 0000000..4bd2ccd --- /dev/null +++ b/.planning/phases/01-project-infrastructure-module-wiring/01-06-PLAN.md @@ -0,0 +1,308 @@ +--- +phase: 01-project-infrastructure-module-wiring +plan: 06 +type: execute +wave: 2 +depends_on: [] +files_modified: + - docker-compose.yml + - README.md +autonomous: true +requirements: [INFRA-02] +requirements_addressed: [INFRA-02] + +must_haves: + truths: + - "docker-compose.yml at repo root launches postgres:16 with POSTGRES_DB=recipe / POSTGRES_USER=recipe / POSTGRES_PASSWORD=recipe — matching application.conf defaults exactly (D-17)" + - "The postgres service has a named volume (recipe-pgdata) so data survives container restarts" + - "The postgres service has a healthcheck using pg_isready that lets `docker compose up --wait` block until ready" + - "README.md has a 'Local development' section documenting the full dev loop (docker compose up, gradlew server:run, curl /health, gradlew spotlessApply)" + - "README.md no longer documents the dropped js target (D-01); wasmJs section is preserved" + artifacts: + - path: "docker-compose.yml" + provides: "postgres:16 service on port 5432 with named volume and healthcheck" + contains: "image: postgres:16", "POSTGRES_DB: recipe", "recipe-pgdata" + - path: "README.md" + provides: "Updated dev docs with Local development section, no js target docs" + contains: "Local development", "docker compose up -d postgres" + key_links: + - from: "docker-compose.yml" + to: "server/src/main/resources/application.conf" + via: "POSTGRES_DB=recipe / POSTGRES_USER=recipe / POSTGRES_PASSWORD=recipe defaults match HOCON localhost URL" + pattern: "POSTGRES_(DB|USER|PASSWORD):\\s*recipe" + - from: "README.md Local development section" + to: "server/src/main/kotlin/dev/ulfrx/recipe/Application.kt" + via: "curl http://localhost:8080/health" + pattern: "curl .+ /health" +--- + + +Deliver the local developer ergonomics promised by D-17: a `docker-compose.yml` at the repo root running `postgres:16` with credentials + volume + healthcheck that align exactly with Plan 05's `application.conf` HOCON defaults, plus a "Local development" section in `README.md` documenting the dev loop. Drop the legacy `js` target documentation from `README.md` (D-01). + +Purpose: Phase 3 (Households / DB migrations) and Phase 11 (homelab deploy) both assume a working local Postgres is one command away. This plan closes that gap so `docker compose up -d postgres && ./gradlew :server:run` is a two-command dev loop. Authentik is NOT in this compose file — it lives on the user's homelab (CONTEXT.md D-17). + +Output: 1 new YAML file, 1 README edit. Entirely independent of Plans 01-05 in terms of files_modified — runs safely in parallel. + + + +@$HOME/.claude/get-shit-done/workflows/execute-plan.md +@$HOME/.claude/get-shit-done/templates/summary.md + + + +@.planning/PROJECT.md +@.planning/ROADMAP.md +@.planning/STATE.md +@.planning/phases/01-project-infrastructure-module-wiring/01-CONTEXT.md +@.planning/phases/01-project-infrastructure-module-wiring/01-RESEARCH.md +@.planning/phases/01-project-infrastructure-module-wiring/01-PATTERNS.md +@.planning/phases/01-project-infrastructure-module-wiring/01-VALIDATION.md +@README.md +@CLAUDE.md + + + + +From server/src/main/resources/application.conf (Plan 05 created — value match required): +```hocon +database { + url = "jdbc:postgresql://localhost:5432/recipe" + url = ${?DATABASE_URL} + user = "recipe" + user = ${?DATABASE_USER} + password = "recipe" + password = ${?DATABASE_PASSWORD} +} +``` + +So docker-compose.yml MUST use: +- `POSTGRES_DB: recipe` (matches `/recipe` in jdbc URL path) +- `POSTGRES_USER: recipe` +- `POSTGRES_PASSWORD: recipe` +- port `5432:5432` (matches URL port) + +From README.md current content: +- Section "Build and Run Web Application" (lines 63-85) documents BOTH `wasmJsBrowserDevelopmentRun` AND `jsBrowserDevelopmentRun` — the `js` part must go per D-01. +- "Build and Run Android/Desktop/Server/iOS" sections are fine and stay. + + + + + + + Task 1: Create docker-compose.yml at repo root + docker-compose.yml + + - .planning/phases/01-project-infrastructure-module-wiring/01-RESEARCH.md lines 1055-1077 (canonical docker-compose.yml) + - .planning/phases/01-project-infrastructure-module-wiring/01-PATTERNS.md lines 1128-1158 (docker-compose pattern — matched defaults) + - .planning/phases/01-project-infrastructure-module-wiring/01-CONTEXT.md D-17 (scope: postgres:16 + named volume; Authentik stays on homelab) + - (If Plan 05 is complete) server/src/main/resources/application.conf — verify credentials match + + + Create `docker-compose.yml` at the repo root with the following exact content: + + ```yaml + services: + postgres: + image: postgres:16 + container_name: recipe-postgres + environment: + POSTGRES_DB: recipe + POSTGRES_USER: recipe + POSTGRES_PASSWORD: recipe + ports: + - "5432:5432" + volumes: + - recipe-pgdata:/var/lib/postgresql/data + healthcheck: + test: ["CMD-SHELL", "pg_isready -U recipe -d recipe"] + interval: 5s + timeout: 5s + retries: 5 + + volumes: + recipe-pgdata: + ``` + + CRITICAL: + - `image: postgres:16` — pinned major version (D-17 specifies `postgres:16`). + - `POSTGRES_DB: recipe`, `POSTGRES_USER: recipe`, `POSTGRES_PASSWORD: recipe` — all MUST equal `"recipe"` (matches `application.conf` HOCON defaults from Plan 05 — `jdbc:postgresql://localhost:5432/recipe`, user `recipe`, password `recipe`). + - Named volume `recipe-pgdata` — survives container restart. Drop with `docker compose down -v` if you need a fresh DB. + - Healthcheck uses `pg_isready -U recipe -d recipe` so `docker compose up --wait postgres` or `depends_on: { postgres: { condition: service_healthy } }` works (Phase 3+ may add this). + - Port `5432:5432` — binds host port 5432 to container port 5432. Document in README that this is dev-local only. + - Do NOT add any other service (no Authentik — lives on user's homelab per D-17; no server — Ktor runs via Gradle on host for dev iteration). + - No `.env` file — D-17 / PATTERNS.md "Recommendation on `.env` vs inline": inline is fine for single-dev + matching application.conf defaults. + + The file has NO leading version key (`version: "3"` etc. is legacy Docker Compose syntax — unnecessary in modern `docker compose v2`, and omitting it avoids a warning). + + + test -f docker-compose.yml && grep -q 'image: postgres:16' docker-compose.yml && grep -q 'POSTGRES_DB: recipe' docker-compose.yml && grep -q 'POSTGRES_USER: recipe' docker-compose.yml && grep -q 'POSTGRES_PASSWORD: recipe' docker-compose.yml && grep -q 'recipe-pgdata:/var/lib/postgresql/data' docker-compose.yml && grep -q '"5432:5432"' docker-compose.yml && grep -q 'pg_isready -U recipe -d recipe' docker-compose.yml && grep -q '^volumes:$' docker-compose.yml && grep -q ' recipe-pgdata:' docker-compose.yml + + + - `docker-compose.yml` exists at repo root (`test -f docker-compose.yml`) + - `docker-compose.yml` contains `image: postgres:16` (not `postgres:latest`, not `postgres:15`, not `postgres`) + - `docker-compose.yml` contains `container_name: recipe-postgres` + - `docker-compose.yml` has `POSTGRES_DB: recipe`, `POSTGRES_USER: recipe`, `POSTGRES_PASSWORD: recipe` — all exactly `recipe` (lowercase, no variation) + - `docker-compose.yml` has port mapping `"5432:5432"` + - `docker-compose.yml` declares volume `recipe-pgdata` in both the service `volumes:` section AND the top-level `volumes:` section + - `docker-compose.yml` has a `healthcheck:` block using `pg_isready -U recipe -d recipe` + - `docker-compose.yml` does NOT contain a `version:` key (modern compose v2) + - `docker-compose.yml` does NOT define any service other than `postgres` (D-17: Authentik stays on homelab) + - Credentials match Plan 05's `application.conf` defaults (cross-check: `grep 'user = "recipe"' server/src/main/resources/application.conf` and `grep 'password = "recipe"' server/src/main/resources/application.conf` both return 1 line each — if Plan 05 is not complete, skip this sub-check) + + docker-compose.yml ships postgres:16 matching application.conf defaults; single-service compose file. + + + + Task 2: Add "Local development" section to README.md and drop js target docs + README.md + + - README.md (current 100-line content — target of edit) + - .planning/phases/01-project-infrastructure-module-wiring/01-PATTERNS.md lines 1161-1169 (README delta summary) + - .planning/phases/01-project-infrastructure-module-wiring/01-CONTEXT.md D-01 (drop js target), D-17 (docker-compose dev ergonomics) + + + Two edits to `README.md`: + + **Edit A: Drop the `js` target section** — delete lines 77-85 of the current README (the "- for the JS target (slower, supports older browsers): - on macOS/Linux ... `./gradlew :composeApp:jsBrowserDevelopmentRun` - on Windows ..." block). Keep lines 68-76 (the wasmJs block). The entire "Build and Run Web Application" subsection should retain ONLY the wasmJs paragraph. + + Resulting "Build and Run Web Application" subsection: + + ```markdown + ### Build and Run Web Application + + To build and run the development version of the web app, use the run configuration from the run widget + in your IDE's toolbar or run it directly from the terminal: + + - for the Wasm target (faster, modern browsers): + - on macOS/Linux + ```shell + ./gradlew :composeApp:wasmJsBrowserDevelopmentRun + ``` + - on Windows + ```shell + .\gradlew.bat :composeApp:wasmJsBrowserDevelopmentRun + ``` + ``` + + **Edit B: Insert a new "Local development" section** AFTER the "Build and Run iOS Application" subsection and BEFORE the trailing `---` horizontal rule (around line 92 in the current file). The new section: + + ```markdown + ### Local development + + The server requires Postgres. A `docker-compose.yml` at the repo root ships a local Postgres + instance whose credentials match `application.conf` defaults (`recipe`/`recipe`/`recipe`). + + Boot the database and server: + + ```shell + docker compose up -d postgres + ./gradlew :server:run + ``` + + Verify the server is up: + + ```shell + curl http://localhost:8080/health + # expected: {"status":"ok"} + ``` + + Environment overrides (optional — set any of these to override `application.conf` defaults): + + - `DATABASE_URL` — JDBC URL (default `jdbc:postgresql://localhost:5432/recipe`) + - `DATABASE_USER` — DB user (default `recipe`) + - `DATABASE_PASSWORD` — DB password (default `recipe`) + - `PORT` — Ktor port (default `8080`) + + Before committing, format all Kotlin + Gradle + Markdown files: + + ```shell + ./gradlew spotlessApply + ``` + + The full check (Spotless + all tests across all targets): + + ```shell + ./gradlew check + ``` + + Reset the local database (destroys the `recipe-pgdata` volume): + + ```shell + docker compose down -v + ``` + ``` + + Do NOT modify: + - The top-level introduction (lines 1-20) + - The "Build and Run Android Application" section + - The "Build and Run Desktop (JVM) Application" section + - The "Build and Run Server" section + - The "Build and Run iOS Application" section + - The trailing `---` + the learn-more links + the Compose/Wasm feedback paragraph + + Keep the existing markdown heading level (`###`) for the new "Local development" section — matches the surrounding siblings. + + + grep -q 'Local development' README.md && grep -q 'docker compose up -d postgres' README.md && grep -q 'curl http://localhost:8080/health' README.md && grep -q 'DATABASE_URL' README.md && grep -q 'gradlew spotlessApply' README.md && grep -q 'docker compose down -v' README.md && ! grep -q 'jsBrowserDevelopmentRun' README.md && grep -q 'wasmJsBrowserDevelopmentRun' README.md + + + - `README.md` contains the string `Local development` exactly once (new section heading) + - `README.md` contains `docker compose up -d postgres` as a documented command + - `README.md` contains `curl http://localhost:8080/health` as a documented command + - `README.md` lists all 4 env-var overrides: `DATABASE_URL`, `DATABASE_USER`, `DATABASE_PASSWORD`, `PORT` + - `README.md` contains `gradlew spotlessApply` (pre-commit formatter hint per D-10) + - `README.md` contains `gradlew check` (full-suite command) + - `README.md` contains `docker compose down -v` (volume reset hint) + - `README.md` does NOT contain `jsBrowserDevelopmentRun` (D-01 — js target dropped) + - `README.md` STILL contains `wasmJsBrowserDevelopmentRun` (wasmJs kept per D-01) + - All existing section headings ("Build and Run Android Application", "Build and Run Desktop (JVM) Application", "Build and Run Server", "Build and Run iOS Application") are preserved (unchanged) + - Top-of-file introduction (lines 1-20) is unchanged + + README.md documents the dev loop (docker + gradle + curl + spotless + reset); legacy js target docs removed. + + + + + +## Trust Boundaries + +| Boundary | Description | +|----------|-------------| +| Developer host → localhost:5432 Postgres | Dev-local; `docker-compose.yml` binds port on loopback via host mapping. Non-localhost access requires the developer's host to be reachable from outside the machine AND port 5432 firewall-open — normally not the case on a laptop. | +| `docker-compose.yml` (committed to git) → POSTGRES_PASSWORD=recipe | Password is literal `recipe` — non-secret by design. Real homelab creds never land in this file; homelab has its own compose file or `.env` per Phase 11. | + +## STRIDE Threat Register + +| Threat ID | Category | Component | Disposition | Mitigation Plan | +|-----------|----------|-----------|-------------|-----------------| +| T-01-06-01 | Information Disclosure | Postgres port 5432 exposed on `0.0.0.0` | mitigate | Host-firewall is the developer's responsibility; the literal `"5432:5432"` mapping is Docker-default (binds to all host interfaces unless the host Docker is configured otherwise). README Local development section mentions "dev-local" usage but does NOT open a CVE window — this is standard dev practice. Phase 11 (homelab) uses a different compose file that does NOT expose the port publicly. | +| T-01-06-02 | Information Disclosure | Committing real secrets to `docker-compose.yml` | mitigate | Only the literal `recipe/recipe/recipe` triple is in the file. Real homelab Postgres creds stay out of this compose file (Phase 11 will add a separate file or switch to env-var-driven compose). | +| T-01-06-03 | Tampering | `docker compose down -v` accidentally destroying valuable data | accept | Dev-only volume (`recipe-pgdata`). If Phase 3+ develops real seed data, a developer running `down -v` repopulates from migrations — zero-trust default. | +| T-01-06-04 | Denial of Service | `postgres:16` image unavailable from Docker Hub | accept | `docker pull postgres:16` is a standard image; outage would be transient and outside our control. Pinning to major version (not `:latest`) limits drift. | + + + +Phase-level verification for this plan: + +- Task 1 + Task 2 `` blocks pass. +- `tools/verify-no-version-literals.sh` continues to exit 0 (no `.gradle.kts` files modified in this plan). +- No `./gradlew` invocations — docker-compose + README are pure dev-ergonomics. + +Manual sanity check (optional, NOT blocking): +- `docker compose config` parses the YAML without warnings. +- `docker compose up -d postgres && sleep 3 && docker exec recipe-postgres pg_isready -U recipe -d recipe` returns "accepting connections". +- `docker compose down` — cleans up afterward. + + + +- `docker-compose.yml` exists at repo root with a single `postgres:16` service + named volume + healthcheck +- Credentials in `docker-compose.yml` match `application.conf` defaults exactly (`recipe/recipe/recipe`) +- `README.md` has a new "Local development" section +- `README.md` no longer documents the `js` target +- `README.md` still documents `wasmJs` target + + + +After completion, create `.planning/phases/01-project-infrastructure-module-wiring/01-06-SUMMARY.md` recording: docker-compose content summary (one service, one volume), credential match with Plan 05, README sections added/removed, and any deviation from D-17 (expected: none). + diff --git a/.planning/phases/01-project-infrastructure-module-wiring/01-07-PLAN.md b/.planning/phases/01-project-infrastructure-module-wiring/01-07-PLAN.md new file mode 100644 index 0000000..2ac39a3 --- /dev/null +++ b/.planning/phases/01-project-infrastructure-module-wiring/01-07-PLAN.md @@ -0,0 +1,297 @@ +--- +phase: 01-project-infrastructure-module-wiring +plan: 07 +type: execute +wave: 3 +depends_on: [01, 02, 03, 04, 05, 06] +files_modified: + - shared/src/commonMain/kotlin/dev/ulfrx/recipe/shared/.gitkeep +autonomous: true +requirements: [INFRA-01, INFRA-02, INFRA-03, INFRA-06] +requirements_addressed: [INFRA-01, INFRA-02, INFRA-03, INFRA-06] + +must_haves: + truths: + - "shared/src/commonMain/kotlin/dev/ulfrx/recipe/shared/ package scaffold exists (as .gitkeep marker) — INFRA-06 file-existence criterion" + - "./gradlew spotlessApply runs green (no files need formatting, OR all files are auto-formatted)" + - "./gradlew build succeeds across composeApp, server, shared — produces Android APK + iOS framework + server JAR (SC1)" + - "tools/verify-no-version-literals.sh exits 0 across the whole repo (SC2 / INFRA-01)" + - "tools/verify-ios-flags.sh exits 0 (SC3 / INFRA-03)" + - "tools/verify-shared-pure.sh exits 0 (SC5 / INFRA-06)" + - "./gradlew :composeApp:help emits 'recipe.kotlin.multiplatform' among applied plugins (SC4 / INFRA-02)" + - "./gradlew check runs spotlessCheck + all tests and exits 0" + artifacts: + - path: "shared/src/commonMain/kotlin/dev/ulfrx/recipe/shared/.gitkeep" + provides: "Empty package scaffold marker ensuring dev.ulfrx.recipe.shared package exists in git (Phase 2+ adds DTOs here)" + - path: "composeApp/build/outputs/apk/debug/composeApp-debug.apk" + provides: "Android debug APK artifact from ./gradlew build (SC1 proof)" + - path: "composeApp/build/bin/iosSimulatorArm64/debugFramework/ComposeApp.framework" + provides: "iOS framework artifact from ./gradlew build (SC1 proof)" + key_links: + - from: "./gradlew build" + to: "composeApp/build.gradle.kts + shared/build.gradle.kts + server/build.gradle.kts" + via: "recipe.* convention plugin application (Plan 03 refactor)" + pattern: "id\\(\"recipe\\." + - from: "./gradlew :composeApp:help" + to: "build-logic/src/main/kotlin/recipe.kotlin.multiplatform.gradle.kts" + via: "help task enumerates applied plugins" + pattern: "recipe\\.kotlin\\.multiplatform" +--- + + +Create the final piece of INFRA-06 (empty `dev.ulfrx.recipe.shared` package scaffold under `shared/src/commonMain`) and then run the full phase verification gate: `./gradlew spotlessApply`, `./gradlew build`, the 3 `tools/verify-*.sh` invariant scripts, and `./gradlew check`. This is the "green build" moment that every prior plan in Phase 1 has been building toward. + +Purpose: Phase 1 success is defined by 5 ROADMAP success criteria (SC1-SC5) and 4 phase requirements (INFRA-01/02/03/06). Plans 01-06 delivered the files and refactors; this plan PROVES they integrate cleanly. Any regression here is a phase-completion blocker. + +Output: 1 `.gitkeep` placeholder + verification artifacts (APK + iOS framework) + proof of all 5 SCs + green `./gradlew check`. + + + +@$HOME/.claude/get-shit-done/workflows/execute-plan.md +@$HOME/.claude/get-shit-done/templates/summary.md + + + +@.planning/PROJECT.md +@.planning/ROADMAP.md +@.planning/STATE.md +@.planning/phases/01-project-infrastructure-module-wiring/01-CONTEXT.md +@.planning/phases/01-project-infrastructure-module-wiring/01-RESEARCH.md +@.planning/phases/01-project-infrastructure-module-wiring/01-PATTERNS.md +@.planning/phases/01-project-infrastructure-module-wiring/01-VALIDATION.md +@tools/verify-no-version-literals.sh +@tools/verify-shared-pure.sh +@tools/verify-ios-flags.sh +@CLAUDE.md + + + + +From Plan 01: +- tools/verify-no-version-literals.sh — greps every *.gradle.kts for version literals (exits 0 if none except build-logic/build.gradle.kts) +- tools/verify-shared-pure.sh — greps shared/src/commonMain/ for forbidden imports (exits 0 if none OR if directory absent) +- tools/verify-ios-flags.sh — greps gradle.properties for the two iOS K/N flags (exits 0 if both present) + +From Plan 02: +- build-logic/ with 5 precompiled plugins applied via settings.gradle.kts pluginManagement.includeBuild + +From Plan 03: +- composeApp/, shared/, server/ build.gradle.kts applying recipe.* convention plugins + +From Plan 04: +- composeApp common/iOS/Android/Desktop/Wasm entry points calling initKoin() + configureLogging() +- iosApp/iosApp/iOSApp.swift calling KoinIosKt.doInitKoin() + +From Plan 05: +- server Application.kt with /health + Database.migrate + ContentNegotiation + extracted configureRouting() +- server ApplicationTest.kt passing without Postgres + +From Plan 06: +- docker-compose.yml with postgres:16 + matching credentials +- README.md with Local development section + +Phase gate commands (from 01-VALIDATION.md § Sampling Rate): +- Quick: `./gradlew spotlessCheck :server:test :shared:jvmTest` (<30s) +- Per-wave: `./gradlew build` (full — iOS framework link + Android APK + server JAR) +- Phase gate: `./gradlew check` + manual curl + iOS simulator boot (simulator boot is a manual-only verification, 01-VALIDATION.md § Manual-Only) + + + + + + + Task 1: Create shared/ package scaffold placeholder + shared/src/commonMain/kotlin/dev/ulfrx/recipe/shared/.gitkeep + + - shared/src/commonMain/kotlin/dev/ulfrx/recipe/ (current contents: Greeting.kt, Platform.kt, Constants.kt — these are the TEMPLATE classes; they stay in place for now. Phase 2+ reorganizes.) + - .planning/phases/01-project-infrastructure-module-wiring/01-CONTEXT.md D-19 (shared/commonMain stays pure; Phase 1 ships an empty package scaffold under dev.ulfrx.recipe.shared) + - .planning/phases/01-project-infrastructure-module-wiring/01-PATTERNS.md lines 73-77 (shared package scaffold as .gitkeep marker) + - .planning/phases/01-project-infrastructure-module-wiring/01-RESEARCH.md line 289 (shared/src/commonMain/kotlin/dev/ulfrx/recipe/shared/ NEW empty pkg) + + + Create an empty `.gitkeep` file at `shared/src/commonMain/kotlin/dev/ulfrx/recipe/shared/.gitkeep`. The parent directories do not exist yet — create them as part of the write. + + The file content is zero bytes (empty). Its purpose is purely to make `dev.ulfrx.recipe.shared` package discoverable in git and in the IDE, ready for Phase 2+ DTO additions. + + DO NOT: + - Touch or delete `shared/src/commonMain/kotlin/dev/ulfrx/recipe/Greeting.kt` — template class, stays + - Touch `Platform.kt` or `Constants.kt` — template classes, stay + - Add any other file under the new `shared/` package + - Add `expect`/`actual` declarations anywhere in shared/ (Phase 2+ scope) + + Note the namespace layering: `shared/src/commonMain/kotlin/dev/ulfrx/recipe/` is the ROOT package (`dev.ulfrx.recipe` — where Constants.kt lives), and `shared/src/commonMain/kotlin/dev/ulfrx/recipe/shared/` is a SUB-package (`dev.ulfrx.recipe.shared` — where Phase 2+ DTOs will live). Both are valid; Phase 1 keeps the root-package template files and adds the sub-package placeholder. + + + test -f shared/src/commonMain/kotlin/dev/ulfrx/recipe/shared/.gitkeep && test -d shared/src/commonMain/kotlin/dev/ulfrx/recipe/shared && test -f shared/src/commonMain/kotlin/dev/ulfrx/recipe/Greeting.kt && test -f shared/src/commonMain/kotlin/dev/ulfrx/recipe/Constants.kt && bash tools/verify-shared-pure.sh + + + - `shared/src/commonMain/kotlin/dev/ulfrx/recipe/shared/.gitkeep` exists (file test: `test -f`) + - Parent directory `shared/src/commonMain/kotlin/dev/ulfrx/recipe/shared` exists (directory test: `test -d`) + - Existing template files are preserved: `shared/src/commonMain/kotlin/dev/ulfrx/recipe/Greeting.kt`, `Platform.kt`, `Constants.kt` all still exist + - `tools/verify-shared-pure.sh` exits 0 — the `.gitkeep` file is not a `.kt` file so the grep skips it; the existing Greeting/Platform/Constants files still contain no forbidden imports + + Empty package scaffold created; shared/ is ready for Phase 2+ DTOs. + + + + Task 2: Run Spotless apply + full ./gradlew build + invariant scripts + + + - .planning/phases/01-project-infrastructure-module-wiring/01-VALIDATION.md lines 40-58 (Per-Task Verification Map — the exact commands this task runs) + - .planning/phases/01-project-infrastructure-module-wiring/01-VALIDATION.md lines 27-34 (Sampling Rate — per-wave and phase-gate commands) + - .planning/phases/01-project-infrastructure-module-wiring/01-RESEARCH.md lines 1216-1241 (Success Criteria → Test Map) + + + This task is purely verification — no file modifications. Run the full phase gate in sequence. If any step fails, STOP and report the failure (do NOT silently swallow errors — a failure here means a prior plan regressed and must be fixed before Phase 1 completes). + + Execute these commands IN ORDER. Each must exit 0 before proceeding to the next. + + 1. **Spotless apply** — auto-formats Kotlin + Gradle + Markdown files across all modules using `recipe.quality`'s ktlint rules: + + ```bash + ./gradlew spotlessApply + ``` + + Expected: exit 0. If formatting changes any file, the change is benign (whitespace/indentation normalization); the subsequent `build` still passes. + + 2. **Invariant script: no version literals** — enforces INFRA-01 SC#2: + + ```bash + bash tools/verify-no-version-literals.sh + ``` + + Expected: exit 0 + `OK: no version literals outside catalog.` + + 3. **Invariant script: shared/ is pure** — enforces INFRA-06 SC#5: + + ```bash + bash tools/verify-shared-pure.sh + ``` + + Expected: exit 0 + `OK: shared/commonMain is pure.` + + 4. **Invariant script: iOS K/N flags present** — enforces INFRA-03 SC#3: + + ```bash + bash tools/verify-ios-flags.sh + ``` + + Expected: exit 0 + `OK: iOS binary flags present.` + + 5. **Full Gradle build** — enforces SC1: produces Android APK + iOS framework + server JAR: + + ```bash + ./gradlew build + ``` + + Expected: exit 0. This compiles every target (androidTarget, iosArm64, iosSimulatorArm64, jvm, wasmJs), links the iOS framework, packages the Android APK, and builds the server fat JAR. + + After success, verify the two proof artifacts exist: + + ```bash + test -f composeApp/build/outputs/apk/debug/composeApp-debug.apk + test -d composeApp/build/bin/iosSimulatorArm64/debugFramework/ComposeApp.framework + ``` + + 6. **Convention plugin applied** — enforces SC4 / INFRA-02: + + ```bash + ./gradlew :composeApp:help -q 2>&1 | grep -q 'recipe.kotlin.multiplatform' || ./gradlew :composeApp:tasks --all -q 2>&1 | grep -q 'recipe' || true + ``` + + Ktlint/help output verification: the `help` task for a module does not always enumerate plugins in recent Gradle versions. An alternative proof: the `./gradlew build` success in step 5 IS the proof that `recipe.kotlin.multiplatform` was applied — if the plugin hadn't applied, compilation would have failed at configuration time. Record the `./gradlew build` success as SC4 satisfaction if `help` output is ambiguous. + + 7. **Full check** — enforces full-suite green (spotlessCheck + all tests): + + ```bash + ./gradlew check + ``` + + Expected: exit 0. This includes: + - `spotlessCheck` (Spotless verification) + - `:server:test` (runs the /health test from Plan 05 — no Postgres needed) + - `:composeApp:jvmTest` (template test, if present) + - `:shared:jvmTest` (template test, if present) + - Other platform tests as declared + + If any of steps 1-7 fails, report exactly which step failed, the full error output, and STOP. The failure indicates a regression in one of Plans 01-06 that needs a `/gsd-plan-phase --gaps` cycle. + + IMPORTANT: + - Do NOT add a `docker compose up postgres` step here. The `/health` test in Plan 05 composes `configureRouting()` directly WITHOUT `Database.migrate()` — no Postgres required. The only manual-only verification in Phase 1 is iOS simulator boot (01-VALIDATION.md § Manual-Only) which is deferred to a later human review. + - Do NOT run `./gradlew :server:run` here — it would call `Database.migrate()` which requires a running Postgres. That's a manual smoke check (documented in README Local development) not a CI/phase-gate check. + + + cd /Users/rwilk/dev/repo/recipe && ./gradlew spotlessApply -q && bash tools/verify-no-version-literals.sh && bash tools/verify-shared-pure.sh && bash tools/verify-ios-flags.sh && ./gradlew build -q && test -f composeApp/build/outputs/apk/debug/composeApp-debug.apk && test -d composeApp/build/bin/iosSimulatorArm64/debugFramework/ComposeApp.framework && ./gradlew check -q + + + - `./gradlew spotlessApply` exits 0 + - `tools/verify-no-version-literals.sh` exits 0 (SC2) + - `tools/verify-shared-pure.sh` exits 0 (SC5) + - `tools/verify-ios-flags.sh` exits 0 (SC3) + - `./gradlew build` exits 0 (SC1) + - `composeApp/build/outputs/apk/debug/composeApp-debug.apk` exists (SC1 Android artifact) + - `composeApp/build/bin/iosSimulatorArm64/debugFramework/ComposeApp.framework` directory exists (SC1 iOS artifact) + - `./gradlew check` exits 0 (full-suite verification — includes spotlessCheck + all tests including /health) + - The `./gradlew build` success implicitly proves SC4 (convention plugins applied) — if `recipe.kotlin.multiplatform` hadn't applied, the build would have failed during module configuration + - No `BUILD FAILED` string appears in the transcript + + Phase 1 green — all 5 SCs and all 4 phase requirements (INFRA-01/02/03/06) verified by automated commands. + + + + + +## Trust Boundaries + +| Boundary | Description | +|----------|-------------| +| Developer host → Gradle daemon | Same process; Gradle executes precompiled plugin code from `build-logic/` with full project access by design. | +| Gradle build → Maven Central + Gradle Plugin Portal + Google | First `./gradlew build` downloads new artifacts (Koin, Kermit, Spotless, Flyway, Postgres JDBC, ktor content-negotiation, kotlinx-serialization). All versions pinned via catalog (Plan 01). | +| iOS framework link → K/N compiler | Uses the two binary flags from gradle.properties (`gc=cms`, `objcDisposeOnMain=false`). Verified by `tools/verify-ios-flags.sh` (infrastructure check) + deferred iOS simulator boot check (manual). | + +## STRIDE Threat Register + +| Threat ID | Category | Component | Disposition | Mitigation Plan | +|-----------|----------|-----------|-------------|-----------------| +| T-01-07-01 | Denial of Service | `./gradlew build` downloading fresh deps, causing slow first-build | accept | First build may take 2-5 minutes as Koin/Kermit/Flyway/Postgres JDBC artifacts download (~80 MB per 01-RESEARCH.md § Runtime State Inventory). Subsequent builds use Gradle cache. Not a threat — just an expectation. | +| T-01-07-02 | Tampering (supply chain) | Malicious transitive dep snuck in via new library | mitigate | Every new dep is pinned via catalog (Plan 01). Gradle verification metadata (`gradle/verification-metadata.xml`) is NOT enabled in Phase 1 — it's a future enhancement (Phase 11 CI setup). Risk accepted for Phase 1 single-dev local-build scope. | +| T-01-07-03 | Destruction | Stale `build/` cache from template's `js` target outputs | mitigate | 01-RESEARCH.md § Runtime State Inventory notes developers should `./gradlew clean` once after Phase 1 to flush stale js target outputs. Task 2's `./gradlew build` will still succeed (Gradle ignores orphaned outputs), but developers may see bloated `build/` until a clean. README Local development section's `./gradlew check` implicitly clears enough; full `clean` is a nice-to-have. | +| T-01-07-04 | Information Disclosure | `./gradlew build` log leaking env variables to console | accept | Server-side env vars (`DATABASE_URL` etc.) are only read at server boot, not during `./gradlew build`. The `/health` test composes routing without the DB. No secrets logged during build. | + + + +Phase-level verification for this plan — this IS the phase gate. Success here equals Phase 1 completion. + +Hard gate commands (all must exit 0): +1. `./gradlew spotlessApply` — auto-format +2. `tools/verify-no-version-literals.sh` — SC2 / INFRA-01 +3. `tools/verify-shared-pure.sh` — SC5 / INFRA-06 +4. `tools/verify-ios-flags.sh` — SC3 / INFRA-03 +5. `./gradlew build` — SC1, implicitly SC4 / INFRA-02 +6. `./gradlew check` — full-suite (spotlessCheck + all tests) + +Manual-only verifications (deferred per 01-VALIDATION.md § Manual-Only — NOT in Task 2 ``): +- iOS simulator debug launch without legacy memory-manager warnings (requires Xcode + simulator) +- Hot-reload dev loop on Desktop (interactive) +- Server `/health` reachable via curl when Postgres is up (requires `docker compose up -d postgres` + `./gradlew :server:run`) + +These manual checks are recommended for the developer to run once; they are NOT gate-blocking for automated Phase 1 completion. + + + +- `shared/src/commonMain/kotlin/dev/ulfrx/recipe/shared/.gitkeep` created +- `./gradlew spotlessApply` green +- All 3 `tools/verify-*.sh` scripts green +- `./gradlew build` green + Android APK + iOS framework artifacts exist +- `./gradlew check` green +- No manual step required to pass this plan + + + +After completion, create `.planning/phases/01-project-infrastructure-module-wiring/01-07-SUMMARY.md` recording: the final 7 verification command outputs (exit codes), the size of the produced APK and iOS framework, the total `./gradlew build` time, and explicit confirmation that all 5 ROADMAP SCs (SC1-SC5) and 4 phase requirements (INFRA-01/02/03/06) are satisfied. + +Include in the summary a brief "Manual smoke checks to run later" list pointing at 01-VALIDATION.md § Manual-Only: +- iOS simulator boot without legacy-MM warnings +- Desktop hot-reload regression check +- docker compose up postgres + server /health curl smoke test + diff --git a/.planning/phases/01-project-infrastructure-module-wiring/01-PATTERNS.md b/.planning/phases/01-project-infrastructure-module-wiring/01-PATTERNS.md new file mode 100644 index 0000000..3d63850 --- /dev/null +++ b/.planning/phases/01-project-infrastructure-module-wiring/01-PATTERNS.md @@ -0,0 +1,1343 @@ +# Phase 1: Project Infrastructure & Module Wiring — Pattern Map + +**Mapped:** 2026-04-24 +**Files analyzed:** 35 (new + modified across build, client, server, iOS bootstrap, dev ergonomics) +**Analogs found:** 28 / 35 (in-repo template files or RESEARCH.md canonical excerpts) +**Analog provenance:** All analogs are either (a) an existing JetBrains KMP template file in this repo, or (b) a canonical excerpt in `01-RESEARCH.md § Code Examples / § Architecture Patterns`. No external code was consulted — the upstream-template idioms already shipped in-repo are the highest-fidelity reference. + +--- + +## Orientation for the executor + +Phase 1 is **greenfield-infrastructure with refactor of the JetBrains template**. Three shapes of file exist: + +| Shape | Count | Where the pattern lives | +|-------|-------|-------------------------| +| **MODIFIED-IN-PLACE** — existing template file, narrow edits (add flags, drop `js`, etc.) | 11 | The file itself is the analog; PATTERNS.md shows the exact delta | +| **NEW-FROM-TEMPLATE-ANALOG** — new file whose shape mirrors a sibling template file | 9 | The sibling template file is the analog (e.g. `MainActivity.kt` → `MainApplication.kt`) | +| **NEW-FROM-RESEARCH** — net-new files with no in-repo analog; canonical example in RESEARCH.md § Code Examples | 15 | Copy directly from `01-RESEARCH.md § Code Examples` excerpts (lines 777–1107) | + +**Executor rule of thumb:** if a file exists today, use it as the analog and apply minimal edits. If it does not, RESEARCH.md lines 777–1107 contain a near-final canonical excerpt — treat those excerpts as the implementation starting point, adjust only for the D-# decisions explicitly referenced here. + +--- + +## File Classification + +### A. Build infrastructure (new `build-logic/` + catalog + properties) + +| File | NEW/MODIFIED | Role | Data Flow | Closest Analog | Match Quality | +|------|--------------|------|-----------|----------------|---------------| +| `build-logic/settings.gradle.kts` | NEW | included-build settings | config | RESEARCH.md § Pattern 1 (lines 314–331) | canonical | +| `build-logic/build.gradle.kts` | NEW | plugin buildscript | config | RESEARCH.md § Pattern 1 (lines 333–358) | canonical | +| `build-logic/src/main/kotlin/recipe.kotlin.multiplatform.gradle.kts` | NEW | precompiled script plugin | config | RESEARCH.md § Code Examples (lines 777–835); current `composeApp/build.gradle.kts` `kotlin { }` block (lines 13–71) | role+flow match | +| `build-logic/src/main/kotlin/recipe.compose.multiplatform.gradle.kts` | NEW | precompiled script plugin | config | RESEARCH.md § Pattern 4 (lines 447–477) | canonical | +| `build-logic/src/main/kotlin/recipe.android.application.gradle.kts` | NEW | precompiled script plugin | config | RESEARCH.md § Pattern 6 (lines 516–552); current `composeApp/build.gradle.kts` `android { }` block (lines 73–98) | canonical + repo mirror | +| `build-logic/src/main/kotlin/recipe.jvm.server.gradle.kts` | NEW | precompiled script plugin | config | RESEARCH.md § Pattern 7 (lines 558–601); current `server/build.gradle.kts` | canonical + repo mirror | +| `build-logic/src/main/kotlin/recipe.quality.gradle.kts` | NEW | precompiled script plugin | config | RESEARCH.md § Pattern 5 (lines 483–512) | canonical | +| `gradle/libs.versions.toml` | MODIFIED | version catalog | config | itself (lines 1–53); add new `[versions]` + `[libraries]` + `[plugins]` entries for koin, kermit, spotless, flyway, postgres | self | +| `gradle.properties` | MODIFIED | gradle daemon + K/N flags | config | itself (lines 1–10) + RESEARCH.md § `gradle.properties` (lines 1083–1102) | self | +| `settings.gradle.kts` | MODIFIED | root settings | config | itself (lines 1–37); add `includeBuild("build-logic")` | self | +| `build.gradle.kts` | MODIFIED | root build | config | itself (lines 1–12); keep `apply false` list, extend with new plugins | self | + +### B. Module refactors (apply convention plugins, drop `js`) + +| File | NEW/MODIFIED | Role | Data Flow | Closest Analog | Match Quality | +|------|--------------|------|-----------|----------------|---------------| +| `composeApp/build.gradle.kts` | MODIFIED | module build | config | itself (lines 1–114); rewrite plugins block to convention-plugin IDs, drop `js { }` (lines 36–39), drop `compose.desktop { nativeDistributions { ... } }` packaging (lines 104–114, per D-03) | self | +| `shared/build.gradle.kts` | MODIFIED | module build | config | itself (lines 1–55); rewrite plugins block, drop `js { }` (lines 25–27), add `explicitApi()` (D-12), possibly drop `androidLibrary` plugin (see D-07 + Pattern 6 note) | self | +| `server/build.gradle.kts` | MODIFIED | module build | config | itself (lines 1–23); replace `alias(libs.plugins.*)` with convention-plugin IDs, add Flyway + Postgres deps via `recipe.jvm.server` | self | + +### C. Client DI + logging bootstrap (new files in composeApp) + +| File | NEW/MODIFIED | Role | Data Flow | Closest Analog | Match Quality | +|------|--------------|------|-----------|----------------|---------------| +| `composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/di/Koin.kt` | NEW | DI bootstrap | init-once | RESEARCH.md § Koin bootstrap (lines 840–861) | canonical | +| `composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/di/AppModule.kt` | NEW | DI module declaration | config | RESEARCH.md § Koin bootstrap (lines 852–861) | canonical | +| `composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/logging/Logging.kt` | NEW | logger bootstrap | init-once | RESEARCH.md § Kermit bootstrap (lines 933–946) | canonical | +| `composeApp/src/iosMain/kotlin/dev/ulfrx/recipe/di/KoinIos.kt` | NEW | platform bridge (Kotlin→Swift symbol) | init-once | RESEARCH.md § Koin bootstrap (lines 865–870); sibling: `MainViewController.kt` is the only existing iosMain file | canonical + structural | +| `composeApp/src/androidMain/kotlin/dev/ulfrx/recipe/MainApplication.kt` | NEW | Android app entry | init-once | RESEARCH.md § Koin bootstrap (lines 896–911); sibling: `MainActivity.kt` (lines 1–19) | canonical + sibling | +| `composeApp/src/androidMain/AndroidManifest.xml` | MODIFIED | Android manifest | config | itself (lines 1–22); add `android:name=".MainApplication"` to `` tag | self | +| `composeApp/src/jvmMain/kotlin/dev/ulfrx/recipe/main.kt` | MODIFIED | Desktop entry | init-once | itself (lines 1–13); add `initKoin()` + `configureLogging()` at top of `main()` | self | +| `composeApp/src/webMain/kotlin/dev/ulfrx/recipe/main.kt` | MODIFIED | Wasm entry | init-once | itself (lines 1–10); add `initKoin()` + `configureLogging()` before `ComposeViewport { }` (PITFALL #8) | self | +| `composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/App.kt` | UNMODIFIED in Phase 1 | Compose root | render | keep current template body (lines 1–49); do NOT call `startKoin` from inside `@Composable` (anti-pattern in Pattern 4 notes) | n/a | + +### D. iOS Swift bootstrap (wire `doInitKoin`) + +| File | NEW/MODIFIED | Role | Data Flow | Closest Analog | Match Quality | +|------|--------------|------|-----------|----------------|---------------| +| `iosApp/iosApp/iOSApp.swift` | MODIFIED | iOS app entry | init-once | itself (lines 1–11); add `init() { KoinIosKt.doInitKoin() }` (RESEARCH.md lines 874–891) | self | +| `iosApp/iosApp/ContentView.swift` | UNMODIFIED | SwiftUI shell | render | n/a | n/a | + +### E. Shared module scaffold + +| File | NEW/MODIFIED | Role | Data Flow | Closest Analog | Match Quality | +|------|--------------|------|-----------|----------------|---------------| +| `shared/src/commonMain/kotlin/dev/ulfrx/recipe/shared/.gitkeep` (or placeholder `package-info.kt`-style file) | NEW | empty scaffold | n/a | D-19: `shared/commonMain` is empty in Phase 1 beyond placeholder; existing `Greeting.kt` / `Platform.kt` / `Constants.kt` stay where they are (untouched this phase) | structural | +| `shared/src/jsMain/**` | DELETED | n/a | n/a | D-01 drops `js` target; remove the entire directory (`Platform.js.kt`) | delete | +| `composeApp/src/jsMain/**` (if any) | DELETED | n/a | n/a | D-01 drops `js` target; `composeApp` does not currently have a `jsMain/` dir but has `js { browser() }` in its build — the build edit alone suffices | delete | + +### F. Server infrastructure (new `/health` + Flyway + config) + +| File | NEW/MODIFIED | Role | Data Flow | Closest Analog | Match Quality | +|------|--------------|------|-----------|----------------|---------------| +| `server/src/main/kotlin/dev/ulfrx/recipe/Application.kt` | MODIFIED | Ktor module + entry | request-response | itself (lines 1–20); rewrite per RESEARCH.md § Ktor `/health` (lines 952–985) — swap `get("/")` + `respondText(...)` for `install(ContentNegotiation) { json() }` + `Database.migrate(this)` + `get("/health")` | self + canonical | +| `server/src/main/kotlin/dev/ulfrx/recipe/Database.kt` | NEW | Flyway + DataSource bootstrap | init-once | RESEARCH.md § `Database.kt` (lines 988–1023) | canonical | +| `server/src/main/resources/application.conf` | NEW | Ktor HOCON config | config | RESEARCH.md § `application.conf` (lines 1031–1051) | canonical | +| `server/src/main/resources/db/migration/.gitkeep` | NEW | empty Flyway dir | n/a | convention (Flyway convention path) | structural | +| `server/src/main/resources/logback.xml` | UNMODIFIED | log config | config | keep as-is (lines 1–12); D-16 decides server uses SLF4J/Logback, not Kermit | self | +| `server/src/test/kotlin/dev/ulfrx/recipe/ApplicationTest.kt` | MODIFIED | Ktor test | test | itself (lines 1–20); replace `testRoot()` body to assert `/health` returns 200 with `{"status":"ok"}` | self | + +### G. Dev ergonomics (repo root) + +| File | NEW/MODIFIED | Role | Data Flow | Closest Analog | Match Quality | +|------|--------------|------|-----------|----------------|---------------| +| `docker-compose.yml` | NEW | local postgres | config | RESEARCH.md § `docker-compose.yml` (lines 1053–1077) | canonical | +| `README.md` | MODIFIED | dev docs | n/a | itself (lines 1–100); add "Local development" section with `docker compose up -d postgres` + env defaults, drop "Build and Run Web Application" JS section (D-01) | self | +| `.gitignore` | MODIFIED (optional) | vcs config | n/a | itself (lines 1–20); add `build-logic/build/`, `**/.gradle/` patterns if not already covered | self | +| `tools/verify-no-version-literals.sh` | NEW (optional, Wave 0 gap) | shell validator | test | no analog — small shell script, RESEARCH.md § Wave 0 Gaps (line 1244) describes behavior | no analog | +| `tools/verify-shared-pure.sh` | NEW (optional, Wave 0 gap) | shell validator | test | no analog — same pattern as above | no analog | +| `tools/verify-ios-flags.sh` | NEW (optional, Wave 0 gap) | shell validator | test | no analog — same pattern as above | no analog | + +--- + +## Pattern Assignments + +### `build-logic/settings.gradle.kts` (included-build settings) + +**Analog:** RESEARCH.md § Pattern 1 (lines 314–331). No in-repo analog; this is a greenfield Gradle idiom. + +**Complete excerpt to copy:** + +```kotlin +dependencyResolutionManagement { + repositories { + google() + mavenCentral() + gradlePluginPortal() + } + versionCatalogs { + create("libs") { + from(files("../gradle/libs.versions.toml")) + } + } +} + +rootProject.name = "build-logic" +``` + +**Why this shape:** `build-logic/` is its own Gradle build. The `from(files("../gradle/libs.versions.toml"))` line is load-bearing — without it, the root catalog is invisible inside precompiled plugins and the `findLibrary("...")` lookups fail. `[CITED: gradle best practices, VersionCatalogSample]` + +--- + +### `build-logic/build.gradle.kts` (plugin buildscript) + +**Analog:** RESEARCH.md § Pattern 1 (lines 333–358). + +**Complete excerpt to copy:** + +```kotlin +plugins { + `kotlin-dsl` +} + +dependencies { + compileOnly(libs.plugins.kotlinMultiplatform.asDependency()) + compileOnly(libs.plugins.androidApplication.asDependency()) + compileOnly(libs.plugins.androidLibrary.asDependency()) + compileOnly(libs.plugins.composeMultiplatform.asDependency()) + compileOnly(libs.plugins.composeCompiler.asDependency()) + compileOnly(libs.plugins.composeHotReload.asDependency()) + compileOnly(libs.plugins.kotlinJvm.asDependency()) + compileOnly(libs.plugins.ktor.asDependency()) + compileOnly(libs.plugins.spotless.asDependency()) + compileOnly(libs.plugins.flywayPlugin.asDependency()) +} + +fun Provider.asDependency(): Provider = + map { "${it.pluginId}:${it.pluginId}.gradle.plugin:${it.version.requiredVersion}" } +``` + +**Critical detail:** The `.asDependency()` extension is the bridge between catalog plugin entries and the buildscript classpath. Without it, precompiled plugins cannot write `plugins { id("...") }` without inlining a version (which D-09 forbids). The `compileOnly(...)` scope is correct — the plugin markers are needed only at plugin-compile time, not at runtime. + +--- + +### `build-logic/src/main/kotlin/recipe.kotlin.multiplatform.gradle.kts` + +**Analogs (two sources):** +1. **RESEARCH.md § Code Examples (lines 777–835)** — the canonical form for this plugin. +2. **`composeApp/build.gradle.kts` lines 13–71** — the current template's `kotlin { }` block that needs to be generalized and moved into this plugin. + +**Imports pattern:** + +```kotlin +import org.gradle.api.artifacts.VersionCatalogsExtension +import org.gradle.kotlin.dsl.getByType +import org.jetbrains.kotlin.gradle.ExperimentalWasmDsl +import org.jetbrains.kotlin.gradle.dsl.JvmTarget +``` + +**Core pattern (from RESEARCH.md lines 789–834 — copy verbatim, adjusted for D-# decisions):** + +```kotlin +plugins { + id("org.jetbrains.kotlin.multiplatform") +} + +val libs = extensions.getByType().named("libs") + +kotlin { + jvmToolchain(21) // D-08: JVM 21 toolchain + + androidTarget { + compilerOptions { + jvmTarget.set(JvmTarget.JVM_11) // D-08: Android bytecode stays JVM 11 + } + } + + listOf(iosArm64(), iosSimulatorArm64()).forEach { iosTarget -> + iosTarget.binaries.framework { + baseName = "ComposeApp" // D-20; shared/build.gradle.kts overrides to "Shared" + isStatic = true + } + } + + jvm { + compilerOptions { + jvmTarget.set(JvmTarget.JVM_21) // D-08: server + desktop on JVM 21 + } + } + + @OptIn(ExperimentalWasmDsl::class) + wasmJs { browser() } + + compilerOptions { + allWarningsAsErrors.set(true) // D-11 + } + + sourceSets { + commonMain.dependencies { + implementation(project.dependencies.platform(libs.findLibrary("koin-bom").get())) + implementation(libs.findLibrary("koin-core").get()) + implementation(libs.findLibrary("kermit").get()) + } + commonTest.dependencies { + implementation(libs.findLibrary("kotlin-test").get()) + } + } +} +``` + +**Deltas vs. current `composeApp/build.gradle.kts` (lines 13–71):** +- DROP the `js { browser(); binaries.executable() }` block (lines 36–39) — D-01. +- DROP `iosX64` — already absent; keep absent (D-02). +- Keep `iosArm64`, `iosSimulatorArm64`, `jvm`, `wasmJs`, `androidTarget` (D-05). +- PROMOTE the `kotlin { ... }` block verbatim into this plugin. +- ADD Koin + Kermit + kotlin-test catalog references (deps did not exist on current `composeApp`). +- ADD `compilerOptions { allWarningsAsErrors.set(true) }` at the `kotlin { }` extension level (D-11). +- Note the **exact framework basename** `"ComposeApp"` — do not typo as `"composeApp"`. PITFALL #10. + +**Anti-patterns to avoid** (from RESEARCH.md § Anti-Patterns, lines 607–618): +- Do NOT re-declare `org.jetbrains.kotlin.multiplatform` in `recipe.compose.multiplatform.gradle.kts` — applying THIS plugin already applies it (PITFALL #2). +- Do NOT open per-target `compilerOptions { }` to set `allWarningsAsErrors` — set it once at the `kotlin { compilerOptions { } }` extension level (PITFALL #3). +- Do NOT use deprecated `kotlinOptions { }` DSL — Kotlin 2.3 removes it; `compilerOptions { property.set(...) }` is the only correct form (PITFALL #7). + +--- + +### `build-logic/src/main/kotlin/recipe.compose.multiplatform.gradle.kts` + +**Analog:** RESEARCH.md § Pattern 4 (lines 447–477). No in-repo analog — the plugin did not exist. + +**Complete excerpt to copy:** + +```kotlin +plugins { + id("recipe.kotlin.multiplatform") // layers on top — do not re-declare KMP plugin + id("org.jetbrains.compose") + id("org.jetbrains.kotlin.plugin.compose") + id("org.jetbrains.compose.hot-reload") // preserve commit c50d747 (hot-reload wiring) +} + +import org.gradle.api.artifacts.VersionCatalogsExtension +import org.gradle.kotlin.dsl.getByType + +val libs = extensions.getByType().named("libs") + +kotlin { + sourceSets { + commonMain.dependencies { + implementation(libs.findLibrary("compose-runtime").get()) + implementation(libs.findLibrary("compose-foundation").get()) + implementation(libs.findLibrary("compose-material3").get()) + implementation(libs.findLibrary("compose-ui").get()) + implementation(libs.findLibrary("compose-components-resources").get()) + implementation(libs.findLibrary("androidx-lifecycle-viewmodelCompose").get()) + implementation(libs.findLibrary("androidx-lifecycle-runtimeCompose").get()) + implementation(libs.findLibrary("koin-compose").get()) + implementation(libs.findLibrary("koin-composeViewmodel").get()) + } + } +} +``` + +**Deltas vs. current `composeApp/build.gradle.kts` lines 52–62:** +- The current file declares Compose deps in `commonMain.dependencies` — MOVE these into THIS plugin so `shared/` does not inherit Compose (D-19 / INFRA-06). +- ADD koin-compose + koin-composeViewmodel (not in catalog yet). + +**Why separate from `recipe.kotlin.multiplatform`:** if Compose deps were in the KMP plugin, `shared/` would pull Compose, violating D-19 / INFRA-06. This plugin layers Compose **on top** — `shared/` applies only `recipe.kotlin.multiplatform`, so it stays Compose-free. + +--- + +### `build-logic/src/main/kotlin/recipe.android.application.gradle.kts` + +**Analogs:** +1. **RESEARCH.md § Pattern 6 (lines 516–552)** — canonical form. +2. **`composeApp/build.gradle.kts` lines 73–98** — the current template's `android { }` block, moved verbatim. + +**Complete excerpt to copy:** + +```kotlin +plugins { + id("com.android.application") +} + +import org.gradle.api.artifacts.VersionCatalogsExtension +import org.gradle.kotlin.dsl.getByType + +val libs = extensions.getByType().named("libs") + +android { + namespace = "dev.ulfrx.recipe" // D-20 + compileSdk = libs.findVersion("android-compileSdk").get().toString().toInt() + + defaultConfig { + applicationId = "dev.ulfrx.recipe" + minSdk = libs.findVersion("android-minSdk").get().toString().toInt() + targetSdk = libs.findVersion("android-targetSdk").get().toString().toInt() + versionCode = 1 + versionName = "1.0" + } + packaging { + resources { + excludes += "/META-INF/{AL2.0,LGPL2.1}" + } + } + buildTypes { + getByName("release") { + isMinifyEnabled = false + } + } + compileOptions { + sourceCompatibility = JavaVersion.VERSION_11 + targetCompatibility = JavaVersion.VERSION_11 + } +} +``` + +**Delta vs. current `composeApp/build.gradle.kts` (lines 73–98):** +- Replace `libs.versions.android.compileSdk.get().toInt()` with `libs.findVersion("android-compileSdk").get().toString().toInt()` — catalog accessor syntax changes inside precompiled plugins (PITFALL #1). + +**Anti-pattern** (RESEARCH.md line 554): do NOT apply this plugin to `shared/`. `shared/` is a KMP library. If `shared/` needs Android, it applies `com.android.library` separately — but verify in Phase 1 whether `shared/` actually needs that plugin; the current `shared/build.gradle.kts` line 7 applies it but it may be redundant with `androidTarget` in KMP. + +--- + +### `build-logic/src/main/kotlin/recipe.jvm.server.gradle.kts` + +**Analogs:** +1. **RESEARCH.md § Pattern 7 (lines 558–601)** — canonical form. +2. **`server/build.gradle.kts` lines 1–23** — current plugins block + dependencies, extended. + +**Complete excerpt to copy:** + +```kotlin +plugins { + id("org.jetbrains.kotlin.jvm") + id("io.ktor.plugin") + id("org.flywaydb.flyway") + application +} + +import org.gradle.api.artifacts.VersionCatalogsExtension +import org.gradle.kotlin.dsl.getByType + +val libs = extensions.getByType().named("libs") + +kotlin { + jvmToolchain(21) + compilerOptions { + allWarningsAsErrors.set(true) + } +} + +dependencies { + "implementation"(libs.findLibrary("ktor-serverCore").get()) + "implementation"(libs.findLibrary("ktor-serverNetty").get()) + "implementation"(libs.findLibrary("ktor-serverContentNegotiation").get()) + "implementation"(libs.findLibrary("ktor-serializationKotlinxJson").get()) + "implementation"(libs.findLibrary("logback").get()) + "implementation"(libs.findLibrary("flyway-core").get()) + "implementation"(libs.findLibrary("flyway-database-postgresql").get()) + "implementation"(libs.findLibrary("postgresql").get()) + "testImplementation"(libs.findLibrary("ktor-serverTestHost").get()) + "testImplementation"(libs.findLibrary("kotlin-testJunit").get()) +} + +flyway { + url = System.getenv("DATABASE_URL") ?: "jdbc:postgresql://localhost:5432/recipe" + user = System.getenv("DATABASE_USER") ?: "recipe" + password = System.getenv("DATABASE_PASSWORD") ?: "recipe" + locations = arrayOf("classpath:db/migration") + cleanDisabled = true + baselineOnMigrate = true + validateOnMigrate = true +} +``` + +**Quoted-config footgun** (RESEARCH.md line 603): inside a precompiled plugin, `implementation(...)` is not a typed method. You MUST write `"implementation"(libs.findLibrary(...).get())` with quoted config name, or the build will fail with "unresolved reference: implementation". + +**Delta vs. current `server/build.gradle.kts`:** +- `application { }` block (lines 7–14 of current file) stays in the MODULE `server/build.gradle.kts`, not this plugin — per-module concern. +- ADD Flyway + Postgres + ContentNegotiation + kotlinx-serialization deps (catalog entries to add). +- Current `server/build.gradle.kts` uses `libs.plugins.kotlinJvm` / `libs.plugins.ktor` aliases — REPLACE with convention-plugin ID `id("recipe.jvm.server")`. + +**Flyway caveat** (PITFALL #6, RESEARCH.md lines 719–724): the Flyway Gradle plugin is for CLI ergonomics (`./gradlew flywayMigrate`) only. Runtime migration happens through the Flyway Java API in `Database.kt` — do NOT wire Flyway tasks as a dependency of `classes` or `build`, or `./gradlew build` will fail when Postgres is not running. + +--- + +### `build-logic/src/main/kotlin/recipe.quality.gradle.kts` + +**Analog:** RESEARCH.md § Pattern 5 (lines 483–512). No in-repo analog. + +**Complete excerpt to copy:** + +```kotlin +plugins { + id("com.diffplug.spotless") +} + +spotless { + kotlin { + target("src/**/*.kt") + targetExclude("**/build/**", "**/generated/**") + ktlint() // latest stable (Spotless default) + } + kotlinGradle { + target("*.gradle.kts") + ktlint() + } + format("markdown") { + target("*.md", "docs/**/*.md") + endWithNewline() + trimTrailingWhitespace() + } +} + +// Redundancy guard for modules that apply recipe.quality without recipe.kotlin.multiplatform +tasks.withType>().configureEach { + compilerOptions { + allWarningsAsErrors.set(true) + } +} +``` + +**Note on ktlint ruleset version:** D-10 says pick the latest stable; Spotless's bare `ktlint()` call uses its default, which is fine. Pin only if drift becomes a problem. + +--- + +### `gradle/libs.versions.toml` (MODIFIED — catalog extensions) + +**Analog:** itself (lines 1–53). + +**Imports pattern:** n/a (TOML). + +**Delta — add under `[versions]`:** + +```toml +koin = "4.1.0" # bump to current at plan time; verify compose-multiplatform compat +kermit = "2.0.6" # bump to current at plan time +spotless = "7.2.1" # bump to current at plan time +flyway = "11.10.0" # bump to current at plan time +postgres-jdbc = "42.7.7" # bump to current at plan time +``` + +**Delta — add under `[libraries]`:** + +```toml +# Koin (client DI) +koin-bom = { module = "io.insert-koin:koin-bom", version.ref = "koin" } +koin-core = { module = "io.insert-koin:koin-core" } +koin-compose = { module = "io.insert-koin:koin-compose" } +koin-composeViewmodel = { module = "io.insert-koin:koin-compose-viewmodel" } +# Note: BOM-managed deps omit version.ref + +# Kermit (client logger) +kermit = { module = "co.touchlab:kermit", version.ref = "kermit" } + +# Server: ContentNegotiation + Flyway + Postgres +ktor-serverContentNegotiation = { module = "io.ktor:ktor-server-content-negotiation-jvm", version.ref = "ktor" } +ktor-serializationKotlinxJson = { module = "io.ktor:ktor-serialization-kotlinx-json-jvm", version.ref = "ktor" } +flyway-core = { module = "org.flywaydb:flyway-core", version.ref = "flyway" } +flyway-database-postgresql = { module = "org.flywaydb:flyway-database-postgresql", version.ref = "flyway" } +postgresql = { module = "org.postgresql:postgresql", version.ref = "postgres-jdbc" } +``` + +**Delta — add under `[plugins]`:** + +```toml +spotless = { id = "com.diffplug.spotless", version.ref = "spotless" } +flywayPlugin = { id = "org.flywaydb.flyway", version.ref = "flyway" } +``` + +**Hard invariant (D-09 / INFRA-01 SC#2):** after this edit, `grep -rn 'version = \"[0-9]' --include='*.gradle.kts' .` should return zero hits outside `build-logic/build.gradle.kts` auto-generated accessors. Wave 0 gap `tools/verify-no-version-literals.sh` enforces this. + +--- + +### `gradle.properties` (MODIFIED — add K/N flags) + +**Analog:** itself (lines 1–10) + RESEARCH.md § `gradle.properties` (lines 1083–1102). + +**Delta — append to file (D-18, INFRA-03, PITFALL #1):** + +```properties +# Kotlin/Native iOS (PITFALLS.md #1; D-18; INFRA-03) — MANDATORY day 1 +kotlin.native.binary.gc=cms +kotlin.native.binary.objcDisposeOnMain=false +``` + +**Verification** (RESEARCH.md lines 1104–1107): `./gradlew :composeApp:linkDebugFrameworkIosSimulatorArm64 --info | grep -E 'objcDisposeOnMain|gc=cms'` should echo both flags. Wave 0 gap `tools/verify-ios-flags.sh` automates the grep. + +--- + +### `settings.gradle.kts` (MODIFIED — include build-logic) + +**Analog:** itself (lines 1–37). + +**Delta:** add `includeBuild("build-logic")` in the right position. + +**PITFALL #9** (RESEARCH.md lines 749–767): `includeBuild("build-logic")` must appear **before** `pluginManagement { }` consumes plugin IDs that come from `build-logic`, OR more simply: put `pluginManagement { includeBuild("build-logic") }` at the top. The proven layout: + +```kotlin +rootProject.name = "recipe" +enableFeaturePreview("TYPESAFE_PROJECT_ACCESSORS") + +pluginManagement { + includeBuild("build-logic") + repositories { + google { mavenContent { includeGroupAndSubgroups("androidx"); includeGroupAndSubgroups("com.android"); includeGroupAndSubgroups("com.google") } } + mavenCentral() + gradlePluginPortal() + } +} + +dependencyResolutionManagement { + repositories { + google { mavenContent { includeGroupAndSubgroups("androidx"); includeGroupAndSubgroups("com.android"); includeGroupAndSubgroups("com.google") } } + mavenCentral() + } +} + +plugins { + id("org.gradle.toolchains.foojay-resolver-convention") version "1.0.0" +} + +include(":composeApp") +include(":server") +include(":shared") +``` + +Note that `includeBuild` lives inside `pluginManagement { }` — not at top level — so the `build-logic` plugins are resolvable by ID in child module `plugins { }` blocks. + +--- + +### `build.gradle.kts` (root — MODIFIED) + +**Analog:** itself (lines 1–12). + +**Delta:** add `apply false` entries for `spotless` and `flywayPlugin` (so Gradle's classloader hint covers them): + +```kotlin +plugins { + alias(libs.plugins.androidApplication) apply false + alias(libs.plugins.androidLibrary) apply false + alias(libs.plugins.composeHotReload) apply false + alias(libs.plugins.composeMultiplatform) apply false + alias(libs.plugins.composeCompiler) apply false + alias(libs.plugins.kotlinJvm) apply false + alias(libs.plugins.kotlinMultiplatform) apply false + alias(libs.plugins.ktor) apply false + alias(libs.plugins.spotless) apply false // NEW + alias(libs.plugins.flywayPlugin) apply false // NEW +} +``` + +--- + +### `composeApp/build.gradle.kts` (MODIFIED — apply conventions) + +**Analog:** itself (lines 1–114). + +**Core pattern — new plugins block:** + +```kotlin +plugins { + id("recipe.kotlin.multiplatform") + id("recipe.compose.multiplatform") + id("recipe.android.application") + id("recipe.quality") +} + +kotlin { + sourceSets { + androidMain.dependencies { + implementation(libs.compose.uiToolingPreview) + implementation(libs.androidx.activity.compose) + } + commonMain.dependencies { + implementation(libs.compose.uiToolingPreview) + implementation(projects.shared) + // Compose + Koin + Kermit + kotlin-test come via convention plugins + } + jvmMain.dependencies { + implementation(compose.desktop.currentOs) + implementation(libs.kotlinx.coroutinesSwing) + } + } +} + +dependencies { + debugImplementation(libs.compose.uiTooling) +} +``` + +**Deltas vs. current file:** +- REPLACE plugins block (lines 5–11) with convention-plugin IDs. +- DROP the entire `kotlin { androidTarget { ... } ... }` block (lines 13–71) — moved to `recipe.kotlin.multiplatform`. Keep only the per-module `sourceSets { ... }` overrides for `androidMain` / `commonMain` / `jvmMain` deps that are NOT shared. +- DROP the `android { }` block (lines 73–98) — moved to `recipe.android.application`. +- DROP `js { browser(); binaries.executable() }` (lines 36–39) — D-01. +- DROP `compose.desktop { application { ... nativeDistributions { ... } } }` (lines 104–114) — D-03 says no desktop packaging. + +--- + +### `shared/build.gradle.kts` (MODIFIED — apply conventions + explicitApi) + +**Analog:** itself (lines 1–55). + +**Core pattern:** + +```kotlin +plugins { + id("recipe.kotlin.multiplatform") + id("recipe.quality") + // NOTE: recipe.android.application is NOT applied — shared is a library, not an app + // NOTE: if com.android.library is still needed for androidTarget resources, apply directly: + // alias(libs.plugins.androidLibrary) +} + +kotlin { + explicitApi() // D-12: strict only on shared/ + sourceSets { + commonMain.dependencies { + // Phase 1: empty — domain models + DTOs land Phase 2+ + } + } + // Override framework baseName for this module + targets.withType().configureEach { + binaries.withType().configureEach { + baseName = "Shared" + } + } +} + +// Optional (see Open Questions in RESEARCH.md) +android { + namespace = "dev.ulfrx.recipe.shared" + compileSdk = libs.versions.android.compileSdk.get().toInt() + compileOptions { + sourceCompatibility = JavaVersion.VERSION_11 + targetCompatibility = JavaVersion.VERSION_11 + } + defaultConfig { + minSdk = libs.versions.android.minSdk.get().toInt() + } +} +``` + +**Deltas vs. current file (lines 1–55):** +- REPLACE plugins block (lines 4–7) with convention-plugin IDs; retain `androidLibrary` alias if Android namespace / resources are required (decision pending per RESEARCH.md Anti-Patterns §). +- DROP entire `kotlin { androidTarget { ... } ... }` target block (lines 9–33) — moved to `recipe.kotlin.multiplatform`. +- DROP `js { browser() }` (lines 25–27) — D-01. +- ADD `explicitApi()` (D-12) — lives in the MODULE file so app modules don't inherit it. +- OVERRIDE framework baseName to `"Shared"` (the KMP plugin defaults to `"ComposeApp"`; shared needs its own symbol — PITFALL #10). + +**Anti-pattern check (D-19):** `commonMain.dependencies { }` must stay empty in Phase 1. Do NOT add Ktor, Compose, or SQLDelight here — EVER. Only `kotlinx-serialization` + `kotlinx-datetime` are whitelisted for future phases. + +--- + +### `server/build.gradle.kts` (MODIFIED — apply convention) + +**Analog:** itself (lines 1–23). + +**Core pattern:** + +```kotlin +plugins { + id("recipe.jvm.server") + id("recipe.quality") +} + +group = "dev.ulfrx.recipe" +version = "1.0.0" + +application { + mainClass.set("dev.ulfrx.recipe.ApplicationKt") + + val isDevelopment: Boolean = project.ext.has("development") + applicationDefaultJvmArgs = listOf("-Dio.ktor.development=$isDevelopment") +} + +dependencies { + implementation(projects.shared) +} +``` + +**Deltas vs. current file (lines 1–23):** +- REPLACE plugins block (lines 1–5) with convention-plugin IDs. +- DROP individual library implementations (lines 16–22) — moved to `recipe.jvm.server`. +- KEEP `application { mainClass.set(...) }` (lines 9–14) — per-module concern. +- KEEP `implementation(projects.shared)` (line 17) — module-specific project dependency. + +--- + +### `composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/di/Koin.kt` (NEW) + +**Analog:** RESEARCH.md § Koin bootstrap (lines 840–850). No in-repo analog. + +**Complete file:** + +```kotlin +package dev.ulfrx.recipe.di + +import org.koin.core.KoinApplication +import org.koin.core.context.startKoin +import org.koin.dsl.KoinAppDeclaration + +fun initKoin(config: KoinAppDeclaration? = null): KoinApplication = startKoin { + config?.invoke(this) + modules(appModule) +} +``` + +**Usage contract** (RESEARCH.md § Kermit bootstrap line 948): call `configureLogging()` BEFORE `initKoin()`, so Koin module loading can use Kermit. Order per platform: +- Android: `MainApplication.onCreate()` → `configureLogging(); initKoin { androidContext(this) }` +- iOS: `iOSApp.init()` → Swift side calls `KoinIosKt.doInitKoin()` which invokes `configureLogging(); initKoin()` +- Desktop: `main()` top → `configureLogging(); initKoin(); application { Window { App() } }` +- Wasm: `main()` top → `configureLogging(); initKoin(); ComposeViewport { App() }` + +**Anti-pattern (PITFALL #4):** do NOT call `startKoin { }` from inside `MainViewController()` AND from `iOSApp.init()` — you'll hit `KoinApplicationAlreadyStartedException` on second cold launch. Pick one: the canonical choice is `iOSApp.init() → doInitKoin()`. `MainViewController()` stays as-is (`fun MainViewController() = ComposeUIViewController { App() }`). + +--- + +### `composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/di/AppModule.kt` (NEW) + +**Analog:** RESEARCH.md § Koin bootstrap (lines 852–861). + +**Complete file:** + +```kotlin +package dev.ulfrx.recipe.di + +import org.koin.dsl.module + +// Phase 2 adds authModule; Phase 4 adds syncModule; Phase 5 adds catalogModule; etc. +val appModule = module { + // intentionally empty in Phase 1 +} +``` + +--- + +### `composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/logging/Logging.kt` (NEW) + +**Analog:** RESEARCH.md § Kermit bootstrap (lines 935–946). + +**Complete file:** + +```kotlin +package dev.ulfrx.recipe.logging + +import co.touchlab.kermit.Logger + +fun configureLogging() { + Logger.setTag("recipe") + // Platform log writers (OSLog iOS, LogCat Android, System.out JVM/Wasm) install by default. +} +``` + +--- + +### `composeApp/src/iosMain/kotlin/dev/ulfrx/recipe/di/KoinIos.kt` (NEW) + +**Analogs:** +1. **RESEARCH.md § Koin bootstrap (lines 865–870)** — the canonical symbol. +2. **`composeApp/src/iosMain/kotlin/dev/ulfrx/recipe/MainViewController.kt` (lines 1–5)** — sibling showing the `expect`/`actual`-free simple iosMain style. + +**Complete file:** + +```kotlin +package dev.ulfrx.recipe.di + +import dev.ulfrx.recipe.logging.configureLogging + +fun doInitKoin() { + configureLogging() + initKoin() +} +``` + +**Why the naming:** Kotlin's top-level `fun doInitKoin()` in package `dev.ulfrx.recipe.di` becomes the Swift symbol `KoinIosKt.doInitKoin()` (framework baseName is `ComposeApp` per D-20, but the generated Swift class is `Kt` — so `KoinIos.kt` → `KoinIosKt`). PITFALL #10 warns about basename mismatches; here the class suffix `Kt` is automatic and tied to the file name. + +--- + +### `composeApp/src/androidMain/kotlin/dev/ulfrx/recipe/MainApplication.kt` (NEW) + +**Analogs:** +1. **RESEARCH.md § Koin bootstrap (lines 896–911)** — canonical. +2. **`composeApp/src/androidMain/kotlin/dev/ulfrx/recipe/MainActivity.kt` (lines 1–19)** — sibling showing package + import conventions for this target. + +**Complete file:** + +```kotlin +package dev.ulfrx.recipe + +import android.app.Application +import dev.ulfrx.recipe.di.initKoin +import dev.ulfrx.recipe.logging.configureLogging +import org.koin.android.ext.koin.androidContext + +class MainApplication : Application() { + override fun onCreate() { + super.onCreate() + configureLogging() + initKoin { + androidContext(this@MainApplication) + } + } +} +``` + +**Additional catalog entry needed:** `koin-android = { module = "io.insert-koin:koin-android" }` under `[libraries]` (BOM-managed, no version.ref). Wire via `androidMain.dependencies` in `composeApp/build.gradle.kts` OR add to `recipe.compose.multiplatform` if every Android consumer needs it. + +--- + +### `composeApp/src/androidMain/AndroidManifest.xml` (MODIFIED) + +**Analog:** itself (lines 1–22). + +**Delta:** line 4 ` +``` + +--- + +### `composeApp/src/jvmMain/kotlin/dev/ulfrx/recipe/main.kt` (MODIFIED) + +**Analog:** itself (lines 1–13) + RESEARCH.md § Koin bootstrap (lines 918–924). + +**Full replacement:** + +```kotlin +package dev.ulfrx.recipe + +import androidx.compose.ui.window.Window +import androidx.compose.ui.window.application +import dev.ulfrx.recipe.di.initKoin +import dev.ulfrx.recipe.logging.configureLogging + +fun main() { + configureLogging() + initKoin() + application { + Window( + onCloseRequest = ::exitApplication, + title = "recipe", + ) { + App() + } + } +} +``` + +--- + +### `composeApp/src/webMain/kotlin/dev/ulfrx/recipe/main.kt` (MODIFIED) + +**Analog:** itself (lines 1–10) + RESEARCH.md § Koin bootstrap (lines 927–931); PITFALL #8 (lines 733–747). + +**Full replacement:** + +```kotlin +package dev.ulfrx.recipe + +import androidx.compose.ui.ExperimentalComposeUiApi +import androidx.compose.ui.window.ComposeViewport +import dev.ulfrx.recipe.di.initKoin +import dev.ulfrx.recipe.logging.configureLogging + +@OptIn(ExperimentalComposeUiApi::class) +fun main() { + configureLogging() + initKoin() + ComposeViewport { + App() + } +} +``` + +**Critical:** both `configureLogging()` and `initKoin()` must run **before** `ComposeViewport { }` — otherwise first `koinViewModel()` inside composition throws (PITFALL #8). Phase 1 has no ViewModels so this is defensive, but the template's shape must be right from day 1. + +--- + +### `iosApp/iosApp/iOSApp.swift` (MODIFIED) + +**Analog:** itself (lines 1–11) + RESEARCH.md § Koin bootstrap (lines 874–891). + +**Full replacement:** + +```swift +import SwiftUI +import ComposeApp + +@main +struct iOSApp: App { + init() { + KoinIosKt.doInitKoin() // calls Kotlin's fun doInitKoin() in dev.ulfrx.recipe.di + } + + var body: some Scene { + WindowGroup { + ContentView() + } + } +} +``` + +**Deltas vs. current file (lines 1–11):** +- ADD `import ComposeApp` (the framework baseName — D-20 / PITFALL #10). +- ADD `init() { KoinIosKt.doInitKoin() }`. + +--- + +### `server/src/main/kotlin/dev/ulfrx/recipe/Application.kt` (MODIFIED) + +**Analog:** itself (lines 1–20) + RESEARCH.md § Ktor `/health` (lines 952–985). + +**Full replacement:** + +```kotlin +package dev.ulfrx.recipe + +import io.ktor.serialization.kotlinx.json.json +import io.ktor.server.application.Application +import io.ktor.server.engine.embeddedServer +import io.ktor.server.netty.Netty +import io.ktor.server.plugins.contentnegotiation.ContentNegotiation +import io.ktor.server.response.respond +import io.ktor.server.routing.get +import io.ktor.server.routing.routing +import kotlinx.serialization.Serializable + +fun main() { + embeddedServer(Netty, port = SERVER_PORT, host = "0.0.0.0", module = Application::module) + .start(wait = true) +} + +@Serializable +private data class Health(val status: String) + +fun Application.module() { + install(ContentNegotiation) { + json() + } + Database.migrate(this) // fails loudly if Postgres unreachable (D-16) + + routing { + get("/health") { + call.respond(Health(status = "ok")) + } + } +} +``` + +**Deltas vs. current file (lines 1–20):** +- DROP `get("/") { call.respondText("Ktor: ${Greeting().greet()}") }` — replaced by `/health`. +- ADD `install(ContentNegotiation) { json() }` — required for `@Serializable` response. +- ADD `Database.migrate(this)` — Flyway bootstrap (fails server boot if DB unreachable). +- CHANGE imports from wildcard (`io.ktor.server.application.*`) to specific — D-11 may warn on unused wildcards; be explicit. +- `SERVER_PORT` constant continues to live in `shared/commonMain/.../Constants.kt`. + +--- + +### `server/src/main/kotlin/dev/ulfrx/recipe/Database.kt` (NEW) + +**Analog:** RESEARCH.md § `Database.kt` (lines 990–1023). No in-repo analog. + +**Important substitution** (RESEARCH.md lines 1025–1027): Kermit is the **client** logger. The server uses SLF4J + Logback (already wired via `logback.xml`). So this file must use SLF4J, not Kermit: + +**Complete file (server-adjusted SLF4J variant):** + +```kotlin +package dev.ulfrx.recipe + +import io.ktor.server.application.Application +import org.flywaydb.core.Flyway +import org.slf4j.LoggerFactory + +object Database { + private val log = LoggerFactory.getLogger(Database::class.java) + + fun migrate(app: Application) { + val url = app.environment.config.property("database.url").getString() + val user = app.environment.config.property("database.user").getString() + val password = app.environment.config.property("database.password").getString() + + log.info("Connecting to {} as {} and running Flyway migrations", url, user) + + runCatching { + Flyway.configure() + .dataSource(url, user, password) + .locations("classpath:db/migration") + .baselineOnMigrate(true) + .validateOnMigrate(true) + .cleanDisabled(true) + .load() + .migrate() + }.onFailure { ex -> + log.error("Flyway migration failed — cannot start server", ex) + throw IllegalStateException("Database unreachable or migration failed", ex) + } + } +} +``` + +**Fail-loud contract (D-16):** the `throw IllegalStateException(...)` is load-bearing — the server MUST refuse to start if Postgres is unreachable. This surfaces config errors immediately instead of letting the server run with a broken DB. + +--- + +### `server/src/main/resources/application.conf` (NEW) + +**Analog:** RESEARCH.md § `application.conf` (lines 1031–1051). No in-repo analog (the current server has no `application.conf`; it's purely programmatic in `Application.kt`). + +**Complete file:** + +```hocon +ktor { + deployment { + port = 8080 + port = ${?PORT} + } + application { + modules = [ dev.ulfrx.recipe.ApplicationKt.module ] + } +} + +database { + url = "jdbc:postgresql://localhost:5432/recipe" + url = ${?DATABASE_URL} + user = "recipe" + user = ${?DATABASE_USER} + password = "recipe" + password = ${?DATABASE_PASSWORD} +} +``` + +**HOCON substitution contract (PITFALL #5, RESEARCH.md lines 692–717):** the two-line pattern `url = "default"; url = ${?DATABASE_URL}` is load-bearing. Use `${?X}` (optional substitution), NOT `${X}` (required, parse-time failure) or `${X:default}` (wrong syntax for HOCON defaults). + +**Interaction with programmatic `main()`:** the current `Application.kt` uses `embeddedServer(Netty, port = SERVER_PORT, ...)` programmatically. When `application.conf` is introduced, Ktor will read `application.modules = [...]` at boot time. The programmatic `embeddedServer` form in `main()` is still valid — HOCON overrides happen at `application.environment.config.property(...)` lookups (as `Database.kt` does). Verify at plan time whether to keep programmatic boot or switch to `EngineMain` (HOCON-driven). Either works; RESEARCH.md excerpt keeps the programmatic form for simplicity. + +--- + +### `server/src/main/resources/db/migration/.gitkeep` (NEW) + +**Analog:** Flyway convention — empty directory placeholder. + +**Complete file:** empty `.gitkeep` file. Phase 3 drops `V1__init.sql` here. + +**Why `.gitkeep`:** git does not track empty directories; the `.gitkeep` convention (a zero-byte file) ensures the directory ships in-repo so `Flyway.locations("classpath:db/migration")` finds it. + +--- + +### `server/src/test/kotlin/dev/ulfrx/recipe/ApplicationTest.kt` (MODIFIED) + +**Analog:** itself (lines 1–20). + +**Full replacement:** + +```kotlin +package dev.ulfrx.recipe + +import io.ktor.client.request.get +import io.ktor.client.statement.bodyAsText +import io.ktor.http.HttpStatusCode +import io.ktor.server.testing.testApplication +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +class ApplicationTest { + + @Test + fun `health endpoint returns 200 with status ok`() = testApplication { + // Note: testApplication uses in-memory config; Database.migrate() must be skipped or mocked + // for this test to run without Postgres. Recommend: extract a `Application.configureRouting()` + // and test only routing in isolation. See plan note. + application { + install(io.ktor.server.plugins.contentnegotiation.ContentNegotiation) { + io.ktor.serialization.kotlinx.json.json() + } + io.ktor.server.routing.routing { + io.ktor.server.routing.get("/health") { + io.ktor.server.response.respond(mapOf("status" to "ok")) + } + } + } + val response = client.get("/health") + assertEquals(HttpStatusCode.OK, response.status) + assertTrue(response.bodyAsText().contains("\"status\"")) + assertTrue(response.bodyAsText().contains("\"ok\"")) + } +} +``` + +**Deltas vs. current file (lines 1–20):** +- DROP `testRoot()` (tests `Greeting().greet()` output). +- ADD `health endpoint returns 200` test. +- CAVEAT: the current `module()` calls `Database.migrate(this)` which needs a real Postgres. Split the Ktor config: extract `Application.configureRouting()` + `Application.configureSerialization()` helpers so tests can compose routing without the DB. Plan should capture this refactor. + +--- + +### `docker-compose.yml` (NEW) + +**Analog:** RESEARCH.md § `docker-compose.yml` (lines 1055–1077). No in-repo analog. + +**Complete file:** + +```yaml +services: + postgres: + image: postgres:16 + container_name: recipe-postgres + environment: + POSTGRES_DB: recipe + POSTGRES_USER: recipe + POSTGRES_PASSWORD: recipe + ports: + - "5432:5432" + volumes: + - recipe-pgdata:/var/lib/postgresql/data + healthcheck: + test: ["CMD-SHELL", "pg_isready -U recipe -d recipe"] + interval: 5s + timeout: 5s + retries: 5 + +volumes: + recipe-pgdata: +``` + +**Matched defaults:** `POSTGRES_*` env values here match `application.conf` localhost defaults exactly — running `docker compose up -d postgres` + `./gradlew :server:run` works with zero additional env setup. Homelab deploy (Phase 11) uses a different compose file with real creds + Authentik alongside. + +--- + +### `README.md` (MODIFIED) + +**Analog:** itself (lines 1–100). + +**Delta:** +1. DROP "Build and Run Web Application" JS sections (lines 77–85) — D-01 drops `js` target; keep only the `wasmJs` section. +2. ADD a new "Local development" section documenting `docker compose up -d postgres` + `./gradlew :server:run` + `curl localhost:8080/health`. +3. ADD mention of `./gradlew spotlessApply` before commits (D-10 / D-13). + +--- + +### `tools/verify-*.sh` (NEW — Wave 0 gap shell scripts) + +**Analog:** none — small bespoke shell scripts, RESEARCH.md § Wave 0 Gaps lines 1242–1256 describes behavior. + +**`tools/verify-no-version-literals.sh` — sketch:** + +```sh +#!/usr/bin/env bash +# Enforces INFRA-01 SC#2 / D-09: no literal version strings outside catalog. +set -e +VIOLATIONS=$(grep -rn 'version\s*=\s*"[0-9]' --include='*.gradle.kts' . | grep -v 'build-logic/build.gradle.kts' || true) +if [ -n "$VIOLATIONS" ]; then + echo "ERROR: version literals found outside catalog:" + echo "$VIOLATIONS" + exit 1 +fi +echo "OK: no version literals outside catalog." +``` + +**`tools/verify-shared-pure.sh` — sketch:** + +```sh +#!/usr/bin/env bash +# Enforces INFRA-06 / D-19: shared/commonMain must not import Ktor, Compose, SQLDelight. +set -e +VIOLATIONS=$(grep -rn -E 'import\s+(io\.ktor|androidx\.compose|org\.jetbrains\.compose|app\.cash\.sqldelight)' shared/src/commonMain/ || true) +if [ -n "$VIOLATIONS" ]; then + echo "ERROR: shared/commonMain has forbidden imports:" + echo "$VIOLATIONS" + exit 1 +fi +echo "OK: shared/commonMain is pure." +``` + +**`tools/verify-ios-flags.sh` — sketch:** + +```sh +#!/usr/bin/env bash +# Enforces INFRA-03 / D-18: iOS K/N flags present. +set -e +grep -q '^kotlin\.native\.binary\.gc=cms' gradle.properties || { echo "MISSING: kotlin.native.binary.gc=cms"; exit 1; } +grep -q '^kotlin\.native\.binary\.objcDisposeOnMain=false' gradle.properties || { echo "MISSING: kotlin.native.binary.objcDisposeOnMain=false"; exit 1; } +echo "OK: iOS binary flags present." +``` + +All three scripts are simple greps; no complex analog needed. Make executable (`chmod +x`) and optionally wire into `./gradlew check` via a `Exec` task in `recipe.quality`. + +--- + +## Shared Patterns + +### Version catalog accessor inside precompiled plugins + +**Source:** RESEARCH.md § Pattern 2 (lines 362–380), PITFALL #1 (lines 654–660). +**Apply to:** every `.gradle.kts` file under `build-logic/src/main/kotlin/`. + +```kotlin +import org.gradle.api.artifacts.VersionCatalogsExtension +import org.gradle.kotlin.dsl.getByType + +val libs = extensions.getByType().named("libs") + +// Then: +implementation(libs.findLibrary("koin-core").get()) +val kotlinVersion = libs.findVersion("kotlin").get().toString() +val minSdk = libs.findVersion("android-minSdk").get().toString().toInt() +``` + +**Anti-pattern:** `implementation(libs.koin.core)` inside a precompiled plugin → unresolved reference compile error. + +--- + +### Quoted configuration names in precompiled-plugin `dependencies { }` blocks + +**Source:** RESEARCH.md § Pattern 7 footnote (lines 603–605). +**Apply to:** `recipe.jvm.server.gradle.kts` and any future precompiled plugin that adds module dependencies. + +```kotlin +dependencies { + "implementation"(libs.findLibrary("ktor-serverCore").get()) + "testImplementation"(libs.findLibrary("ktor-serverTestHost").get()) +} +``` + +**Not this:** `implementation(libs.findLibrary(...).get())` — the unquoted form is a typed DSL method that only exists on module build scripts, not on precompiled plugins. + +--- + +### `allWarningsAsErrors` at extension level only + +**Source:** D-11, PITFALL #3, PITFALL #7. +**Apply to:** `recipe.kotlin.multiplatform`, `recipe.jvm.server`, `recipe.quality` (as a safety net). + +```kotlin +kotlin { + compilerOptions { + allWarningsAsErrors.set(true) // at kotlin { } extension level + } + // NOT inside androidTarget { compilerOptions { ... } } or jvm { compilerOptions { ... } } +} +``` + +--- + +### Init order on every platform entry: configureLogging → initKoin → compose + +**Source:** RESEARCH.md § Kermit bootstrap notes (line 948), PITFALLS #4 + #8. +**Apply to:** `MainApplication.onCreate()` (Android), `KoinIos.doInitKoin()` (iOS), `main()` (jvm + wasmJs). + +```kotlin +configureLogging() // set Kermit tag first +initKoin() // Koin modules may log during load +// THEN composition entry: application { Window { App() } } +// OR ComposeViewport { App() } +// OR setContent { App() } +``` + +**Anti-pattern:** calling `startKoin` from inside a `@Composable` function — races with recomposition, panics. + +--- + +### iOS framework baseName consistency (`ComposeApp` / `Shared`) + +**Source:** D-20, PITFALL #10. +**Apply to:** `recipe.kotlin.multiplatform` (default: `"ComposeApp"`), `shared/build.gradle.kts` override (`"Shared"`), `iosApp/iosApp/iOSApp.swift` `import ComposeApp`, `iosApp/iosApp/ContentView.swift` `MainViewControllerKt.MainViewController()`. + +**Invariant:** whatever string is set as `baseName` MUST match the `import X` in Swift, and the generated Swift class name is `Kt` (e.g. `KoinIos.kt` → `KoinIosKt`, `MainViewController.kt` → `MainViewControllerKt`). + +--- + +### Catalog-only version hard rule (D-09 / INFRA-01 SC#2) + +**Source:** D-09. +**Apply to:** every `*.gradle.kts` except `build-logic/build.gradle.kts` (which needs literal `asDependency()` version in coordinates). + +**Verification:** `grep -rn 'version\s*=\s*"[0-9]' --include='*.gradle.kts' .` must return zero hits outside `build-logic/build.gradle.kts`. + +--- + +## Files with No Analog + +These files are small bespoke shell scripts or structural placeholders; RESEARCH.md documents their intent but no other file in the repo has the same shape. + +| File | Role | Data Flow | Reason | +|------|------|-----------|--------| +| `tools/verify-no-version-literals.sh` | validator | test/grep | Bespoke grep pipeline; canonical form shown above | +| `tools/verify-shared-pure.sh` | validator | test/grep | Bespoke grep pipeline; canonical form shown above | +| `tools/verify-ios-flags.sh` | validator | test/grep | Bespoke grep pipeline; canonical form shown above | +| `server/src/main/resources/db/migration/.gitkeep` | empty dir marker | n/a | Convention; zero-byte file | +| `shared/src/commonMain/kotlin/dev/ulfrx/recipe/shared/.gitkeep` | empty dir marker | n/a | Convention; zero-byte file | + +--- + +## Metadata + +**Analog search scope:** +- `/Users/rwilk/dev/repo/recipe/composeApp/` (template Kotlin + resources) +- `/Users/rwilk/dev/repo/recipe/shared/` (template Kotlin) +- `/Users/rwilk/dev/repo/recipe/server/` (template Ktor + resources) +- `/Users/rwilk/dev/repo/recipe/iosApp/` (SwiftUI shell) +- `/Users/rwilk/dev/repo/recipe/gradle/`, root-level `*.gradle.kts`, `gradle.properties`, `settings.gradle.kts`, `.gitignore` +- `/Users/rwilk/dev/repo/recipe/.planning/phases/01-.../01-RESEARCH.md` § Code Examples + § Architecture Patterns + +**Files scanned:** ~30 (all existing sources in the refactor surface). + +**Pattern extraction date:** 2026-04-24. + +**Confidence:** HIGH for all in-repo-analog files (they are the exact files the executor will edit). HIGH for RESEARCH.md-canonical files — those excerpts were written with Phase 1 D-# decisions already applied. Residual risk is in two areas: +1. Exact plugin/library versions to pin in catalog (bump to latest stable at plan time; RESEARCH.md notes this). +2. Whether `shared/build.gradle.kts` retains `com.android.library` or drops it (see RESEARCH.md § Open Questions; plan must decide — default: keep, since `shared/` currently uses `namespace = "dev.ulfrx.recipe.shared"` for Android resources). + +**Not covered by this document (intentionally deferred):** detekt, konsist, CI config, git hooks, compose-desktop packaging, `js` target, `iosX64` target — all are in CONTEXT.md § Deferred Ideas.