# 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 `<application>` 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<PluginDependency>.asDependency(): Provider<String> =
    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<VersionCatalogsExtension>().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<VersionCatalogsExtension>().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<VersionCatalogsExtension>().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<VersionCatalogsExtension>().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<org.jetbrains.kotlin.gradle.tasks.KotlinCompilationTask<*>>().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<org.jetbrains.kotlin.gradle.plugin.mpp.KotlinNativeTarget>().configureEach {
        binaries.withType<org.jetbrains.kotlin.gradle.plugin.mpp.Framework>().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 `<KotlinFileName>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 `<application` tag — add `android:name=".MainApplication"`.

**After edit:**

```xml
<application
        android:name=".MainApplication"
        android:allowBackup="true"
        android:icon="@mipmap/ic_launcher"
        android:label="@string/app_name"
        android:roundIcon="@mipmap/ic_launcher_round"
        android:supportsRtl="true"
        android:theme="@android:style/Theme.Material.Light.NoActionBar">
```

---

### `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<X>()` 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<VersionCatalogsExtension>().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 `<KotlinFileName>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.
