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)
+
+
+
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
+
+
+
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
+
+
+
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)
+
+
+
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
+
+
+
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
+
+
+
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
+
+
+
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.