diff --git a/.planning/phases/01-project-infrastructure-module-wiring/01-RESEARCH.md b/.planning/phases/01-project-infrastructure-module-wiring/01-RESEARCH.md new file mode 100644 index 0000000..8e2d881 --- /dev/null +++ b/.planning/phases/01-project-infrastructure-module-wiring/01-RESEARCH.md @@ -0,0 +1,1330 @@ +# Phase 1: Project Infrastructure & Module Wiring - Research + +**Researched:** 2026-04-24 +**Domain:** Gradle build infrastructure for KMP + CMP + Ktor (iOS-primary, self-hosted server) +**Confidence:** HIGH + +## Summary + +Phase 1 is build-plumbing only — no feature logic, no auth, no domain tables. The risk is not "can we pick the right library?" (the stack is locked) but "will the plumbing we lay now be painful to unwind?" Four things have to be right on day 1: (a) iOS native binary flags (`objcDisposeOnMain`, `gc=cms`), (b) a clean split between the 5 convention plugins so `shared/` never pulls Compose and the server never pulls Android, (c) a version-catalog-only dependency surface so grep-for-version-literals returns nothing, and (d) Ktor booting against Postgres with Flyway already scheduled to run in Phase 3 without refactor. + +The single highest-leverage construct is the precompiled-script-plugin pattern in `build-logic/` — fine-grained (per D-06) so each module's `plugins { }` block reads as a role declaration. Accessing the version catalog from inside a precompiled plugin requires a deliberate extension trick (`extensions.getByType().named("libs")`) because the `libs` accessor is only auto-generated in module build scripts, not inside `build-logic/`. Everything else (Koin bootstrap, Kermit setup, `/health` route, docker-compose) is pattern-matching against well-documented idioms. + +**Primary recommendation:** Build `recipe.quality` first (smallest, testable in isolation), then `recipe.kotlin.multiplatform` (the dependency root), then the three leaf plugins (`compose.multiplatform`, `android.application`, `jvm.server`) in parallel. Wire each module's `build.gradle.kts` only after its convention plugins are green. Gate Phase 1 completion on `./gradlew build` producing an Android APK + iOS framework + server fat JAR with zero version literals outside the catalog. + + +## User Constraints (from CONTEXT.md) + +### Locked Decisions + +**Target matrix** +- **D-01:** Drop the `js` target from `composeApp` and `shared`. Keep `wasmJs` as the strategic future-web bet. +- **D-02:** Skip `iosX64`. User is on Apple Silicon; no Intel-Mac contributors anticipated. +- **D-03:** Keep `jvm` target in `composeApp` for Desktop — as a dev tool only (hot-reload). No Compose Desktop packaging; not a release surface. +- **D-04:** `shared/` ships the same target set as `composeApp`: `androidTarget, iosArm64, iosSimulatorArm64, jvm, wasmJs`. Plus `jvm` covers the server dependency. +- **D-05:** Final target matrix repo-wide: `androidTarget, iosArm64, iosSimulatorArm64, jvm (Desktop + Server), wasmJs`. + +**Convention plugins (build-logic/)** +- **D-06:** Fine-grained plugin split (5 plugins): `recipe.kotlin.multiplatform`, `recipe.compose.multiplatform`, `recipe.android.application`, `recipe.jvm.server`, `recipe.quality`. +- **D-07:** `recipe.kotlin.multiplatform` locks in the D-05 target set, JVM toolchain, framework basename convention (`ComposeApp` / `Shared`), and `kotlin-test` as a common-test dep. +- **D-08:** JVM toolchain is **JVM 21** for server, desktop, and `shared/jvm`. Android bytecode target stays **JVM 11** (Android 7 minSdk constraint per template). Document this split in convention plugin comments. +- **D-09:** **All library versions live in `gradle/libs.versions.toml`.** Hard rule: grep for a non-test version literal inside any `build.gradle.kts` returns zero matches. Plugin versions also routed through the catalog. + +**Code-quality toolchain** +- **D-10:** Minimal baseline — ktlint via **Spotless** only. Spotless handles Kotlin + Gradle files + markdown. Commands: `./gradlew spotlessCheck`, `./gradlew spotlessApply`. No Detekt, no Konsist in Phase 1. +- **D-11:** `allWarningsAsErrors = true` everywhere (configured in `recipe.kotlin.multiplatform`). +- **D-12:** `explicitApi()` **strict on `shared/` only**. Configured in `shared/build.gradle.kts` directly, not in the KMP plugin. +- **D-13:** **No git hooks.** `./gradlew check` is the local gate; CI gate deferred to Phase 11. + +**Phase 1 scope beyond the template** +- **D-14:** Koin bootstrap. Add Koin deps (`koin-core`, `koin-compose`, `koin-compose-viewmodel`) via `recipe.kotlin.multiplatform`. Ship an empty `appModule` in `composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/di/AppModule.kt`. +- **D-15:** Kermit logger bootstrap. Set a single top-level tag (`"recipe"`) during app init. +- **D-16:** Server: `/health` endpoint + Flyway scaffold + Postgres conn config. `GET /health` returns 200 with trivial JSON body. Flyway Gradle plugin + runtime dep wired; `src/main/resources/db/migration/` created empty. `application.conf` reads `DATABASE_URL`, `DATABASE_USER`, `DATABASE_PASSWORD` from env with localhost defaults. Server fails loudly if Postgres unreachable. +- **D-17:** `docker-compose.yml` at repo root defines `postgres:16` service with named volume. README gets a "Local development" section. + +**Infrastructure hygiene** +- **D-18:** iOS binary flags: `kotlin.native.binary.objcDisposeOnMain=false` and `kotlin.native.binary.gc=cms` in `gradle.properties`. +- **D-19:** `shared/commonMain` stays pure: domain models + `@Serializable` DTOs only; no Ktor, Compose, or SQLDelight imports. Phase 1 ships an empty package scaffold under `dev.ulfrx.recipe.shared`. +- **D-20:** Namespace `dev.ulfrx.recipe`. Framework basename `ComposeApp` for iOS. No feature modules in v1. + +### Claude's Discretion +- Exact ordering of plugin application inside each `build.gradle.kts`. +- Specific `spotless { kotlin { ktlint(...) } }` ruleset version (pick latest stable from catalog). +- Whether `application.conf` or `ApplicationConfig.kt` code owns env-var parsing. +- Flyway `cleanDisabled` and `baselineOnMigrate` flag choices (use sane defaults for dev). +- Whether Koin bootstrap in `MainViewController` uses `KoinApplication` vs `startKoin` (iOS-specific idiom). +- Whether `docker-compose.yml` uses a `.env` file or inlines localhost defaults. +- The exact sentinel JSON body for `/health` (empty object is fine). + +### Deferred Ideas (OUT OF SCOPE) +- **Detekt** — add only if code review starts missing bugs that Detekt would catch. +- **Konsist** — revisit ~Phase 4. +- **CI pipeline** — Phase 11. +- **Git hooks** — explicitly rejected. +- **`explicitApi` for composeApp/server** — rejected (app code, not library). +- **`iosX64` target** — rejected. +- **`js` target** — rejected. +- **Compose Desktop packaging (dmg/msi/exe)** — out of scope entirely. + + + +## Phase Requirements + +| ID | Description | Research Support | +|----|-------------|------------------| +| INFRA-01 | Gradle version catalog (`gradle/libs.versions.toml`) is the single source of truth for library versions | §Standard Stack table + version-lookup patterns for `libs.versions.X.get().toInt()` and `libs.plugins.X.get().pluginId` | +| INFRA-02 | `build-logic/` convention plugins centralize Kotlin/Compose/test configuration across modules | §Convention Plugin Mechanics + §Architecture Patterns with full skeleton for all 5 plugins | +| INFRA-03 | iOS Kotlin/Native binary options set from day 1: `kotlin.native.binary.objcDisposeOnMain=false`, `gc=cms` | §iOS Binary Flags with exact property keys, rationale, and verification procedure | +| INFRA-06 | `shared/commonMain` contains only domain models + API DTOs — no UI, no HTTP, no DB code | §Pattern: Shared module as a pure-Kotlin library + Anti-Patterns + verification grep | + + +## Project Constraints (from CLAUDE.md) + +The following CLAUDE.md conventions are load-bearing for Phase 1 and must be honored by all plans: + +- **#5 Exposed DSL only, never DAO.** Phase 1 doesn't wire Exposed at all, but the `recipe.jvm.server` plugin must not accidentally pull `exposed-dao` transitively (it won't if Exposed isn't added in Phase 1 — verified below). +- **#6 `newSuspendedTransaction` for every coroutine-touching handler.** Not applicable in Phase 1 (no DB operations yet) but the server plugin must not preclude it. +- **#7 iOS binary flags on day 1.** Directly addressed by D-18 / §iOS Binary Flags. +- **#8 `shared/commonMain` stays light.** Directly addressed by D-19 / INFRA-06. +- **#9 Strings externalized from day 1.** Phase 1 only needs the `composeApp` module to have Compose Resources wired (already present from template — `compose.components.resources` in `commonMain` deps). Real copy lands in Phase 11. + +## Architectural Responsibility Map + +Phase 1 ships infrastructure — not features — so the "tier ownership" exercise is about where each concern's **configuration** lives, not where business logic runs: + +| Capability | Primary Tier | Secondary Tier | Rationale | +|------------|-------------|----------------|-----------| +| Target matrix (KMP) | `recipe.kotlin.multiplatform` plugin | module `build.gradle.kts` | Plugin defines targets; module applies plugin; target-specific source sets live in the module. | +| Compose runtime wiring | `recipe.compose.multiplatform` plugin | `composeApp/build.gradle.kts` only | Server + shared must NOT get Compose; this plugin applies only to `composeApp`. | +| Android app shell | `recipe.android.application` plugin | `composeApp/build.gradle.kts` only | Namespace, SDK versions, application ID; shared is a KMP library (no `android-application`). | +| Server JVM + Ktor + Flyway | `recipe.jvm.server` plugin | `server/build.gradle.kts` only | Applies `kotlin("jvm")`, Ktor plugin, Flyway plugin; pulls only server-side deps. | +| Code quality (ktlint, warnings-as-errors) | `recipe.quality` plugin | every module | Cross-cutting; must be reusable across KMP/Android/JVM modules without breakage. | +| iOS native runtime flags | `gradle.properties` (project root) | — | Global K/N compiler options; cannot be set per-module. | +| Version constraints | `gradle/libs.versions.toml` | — | Single source of truth (D-09). Modules and convention plugins read from here. | +| Koin DI container | `composeApp` (`di/AppModule.kt`, `App()`, `MainViewController`) | — | Client-side DI only in Phase 1; server DI added in Phase 2+. | +| Kermit logger | `composeApp` init path | `shared/` (usage later) | Client-side logger; server uses Logback (already wired). | +| Ktor server module + `/health` | `server/src/main/kotlin/.../Application.kt` | `application.conf` | Routing lives in Kotlin; boot config in HOCON. | +| Database connection config | `application.conf` (HOCON env vars) | `ApplicationConfig.kt` reader | Env wins; HOCON provides localhost defaults matching docker-compose. | +| Local Postgres | `docker-compose.yml` (repo root) | `.env` file optional | Dev ergonomics only; homelab deploy is Phase 11. | + +## Standard Stack + +### Core (already in catalog — versions below are what's pinned in `libs.versions.toml`) +| Library | Version | Purpose | Why Standard | +|---------|---------|---------|--------------| +| Kotlin | 2.3.20 | Compiler / stdlib | [CITED: libs.versions.toml] — locked per PROJECT.md | +| AGP | 8.11.2 | Android build | [CITED: libs.versions.toml] — template default | +| Compose Multiplatform | 1.10.3 | UI framework | [CITED: libs.versions.toml] | +| Compose Hot Reload | 1.0.0 | Desktop dev iteration | [CITED: libs.versions.toml] — preserve existing wiring (commit c50d747) | +| Ktor | 3.4.1 | Server + Client | [CITED: libs.versions.toml] — locked per PROJECT.md | +| Kotlinx Coroutines | 1.10.2 | Async primitives | [CITED: libs.versions.toml] | +| Logback | 1.5.32 | Server logging | [CITED: libs.versions.toml] — already wired | + +### New additions for Phase 1 (to be added to catalog) +| Library | Version | Purpose | Source | +|---------|---------|---------|--------| +| Koin (BOM) | 4.2.1 | DI container | [VERIFIED: central.sonatype.com] — latest stable Apr 2026, supports Kotlin 2.3.x | +| Koin Compose Viewmodel | (via BOM) | CMP ViewModel integration | [CITED: Koin KMP setup docs] — required for `koinViewModel()` with Jetpack Nav CMP | +| Kermit | 2.1.0 | KMP logger | [VERIFIED: github.com/touchlab/Kermit/releases] — latest stable Mar 2025 | +| Spotless plugin | 8.4.0 | Formatter harness | [VERIFIED: web search 2026-03] — requires JRE 17+ (we use 21, fine) | +| Flyway plugin | 12.4.0 | DB migration runner | [VERIFIED: plugins.gradle.org 2026-04-14] — standard ID `org.flywaydb.flyway` | +| Flyway core | 12.4.0 | Runtime migrator | [VERIFIED: central.sonatype.com] — match plugin version | +| Flyway postgresql | 12.4.0 | Postgres dialect | [VERIFIED: Flyway docs] — required for Postgres 15+ support | +| PostgreSQL JDBC | 42.7.10 | JDBC driver (runtime) | [VERIFIED: mvnrepository.com] — current stable; Flyway pulls indirectly but we pin explicitly | +| kotlinx-serialization-json | 1.7.3+ | Ktor JSON content-negotiation | [VERIFIED: via ktor-serialization-kotlinx-json coords] — version bundled with Ktor 3.4.1 | +| ktor-server-content-negotiation | 3.4.1 | Content negotiation plugin | [VERIFIED: same as ktor version] — required for `/health` JSON | +| ktor-serialization-kotlinx-json | 3.4.1 | JSON serializer for Ktor | [VERIFIED: same as ktor version] | +| Postgres JDBC test driver | 42.7.10 | Server integration tests (Phase 3+) | Deferred — not required in Phase 1 | + +**Alternatives considered:** + +| Instead of | Could Use | Why not for v1 | +|------------|-----------|----------------| +| Spotless + ktlint | ktlint standalone plugin (`jlleitschuh.gradle.ktlint`) | Spotless covers Kotlin + Gradle KTS + markdown in one plugin; simpler config surface | +| Koin `KoinApplication` composable | `startKoin { }` at app entry | See §Koin Bootstrap below; both work but `startKoin` via top-level `initKoin()` is the Koin-docs-canonical pattern for KMP | +| Flyway via programmatic API only (no Gradle plugin) | Kept the Gradle plugin anyway | Plugin gives `./gradlew flywayInfo` / `flywayMigrate` for ops ergonomics; runtime `Flyway.configure().load().migrate()` still drives boot-time migration | +| `ktor-server-auto-head` / `ktor-server-call-logging` | skip in Phase 1 | Not needed for a single `/health` route; added incrementally in later phases | + +**Installation (new catalog entries — illustrative TOML fragments):** + +```toml +[versions] +koin = "4.2.1" +kermit = "2.1.0" +spotless = "8.4.0" +flyway = "12.4.0" +postgresql = "42.7.10" +kotlinx-serialization = "1.7.3" + +[libraries] +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" } +kermit = { module = "co.touchlab:kermit", version.ref = "kermit" } +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" } +ktor-serverConfigYaml = { module = "io.ktor:ktor-server-config-yaml-jvm", version.ref = "ktor" } # only if HOCON→YAML is preferred; see §Ktor config +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" } + +[plugins] +spotless = { id = "com.diffplug.spotless", version.ref = "spotless" } +flywayPlugin = { id = "org.flywaydb.flyway", version.ref = "flyway" } +``` + +**Version verification commands (Wave 0 — before locking):** + +```bash +# Verify each added version against current registry +curl -s 'https://repo1.maven.org/maven2/io/insert-koin/koin-bom/' | grep 'href="4\.' +curl -s 'https://repo1.maven.org/maven2/co/touchlab/kermit/' | grep 'href="2\.' +curl -s 'https://plugins.gradle.org/m2/com/diffplug/spotless/com.diffplug.spotless.gradle.plugin/' | grep 'href' +curl -s 'https://plugins.gradle.org/m2/org/flywaydb/flyway/org.flywaydb.flyway.gradle.plugin/' | grep 'href' +``` + +If any version is newer than what's listed above, use the newer stable and update this table. + +## Architecture Patterns + +### System Architecture Diagram + +``` +┌──────────────────────────────────────────────────────────────────┐ +│ gradle/libs.versions.toml (single source of truth, D-09) │ +└──────────────────────────────────────────────────────────────────┘ + │ + ▼ read by +┌──────────────────────────────────────────────────────────────────┐ +│ build-logic/ (included build: includeBuild("build-logic")) │ +│ ┌────────────────────────────────────────────────────────────┐ │ +│ │ recipe.quality.gradle.kts (Spotless + ktlint, │ │ +│ │ allWarningsAsErrors │ │ +│ │ — reusable everywhere) │ │ +│ └────────────────────────────────────────────────────────────┘ │ +│ ┌────────────────────────────────────────────────────────────┐ │ +│ │ recipe.kotlin.multiplatform.gradle.kts │ │ +│ │ Targets: androidTarget, iosArm64, iosSimulatorArm64, │ │ +│ │ jvm, wasmJs. JVM toolchain 21. │ │ +│ │ Deps: Koin BOM + koin-core (commonMain) │ │ +│ │ Kermit (commonMain) │ │ +│ │ kotlin-test (commonTest) │ │ +│ └────────────────────────────────────────────────────────────┘ │ +│ │ │ │ +│ ▼ ▼ │ +│ ┌────────────┐ ┌───────────────────────────┐ │ +│ │ recipe. │ │ recipe.compose. │ │ +│ │ android. │ │ multiplatform.gradle.kts │ │ +│ │ app │ │ (Compose plugin + │ │ +│ └────────────┘ │ hot-reload + │ │ +│ │ compose deps commonMain) │ │ +│ └───────────────────────────┘ │ +│ ┌────────────────────────────────────────────────────────────┐ │ +│ │ recipe.jvm.server.gradle.kts │ │ +│ │ kotlin("jvm") + Ktor plugin + Flyway plugin │ │ +│ │ Ktor server-core / netty / content-negotiation / json │ │ +│ │ Flyway core / postgresql + PostgreSQL JDBC driver │ │ +│ └────────────────────────────────────────────────────────────┘ │ +└──────────────────────────────────────────────────────────────────┘ + │ applied by + ▼ +┌────────────────┐ ┌────────────────┐ ┌────────────────┐ +│ composeApp/ │ │ shared/ │ │ server/ │ +│ plugins { │ │ plugins { │ │ plugins { │ +│ id("recipe. │ │ id("recipe. │ │ id("recipe. │ +│ kotlin.mp") │ │ kotlin.mp") │ │ jvm.server")│ +│ id("recipe. │ │ id("recipe. │ │ id("recipe. │ +│ compose.mp")│ │ quality") │ │ quality") │ +│ id("recipe. │ │ } │ │ } │ +│ android.app")│ │ kotlin{ │ │ (no Compose, │ +│ id("recipe. │ │ explicitApi() │ │ no Android) │ +│ quality") │ │ } │ │ │ +│ } │ └────────────────┘ └────────────────┘ +└────────────────┘ + │ + ▼ boots + ┌──────────────────────┐ + │ ApplicationKt.main() │ + │ → Flyway.migrate() │ ◄── fails loudly + │ → /health (JSON) │ if Postgres + │ → Netty :8080 │ unreachable + └──────────────────────┘ + │ + ▼ + ┌──────────────────────┐ + │ docker-compose.yml │ + │ postgres:16 │ + │ volume: pgdata │ + │ :5432 │ + └──────────────────────┘ +``` + +### Recommended Project Structure (Phase 1 adds the bolded items) + +``` +recipe/ +├── composeApp/ +│ └── src/commonMain/kotlin/dev/ulfrx/recipe/ +│ ├── App.kt # (exists) — add startKoin call +│ ├── di/ +│ │ └── AppModule.kt # NEW — empty module placeholder +│ └── logging/ +│ └── Logging.kt # NEW — `Logger.setTag("recipe")` +├── iosApp/iosApp/ +│ ├── iOSApp.swift # MODIFY — call KoinKt.doInitKoin() +│ └── ContentView.swift # (exists) — no change +├── server/ +│ ├── build.gradle.kts # REWRITE — applies recipe.jvm.server + quality +│ └── src/main/ +│ ├── kotlin/dev/ulfrx/recipe/ +│ │ ├── Application.kt # MODIFY — install ContentNegotiation, /health, Flyway boot +│ │ └── Database.kt # NEW — reads ApplicationConfig, runs Flyway.migrate() +│ └── resources/ +│ ├── application.conf # NEW — HOCON with ${?DATABASE_URL} env overrides +│ ├── logback.xml # (exists) +│ └── db/migration/ # NEW — empty dir, .gitkeep +├── shared/ +│ ├── build.gradle.kts # REWRITE — recipe.kotlin.mp + explicitApi() + quality +│ └── src/commonMain/kotlin/dev/ulfrx/recipe/shared/ # NEW pkg (empty) +├── build-logic/ # NEW — included build +│ ├── settings.gradle.kts # NEW +│ ├── build.gradle.kts # NEW — kotlin-dsl, kotlinGradlePlugin, agp, spotless-plugin as compileOnly +│ └── src/main/kotlin/ +│ ├── recipe.quality.gradle.kts # NEW +│ ├── recipe.kotlin.multiplatform.gradle.kts # NEW +│ ├── recipe.compose.multiplatform.gradle.kts # NEW +│ ├── recipe.android.application.gradle.kts # NEW +│ └── recipe.jvm.server.gradle.kts # NEW +├── gradle/ +│ └── libs.versions.toml # EXTEND — add Koin/Kermit/Spotless/Flyway/Postgres +├── gradle.properties # EXTEND — append 2 iOS binary flags +├── settings.gradle.kts # EXTEND — add includeBuild("build-logic") +├── docker-compose.yml # NEW — postgres:16 service +├── .env.example # NEW (if D-17 .env route chosen) +└── README.md # EXTEND — "Local development" section +``` + +### Pattern 1: build-logic/ as an included build (not a subproject) + +**What:** `build-logic/` is its own Gradle build, composed into the main build via `includeBuild("build-logic")` in `settings.gradle.kts`. Plugins are written as **precompiled script plugins**: a Kotlin file named `foo.bar.gradle.kts` under `build-logic/src/main/kotlin/` automatically becomes a plugin with ID `foo.bar`. + +**When to use:** Always for multi-module Kotlin/Android/KMP projects. Prefer precompiled `.gradle.kts` plugins over full `Plugin` classes unless you need parameterization — the DSL is identical to a `build.gradle.kts`, so your mental model transfers. + +**Example — `build-logic/settings.gradle.kts`** `[CITED: gradle best practices, VersionCatalogSample]`: + +```kotlin +dependencyResolutionManagement { + repositories { + google() + mavenCentral() + gradlePluginPortal() + } + versionCatalogs { + create("libs") { + from(files("../gradle/libs.versions.toml")) + } + } +} + +rootProject.name = "build-logic" +``` + +**Example — `build-logic/build.gradle.kts`:** + +```kotlin +plugins { + `kotlin-dsl` +} + +dependencies { + // These must be on the buildscript classpath so precompiled plugins + // can use `plugins { id("...") }` for alias-based IDs. + 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()) +} + +// Helper extension because plugin catalog entries have .pluginId but not .asDependency() directly +fun Provider.asDependency(): Provider = + map { "${it.pluginId}:${it.pluginId}.gradle.plugin:${it.version.requiredVersion}" } +``` + +Note: the `asDependency()` trick maps a catalog plugin entry to its marker artifact coordinates so `compileOnly()` can resolve it on the buildscript classpath. This is how precompiled plugins can write `plugins { id("org.jetbrains.kotlin.multiplatform") }` without an explicit version. + +### Pattern 2: Accessing the version catalog from inside a precompiled plugin + +**What:** The `libs.xxx.yyy` accessor is auto-generated only in module build scripts, not in precompiled plugins under `build-logic/`. Inside a precompiled plugin you must look up the catalog explicitly. `[CITED: Gradle docs — Using a catalog in buildSrc]` + +**Example — top of every precompiled plugin that reads versions:** + +```kotlin +import org.gradle.api.artifacts.VersionCatalogsExtension +import org.gradle.kotlin.dsl.getByType + +val libs = extensions.getByType().named("libs") + +// Now read versions/libraries/plugins: +val kotlinVersion = libs.findVersion("kotlin").get().toString() +val koinBom = libs.findLibrary("koin-bom").get() +val minSdk = libs.findVersion("android-minSdk").get().toString().toInt() +``` + +**Anti-pattern:** Writing `implementation(libs.koin.core)` inside a precompiled plugin — **does not compile**. Use `implementation(libs.findLibrary("koin-core").get())` instead. + +### Pattern 3: Applying another plugin by ID from inside a precompiled plugin + +**What:** Inside a precompiled `.gradle.kts` you can write a normal `plugins { id("...") }` block, but only for plugin IDs whose markers are on the buildscript classpath via `build-logic/build.gradle.kts` (Pattern 1). Use bare string IDs — catalog accessors (`libs.plugins.X`) are NOT available in precompiled plugins, same constraint as Pattern 2. + +**Example — `recipe.kotlin.multiplatform.gradle.kts` (excerpt):** + +```kotlin +plugins { + id("org.jetbrains.kotlin.multiplatform") +} + +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 + +val libs = extensions.getByType().named("libs") + +kotlin { + jvmToolchain(21) + + androidTarget { + compilerOptions { + jvmTarget.set(JvmTarget.JVM_11) // Android bytecode only (D-08) + } + } + + listOf(iosArm64(), iosSimulatorArm64()).forEach { iosTarget -> + iosTarget.binaries.framework { + baseName = "ComposeApp" // D-20; shared overrides to "Shared" + isStatic = true + } + } + + jvm { + compilerOptions { + jvmTarget.set(JvmTarget.JVM_21) // Server + Desktop (D-08) + } + } + + @OptIn(ExperimentalWasmDsl::class) + wasmJs { browser() } + + // D-11: warnings as errors at extension level + 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()) + } + } +} +``` + +### Pattern 4: Layering compose on top of kotlin-multiplatform + +**What:** `recipe.compose.multiplatform` applies itself **on top** of `recipe.kotlin.multiplatform` — it does NOT re-declare the KMP plugin, because a precompiled plugin applying another precompiled plugin is supported, but double-applying causes errors. `shared/` does not apply `recipe.compose.multiplatform`, so it never pulls Compose. + +**Example — `recipe.compose.multiplatform.gradle.kts`:** + +```kotlin +plugins { + id("recipe.kotlin.multiplatform") // assumes kotlin-mp is already configured + id("org.jetbrains.compose") + id("org.jetbrains.kotlin.plugin.compose") + id("org.jetbrains.compose.hot-reload") // preserve commit c50d747 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()) + } + } +} +``` + +**Why separate:** If Compose deps were inside `recipe.kotlin.multiplatform`, `shared/` would pull Compose — violating INFRA-06 / D-19. + +### Pattern 5: `recipe.quality` as the cross-cutting plugin + +```kotlin +plugins { + id("com.diffplug.spotless") +} + +spotless { + kotlin { + target("src/**/*.kt") + targetExclude("**/build/**", "**/generated/**") + ktlint() // pick up default version; bump via `.ktlint("1.x.y")` if needed + } + 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) + } +} +``` + +### Pattern 6: `recipe.android.application` — applied ONLY to `composeApp` + +```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" + 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 + } +} +``` + +**Anti-pattern:** Applying this to `shared/`. `shared/` is a KMP library — if it ever needs Android, it should apply `com.android.library` (and we'd build a `recipe.android.library` plugin). Phase 1 does not require this; `shared/` builds android-less through KMP's androidTarget only when a consumer (composeApp) applies the android-application plugin. (Note: the current template's `shared/build.gradle.kts` DOES apply `com.android.library` directly. Verify whether this is still needed after the refactor; if the shared Android target compiles via KMP + androidTarget alone, we can drop the android-library plugin. See §Open Questions.) + +### Pattern 7: `recipe.jvm.server` — Ktor + Flyway, no Compose/Android + +```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 plugin config — dev ergonomics only; runtime uses Flyway Java API in Application.kt +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 // safety: no accidental `flywayClean` in dev + baselineOnMigrate = true // tolerate an existing DB with no Flyway history + validateOnMigrate = true +} +``` + +**Note on `dependencies { "implementation"(...) }`:** inside precompiled plugins the named configurations aren't statically typed, so you must quote them. (In module `build.gradle.kts` files, `implementation(...)` is a typed method from the plugin's extension.) + +**Quoted configurations are a common footgun** — plan-checkers should verify these compile before Phase 1 sign-off. + +### Anti-Patterns to Avoid + +- **Putting Compose plugin ID inside `recipe.kotlin.multiplatform`:** breaks `shared/` (no Compose). Instead, layer `recipe.compose.multiplatform` on top (Pattern 4). +- **Applying `recipe.android.application` to `shared/`:** `shared/` is a library, not an app. The current template applies `com.android.library` directly in `shared/build.gradle.kts` — we may or may not need to keep that after refactor (see Open Questions). +- **Calling `startKoin` inside a `@Composable`:** composition-timed side effects race with UI rendering and panic on recomposition. Call at app entry, before composition starts (Android: `Application.onCreate()`; iOS: `iOSApp.init()`; Desktop: top of `main()` before `application { }`). +- **Adding Ktor Client, Compose, or SQLDelight deps to `shared/commonMain`:** violates D-19 / INFRA-06. Only `kotlinx-serialization` + `kotlinx-datetime` are allowed non-stdlib deps in `shared/`. (Phase 1 adds neither yet — `shared/commonMain` is truly empty beyond the placeholder package.) +- **Configuring `allWarningsAsErrors` via `kotlinOptions {}`:** deprecated in Kotlin 2.2+ (removed in 2.3). Use `compilerOptions { allWarningsAsErrors.set(true) }` at the `kotlin { }` extension level. [CITED: kotlinlang.org/docs/gradle-compiler-options.html] +- **Using deprecated `js()` target:** D-01 explicitly drops it; current `composeApp` and `shared` still reference it and must be removed. (Current files confirm `js { browser() }` blocks exist.) +- **Referencing `iosX64()`:** D-02 skips it; the current template doesn't reference it (verified in `composeApp/build.gradle.kts` and `shared/build.gradle.kts`), so this is a "don't add" guideline. +- **Calling `startKoin` twice on iOS:** if `iOSApp.init()` calls `doInitKoin()` AND `MainViewController` also calls `startKoin`, the second throws `KoinApplicationAlreadyStartedException`. Pick one call site (recommendation: `iOSApp.init()` — see §Koin Bootstrap). +- **Using the Flyway Gradle plugin for runtime migration at server boot:** the plugin is for ops ergonomics (CLI); runtime migration uses `Flyway.configure().dataSource(...).load().migrate()` in `Database.kt`. Mixing the two leads to "why didn't my migration run on boot?" debugging. +- **Using `transaction {}` in a coroutine / suspend context:** PITFALLS.md #5. Phase 1 doesn't touch DB yet, but the `recipe.jvm.server` plugin must not preclude using `newSuspendedTransaction` later — verify by NOT adding `exposed-dao` deps in Phase 1 (we don't; Exposed isn't added at all until Phase 3). + +## Don't Hand-Roll + +| Problem | Don't Build | Use Instead | Why | +|---------|-------------|-------------|-----| +| Multi-module Gradle conventions | Scripted copy-paste of `kotlin { }` blocks | `build-logic/` + precompiled `.gradle.kts` plugins | One file per module, DSL identical to build.gradle.kts, full IDE support | +| Version management | Hardcoded versions in each module | `gradle/libs.versions.toml` + catalog accessors | Single source of truth (INFRA-01 hard rule) | +| Kotlin formatter/linter harness | Script `ktlint` as a `Exec` task | Spotless plugin | Handles multi-file, editor-config, caching, `spotlessApply` fix command | +| DB migration runner | Hand-rolled version table + SQL runner | Flyway (core + postgres dialect + plugin) | Industry standard; auto-apply on boot; repair command for corruption | +| KMP DI container | Service-locator singletons | Koin | Explicit graph, `koinViewModel()` integration with Jetpack Nav CMP, no codegen | +| KMP logger | `println` + `NSLog` via `expect/actual` | Kermit | Platform-correct defaults; `withTag`; format control; same API everywhere | +| Plugin classpath for build-logic | Manually adding kgp/agp as `buildscript` classpath | `compileOnly(libs.plugins.X.asDependency())` in build-logic/build.gradle.kts | Plugin markers resolve the real JARs; version stays in catalog | +| Postgres local dev | "install Postgres via brew" instructions | `docker-compose up -d` | Works identically on macOS/Linux; clean teardown; version-pinned | +| iOS K/N GC tuning | Custom finalizer plumbing in Koin modules | Set `objcDisposeOnMain=false`, `gc=cms` in `gradle.properties` | Addresses PITFALL #1 once, for all iOS code in the repo | + +**Key insight:** every item above is "the plumbing everyone eventually rebuilds badly; starting with the library means you get the edge-case handling for free." + +## Runtime State Inventory + +Phase 1 scaffolds infrastructure; it does not rename or migrate. Still, because it modifies the template, I audit what persists: + +| Category | Items Found | Action Required | +|----------|-------------|------------------| +| Stored data | None — no DB tables exist yet; Postgres volume (`pgdata`) is created by docker-compose on first boot but carries no meaningful state until Phase 3. | None. Developers can `docker compose down -v` freely during Phase 1. | +| Live service config | None — the repo has no deployed services yet. Authentik is on user's homelab but untouched by Phase 1. | None. | +| OS-registered state | None — no Windows Task Scheduler / launchd / pm2 involvement. Hot-reload is a `./gradlew :composeApp:jvmRun -DmainClass=... --no-daemon` invocation, nothing persistent. | None. | +| Secrets/env vars | `DATABASE_URL`, `DATABASE_USER`, `DATABASE_PASSWORD` — introduced in Phase 1 via `application.conf`. Not secret in dev (localhost Postgres via docker-compose). No homelab secrets in Phase 1. | Document defaults in README "Local development". Real secrets Phase 11 (homelab deploy). | +| Build artifacts / installed packages | `~/.gradle/caches` pulls new artifacts (Koin, Kermit, Spotless, Flyway, Postgres JDBC). First `./gradlew build` after Phase 1 will download ~80 MB. Existing `build/` and `composeApp/build/Kotlin/` caches from the template may contain stale `js` target outputs (D-01 removes this target). | Developers should `./gradlew clean` once after Phase 1 to flush stale `js/` target outputs. Document this in the migration note. | + +**Canonical question** *— After every file in the repo is updated, what runtime systems still have the old string cached, stored, or registered?* + +Answer for Phase 1: **Build caches only.** A single `./gradlew clean` resolves it. No external systems are affected. + +## Common Pitfalls + +### Pitfall 1: precompiled plugin can't see `libs.xxx` accessor + +**What goes wrong:** You write `implementation(libs.koin.core)` inside a `build-logic/src/main/kotlin/recipe.foo.gradle.kts` file — compile error: "Unresolved reference: libs". +**Why it happens:** Type-safe catalog accessors are generated only for module build scripts, not for precompiled plugins. `[CITED: Gradle docs — Using a catalog in buildSrc]` +**How to avoid:** Use the explicit lookup pattern (`extensions.getByType().named("libs").findLibrary("koin-core").get()`). +**Warning signs:** First red squiggle when you start writing a convention plugin. + +### Pitfall 2: double-applying KMP plugin + +**What goes wrong:** `recipe.compose.multiplatform.gradle.kts` applies both `id("org.jetbrains.kotlin.multiplatform")` and `id("recipe.kotlin.multiplatform")` — Gradle error "Plugin X already applied". +**Why it happens:** Forgetting that `recipe.kotlin.multiplatform` already applies KMP. +**How to avoid:** Compose plugin applies only `id("recipe.kotlin.multiplatform")` (which internally applies KMP) plus the Compose-specific plugins. See Pattern 4. +**Warning signs:** Clear error message; easy to fix once seen. + +### Pitfall 3: forgot `allWarningsAsErrors` on a specific compilation + +**What goes wrong:** Setting `compilerOptions { allWarningsAsErrors = true }` at the `kotlin { }` extension level covers common, but a per-target override (e.g. `androidTarget { compilerOptions { ... } }`) can silently mask it. +**Why it happens:** Kotlin 2.x DSL inherits options top-down but any child `compilerOptions { }` block creates its own scope. +**How to avoid:** Set `allWarningsAsErrors.set(true)` at the `kotlin { compilerOptions { } }` extension level only; don't re-open per-target `compilerOptions { }` blocks unless setting target-specific things (like `jvmTarget`). The `recipe.quality` plugin also has a `tasks.withType>().configureEach { ... }` safety net. +**Warning signs:** Build passes despite a deprecation warning in some sourceSet. + +### Pitfall 4: Koin `startKoin` called twice on iOS + +**What goes wrong:** `iOSApp.swift` calls `KoinKt.doInitKoin()`, THEN `MainViewController()` calls `startKoin { modules(appModule) }` again → `KoinApplicationAlreadyStartedException` on second app launch (or on cold iOS re-entry). +**Why it happens:** Both Android and iOS samples in Koin docs show different entry points; it's easy to copy both. +**How to avoid:** Pick a single `initKoin()` helper in `commonMain`, called from ONE place per platform. Canonical pattern: + +```kotlin +// composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/di/Koin.kt +fun initKoin(config: KoinAppDeclaration? = null) = startKoin { + config?.invoke(this) + modules(appModule) +} +``` + +Then Android calls `initKoin { androidContext(applicationContext) }` from `Application.onCreate()` or `MainActivity.onCreate()`; iOS calls `initKoin()` from `KoinKt.doInitKoin()` in `iosMain`, invoked once in `iOSApp.init()`. `MainViewController()` does NOT start Koin — it assumes Koin is already started. `[CITED: insert-koin.io KMP setup docs]` +**Warning signs:** App crashes on second launch on iOS; works fine the first time after a clean install. + +### Pitfall 5: Ktor HOCON env-var syntax confusion + +**What goes wrong:** `port = ${?PORT}` behaves differently from `port = ${PORT:8080}`. The first makes the whole assignment disappear if `PORT` is unset (not what you want for defaults); the second fails at parse time if `PORT` is unset. +**Why it happens:** HOCON substitution semantics are non-obvious. `[VERIFIED: Ktor docs — heroku.md, sevalla.md, dokku.md]` +**How to avoid:** Use the two-line pattern for a default that can be overridden by env: + +```hocon +ktor { + deployment { + port = 8080 + port = ${?PORT} # if PORT is set, overrides; otherwise this line is a no-op + } +} + +database { + url = "jdbc:postgresql://localhost:5432/recipe" + url = ${?DATABASE_URL} + user = "recipe" + user = ${?DATABASE_USER} + password = "recipe" + password = ${?DATABASE_PASSWORD} +} +``` + +Read via `application.environment.config.propertyOrNull("database.url")?.getString()` in `Database.kt`. +**Warning signs:** Server crashes on startup with "Could not resolve substitution to a value" (fix: use `${?X}` form); or env override silently ignored (fix: the second line overrides the first only if env is set). + +### Pitfall 6: Flyway connects to Postgres at plugin-task time, not server-boot time + +**What goes wrong:** You run `./gradlew build` without Postgres running, and the build fails with a JDBC error — even though you didn't invoke `flywayMigrate`. +**Why it happens:** Some Flyway plugin versions evaluate the `flyway { ... }` block eagerly at configuration time. +**How to avoid:** Keep the Flyway plugin config simple and don't depend Flyway tasks on `classes`/`build`. Use the plugin *only* for CLI tasks (`./gradlew flywayInfo`, `./gradlew flywayMigrate`). The runtime migration path is through `Flyway.configure().dataSource(...).load().migrate()` in `Database.kt`. D-17's docker-compose documents starting Postgres before running the server; CI in Phase 11 will bring its own Postgres. +**Warning signs:** `./gradlew build` fails with JDBC connection refused despite not targeting Flyway. + +### Pitfall 7: Kotlin 2.x `compilerOptions` vs `kotlinOptions` + +**What goes wrong:** Copy-pasting `kotlinOptions { jvmTarget = "21" }` from an older tutorial — deprecation warning under Kotlin 2.x, fails the build under D-11 (`allWarningsAsErrors`). +**Why it happens:** Legacy DSL still "works" but is deprecated. +**How to avoid:** Always write `compilerOptions { jvmTarget.set(JvmTarget.JVM_21) }` (note `.set()` — it's a Provider property). `[CITED: kotlinlang.org/docs/gradle-compiler-options.html]` +**Warning signs:** Deprecation message: "The 'kotlinOptions' DSL is deprecated." + +### Pitfall 8: Wasm + Koin init order + +**What goes wrong:** `wasmJsMain` doesn't have an `iOSApp`-style init hook; `ComposeViewport { App() }` runs composition immediately. If Koin isn't started before `App()` composes, `koinViewModel()` throws. +**Why it happens:** The template's `webMain/main.kt` enters composition directly. +**How to avoid:** Call `initKoin()` at the top of the Wasm `main()`: + +```kotlin +fun main() { + initKoin() + ComposeViewport { App() } +} +``` + +Phase 1 doesn't ship any ViewModels yet, so this is a future-proofing note — but since D-01 keeps `wasmJs` and `composeApp/src/webMain/main.kt` exists, we should add it now to avoid "it broke silently in Phase 5" discoveries. +**Warning signs:** First Koin usage in wasmJs throws `NoDefinitionFoundException` despite `appModule` being correct. + +### Pitfall 9: `includeBuild` order in `settings.gradle.kts` + +**What goes wrong:** `includeBuild("build-logic")` is placed inside `dependencyResolutionManagement { }` instead of `pluginManagement { }`; the main project can't find `recipe.*` plugin IDs. +**Why it happens:** `includeBuild` inside `pluginManagement` makes the included build's plugins available to the root project's `plugins { }` blocks. Inside `dependencyResolutionManagement` it affects dependency resolution instead. +**How to avoid:** Place `includeBuild("build-logic")` inside `pluginManagement { }` at the top of `settings.gradle.kts`: + +```kotlin +pluginManagement { + includeBuild("build-logic") + repositories { + google() + mavenCentral() + gradlePluginPortal() + } +} +``` + +**Warning signs:** "Plugin [id: 'recipe.kotlin.multiplatform'] was not found" — almost always this. + +### Pitfall 10: iOS framework basename mismatch + +**What goes wrong:** `baseName = "ComposeApp"` in `composeApp` (D-20) but `baseName = "shared"` (template default) in `shared/`. Xcode imports both as `import ComposeApp` and `import shared` — works but inconsistent; if shared later gains its own framework publication, name casing will trip developers. +**Why it happens:** The template defaults to lowercase "shared". +**How to avoid:** Since `shared/` in this project is NOT published as its own iOS framework (it's a dependency of `composeApp`'s framework, compiled in), its `baseName` is irrelevant — but set it to `"Shared"` anyway for consistency, per D-07. The composeApp framework `ComposeApp` re-exports shared symbols automatically. +**Warning signs:** Xcode `import` statements inconsistent casing. + +## Code Examples + +### Convention plugin: `recipe.kotlin.multiplatform.gradle.kts` (full) + +```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; everything else is JVM 21 (D-08). + +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" // modules override (shared → "Shared") + isStatic = true + } + } + + jvm { + compilerOptions { + jvmTarget.set(JvmTarget.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()) + } + } +} +``` + +### Koin bootstrap: `initKoin()` in commonMain, called once per platform + +```kotlin +// composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/di/Koin.kt +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) +} + +// composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/di/AppModule.kt +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 +} +``` + +**iOS-side wrapper** `[CITED: insert-koin.io KMP setup docs]`: + +```kotlin +// composeApp/src/iosMain/kotlin/dev/ulfrx/recipe/di/KoinIos.kt +package dev.ulfrx.recipe.di + +fun doInitKoin() { initKoin() } +``` + +**Swift call site** `[CITED: insert-koin.io cmp.md]`: + +```swift +// iosApp/iosApp/iOSApp.swift +import SwiftUI +import ComposeApp + +@main +struct iOSApp: App { + init() { + KoinIosKt.doInitKoin() // Kotlin fun `doInitKoin` → generated Swift symbol KoinIosKt.doInitKoin + } + + var body: some Scene { + WindowGroup { + ContentView() + } + } +} +``` + +**Android call site:** + +```kotlin +// composeApp/src/androidMain/kotlin/dev/ulfrx/recipe/MainApplication.kt +package dev.ulfrx.recipe + +import android.app.Application +import dev.ulfrx.recipe.di.initKoin +import org.koin.android.ext.koin.androidContext + +class MainApplication : Application() { + override fun onCreate() { + super.onCreate() + initKoin { + androidContext(this@MainApplication) + } + } +} +``` + +Then `AndroidManifest.xml` gains `android:name=".MainApplication"` on the `` tag. + +**Desktop + Wasm call sites** (top of `main()` before composition): + +```kotlin +// composeApp/src/jvmMain/kotlin/dev/ulfrx/recipe/main.kt +fun main() { + initKoin() + application { + Window(onCloseRequest = ::exitApplication, title = "recipe") { App() } + } +} + +// composeApp/src/webMain/kotlin/dev/ulfrx/recipe/main.kt +fun main() { + initKoin() + ComposeViewport { App() } +} +``` + +### Kermit bootstrap: set tag once, BEFORE Koin + +```kotlin +// composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/logging/Logging.kt +package dev.ulfrx.recipe.logging + +import co.touchlab.kermit.Logger + +fun configureLogging() { + Logger.setTag("recipe") + // Platform-specific log writers (OSLog on iOS, LogCat on Android, System.out on JVM) + // are installed automatically by Kermit's default Logger setup. +} +``` + +Call `configureLogging()` at the **very top** of `initKoin()` (or each platform `main()`/`Application.onCreate()`/`iOSApp.init()`) so logging is available inside Koin module loading itself. Order: `configureLogging() → initKoin() → composition`. + +### Ktor `/health` + `ContentNegotiation` + JSON + +```kotlin +// server/src/main/kotlin/dev/ulfrx/recipe/Application.kt +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")) + } + } +} +``` + +### `Database.kt` — fail-loud Postgres + Flyway boot + +```kotlin +// server/src/main/kotlin/dev/ulfrx/recipe/Database.kt +package dev.ulfrx.recipe + +import co.touchlab.kermit.Logger +import io.ktor.server.application.Application +import org.flywaydb.core.Flyway + +object Database { + private val log = Logger.withTag("Database") + + 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.i { "Connecting to $url as $user and running Flyway migrations" } + + runCatching { + Flyway.configure() + .dataSource(url, user, password) + .locations("classpath:db/migration") + .baselineOnMigrate(true) + .validateOnMigrate(true) + .cleanDisabled(true) + .load() + .migrate() + }.onFailure { ex -> + log.e(ex) { "Flyway migration failed — cannot start server" } + throw IllegalStateException("Database unreachable or migration failed", ex) + } + } +} +``` + +Note: server uses **Logback** (already wired) not Kermit — Kermit is the client-side logger. Kept for consistency at the API level but server logs go through SLF4J/Logback. For the `Database` object, if Kermit isn't set up on the server side, substitute `org.slf4j.LoggerFactory.getLogger(...)` — recommend using SLF4J on the server throughout, keeping Kermit for `composeApp`/`shared`. + +Decision recommendation: **Server uses SLF4J+Logback; client uses Kermit.** Kermit's server-side JVM log writer exists but adds no value over the Logback stack already present. + +### `application.conf` — HOCON with env overrides + +```hocon +// server/src/main/resources/application.conf +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} +} +``` + +### `docker-compose.yml` + +```yaml +# repo root docker-compose.yml +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: +``` + +**Recommendation on `.env` vs inline:** inline is fine for single-dev + matching application.conf defaults. `.env` adds a file to `.gitignore` and an `.env.example`; more surface area without much benefit for a 2-person project. Deferring `.env` is safe; revisit when a second environment (staging) appears. + +### `gradle.properties` — iOS binary flags (D-18) + +```properties +# Kotlin +kotlin.code.style=official +kotlin.daemon.jvmargs=-Xmx3072M + +# Gradle +org.gradle.jvmargs=-Xmx4096M -Dfile.encoding=UTF-8 +org.gradle.configuration-cache=true +org.gradle.caching=true + +# Android +android.nonTransitiveRClass=true +android.useAndroidX=true + +# Kotlin/Native iOS (PITFALLS.md #1; D-18; INFRA-03) — MANDATORY day 1 +# CMS GC: reduces pause spikes on UI-heavy iOS apps (Compose Multiplatform) +kotlin.native.binary.gc=cms +# Prevents Obj-C deinit from blocking the main thread — ships deinit to a special GC thread +kotlin.native.binary.objcDisposeOnMain=false +``` + +**Verification of the two flags** `[CITED: kotlinlang.org/docs/native-binary-options.html]`: + +- Run `./gradlew :composeApp:linkDebugFrameworkIosSimulatorArm64 --info` and grep the output for `objcDisposeOnMain` and `gc=cms` — Kotlin's K/N link step echoes the binary options it's compiling with. +- On simulator launch, absence of warnings like "legacy memory manager" or "freeze()" deprecations confirms the new MM is active. + +## State of the Art + +| Old Approach | Current Approach | When Changed | Impact | +|--------------|------------------|--------------|--------| +| `kotlinOptions { jvmTarget = "11" }` | `compilerOptions { jvmTarget.set(JvmTarget.JVM_11) }` | Kotlin 2.2 (deprecated), 2.3 (still works but warns) | D-11 treats warnings as errors, so must migrate | +| `freeze()` / `@SharedImmutable` / `kotlin.native.concurrent.AtomicReference` | new K/N memory manager (default since 1.9); StateFlow is thread-safe | Kotlin 1.9 | PITFALLS.md #2 — old tutorials still show this, reject them | +| `kotlin.native.binary.memoryModel=experimental` | No flag needed; new MM is default | Kotlin 1.9 | Don't re-add legacy flag | +| `js { browser() }` target for web | `wasmJs { browser() }` | CMP 1.6+ | D-01 drops `js` entirely | +| `build-src/` directory | `build-logic/` as included build via `includeBuild` | Gradle 7+ | Cleaner build isolation, faster rebuilds | +| Flyway 9.x `flyway-core` only | Flyway 12.x splits Postgres dialect into `flyway-database-postgresql` artifact | Flyway 10 | Must add `flyway-database-postgresql` for Postgres 15+ | +| `io.ktor:ktor-serialization:...` | `io.ktor:ktor-serialization-kotlinx-json-jvm:...` | Ktor 3.x | Separate content-negotiation plugin + serializer plugin | +| Compose hot reload via third-party plugin | `org.jetbrains.compose.hot-reload` (JetBrains) | CMP 1.9+ | Already in catalog (template commit c50d747) | + +**Deprecated/outdated:** + +- `kotlinOptions { }` block — replaced by `compilerOptions { }` with Provider properties +- `iosX64()` target — D-02 rejects; Apple Silicon only +- `js()` target — D-01 rejects; wasmJs covers the niche +- `freeze()` / `@SharedImmutable` / old K/N memory-management APIs — all no-ops or deprecated under Kotlin 2.x + +## Assumptions Log + +| # | Claim | Section | Risk if Wrong | +|---|-------|---------|---------------| +| A1 | Koin 4.2.1 (BOM) is the current stable and works under Kotlin 2.3.20 + CMP 1.10.3 | Standard Stack | If 4.2.x has a KMP regression with CMP 1.10.3, need to pin to last known-good 4.1.x until resolved. Verify by `./gradlew build` after bumping; mitigation is easy (single version.ref change). | +| A2 | Flyway 12.4.0's programmatic API signature (`Flyway.configure().dataSource(...).load().migrate()`) is stable and compatible with Postgres 16 | Code Examples: Database.kt | Low — API hasn't changed in years. Doc [CITED: Flyway API (Java).md]. | +| A3 | `baselineOnMigrate = true` is the right default for a dev environment with a fresh DB (no-op) and a homelab prod with pre-existing tables in Phase 11 | Pattern 7; Database.kt | Low — this is the recommended setting for projects adopting Flyway partway; we're adopting from day 1, so it's technically unnecessary but defensive. | +| A4 | The current template's `shared/build.gradle.kts` applies `com.android.library` because Android cross-compilation needs it; we may drop it if `androidTarget { }` inside KMP is sufficient | Anti-Patterns (android.application to shared) | Medium — dropping android-library might break `:shared:androidDebugLibrary` consumption. Keep applying it through a future `recipe.android.library` plugin (deferred beyond Phase 1) OR keep the existing direct application in `shared/build.gradle.kts` for now. Research inconclusive; **recommend keeping direct application until tested**. | +| A5 | The "Kermit for client, SLF4J/Logback for server" split is preferable to Kermit on both | Pattern 6; Database.kt | Low — Kermit-JVM works on the server, just adds a dep. The split is a style call, not a correctness issue. | +| A6 | Hot-reload wiring (commit c50d747) continues to work after refactor into `recipe.compose.multiplatform` | Pattern 4 | Medium — if the plugin's `id("org.jetbrains.compose.hot-reload")` must be applied AFTER `id("org.jetbrains.compose")`, the order inside the precompiled plugin matters. Verify by `./gradlew :composeApp:jvmRun` producing a hot-reloadable Desktop window after Phase 1. | +| A7 | Postgres JDBC 42.7.10 works under JVM 21 with Postgres 16 | Standard Stack | Low — well-documented. | +| A8 | `explicitApi()` in `shared/build.gradle.kts` will not force the current empty `Greeting`/`Platform` classes to add visibility modifiers — they're already `class` (public by default in Kotlin, so no changes) | Pattern 4 / shared configuration | Low — current `shared/src/commonMain/.../Greeting.kt` uses default-public classes, which explicitApi() accepts. Verify by running `./gradlew :shared:build` after enabling. | + +**If this table is non-empty:** Planner and `/gsd-discuss-phase` already ran; these assumptions are residual and should be verified during implementation, not re-surfaced. Items A4 and A6 are the ones most likely to surprise — include in Wave 0 smoke checks. + +## Open Questions + +1. **Should `shared/` keep `com.android.library` directly applied, or rely on `androidTarget` in the `recipe.kotlin.multiplatform` plugin alone?** + - What we know: Current template applies `com.android.library` directly. KMP's `androidTarget { }` declares the Android target but doesn't strictly require the android-library plugin for every module — sometimes it does. + - What's unclear: Whether dropping `com.android.library` from `shared/` breaks the composeApp Android consumer. + - Recommendation: **Keep `com.android.library` applied in `shared/build.gradle.kts` directly in Phase 1.** Build a `recipe.android.library` convention plugin in a future phase if the direct application becomes a pattern. Don't block Phase 1 on this refactor. + +2. **Does `./gradlew build` invoke `flywayMigrate`? Should it?** + - What we know: Flyway plugin exposes `flywayMigrate`, `flywayInfo`, etc. as tasks; it does NOT hook them into `build` by default. + - What's unclear: Nothing — this is a choice. + - Recommendation: **Do NOT wire Flyway tasks into `build` in Phase 1.** Migration is a server-boot concern; the plugin is for CLI ops (developer runs `./gradlew flywayInfo` manually to inspect state). CI integration lands in Phase 11. + +3. **Should we add `ktor-server-config-yaml` for a `application.yaml` alternative to HOCON?** + - What we know: Ktor 3.x supports YAML config via the `ktor-server-config-yaml` artifact; HOCON remains the default. + - What's unclear: Team preference. + - Recommendation: **Stick with HOCON.** Our server dev is Kotlin/Ktor background (user profile) and HOCON is the historically canonical Ktor config. YAML is a nice-to-have, not worth the added dep. + +4. **How to verify iOS binary flags take effect without shipping a build to hardware?** + - What we know: Simulator launch eliminates most of the visible symptoms of PITFALL #1; Instruments on a real device would be the gold standard. + - What's unclear: Whether simulator-level verification is sufficient for Phase 1 sign-off. + - Recommendation: **Verify at two levels:** (a) grep `gradle.properties` for the two flags (trivial but catches omission); (b) build the iOS framework and capture the Kotlin/Native link log for a line showing the GC + objcDisposeOnMain options. Real-device verification under Instruments is deferred to Phase 10 (UI chrome) when there's meaningful UI work to stress-test. + +5. **Does `recipe.quality` need a `targetExclude` for generated Compose Resources code?** + - What we know: Compose Multiplatform generates `Res.kt` under `build/generated/compose/resourceGenerator/...`. + - What's unclear: Whether Spotless/ktlint visit `build/` by default (they shouldn't, but worth confirming). + - Recommendation: **Add `targetExclude("**/build/**", "**/generated/**")` explicitly in the Spotless config** (already in Pattern 5 example) to future-proof against any `.kt` file landing in those paths. + +## Environment Availability + +| Dependency | Required By | Available | Version | Fallback | +|------------|------------|-----------|---------|----------| +| Java / JDK | All Gradle builds | ✓ | OpenJDK 25.0.2 | — (JDK 21 toolchain resolved via Foojay if project JDK differs) | +| Docker | D-17 docker-compose / Postgres local | ✓ | 27.3.1 | — | +| Docker Compose | D-17 | ✓ | v2.40.0-desktop.1 | `docker compose` (v2 as subcommand) works identically | +| Xcode + iOS SDK | iOS framework build, simulator verification | ❓ (not probed — user on macOS; assume present given iOS-primary target) | — | If missing, developer installs from App Store before Phase 1 Wave 2 (iOS tasks) | +| Gradle daemon | All builds | Implicit (bundled with wrapper) | 8.x (from wrapper) | — | +| npx (for ctx7 docs lookups) | Research only, not build | ✓ (npm present) | — | — | +| Node / npm | Not required at build time | — | — | Project is pure Kotlin; no Node | +| Homebrew | Not required | — | — | — | + +**Missing dependencies with no fallback:** None detected. Xcode is assumed but not probed; if missing, the iOS framework build will fail with a clear error at Phase 1 Wave 2 (iOS task execution). + +**Missing dependencies with fallback:** None. + +## Validation Architecture + +Phase 1 success is defined by 5 success criteria in ROADMAP.md (SC1–SC5) and 4 phase requirements (INFRA-01/02/03/06). Each maps to a specific verification command. + +### Test Framework + +| Property | Value | +|----------|-------| +| Framework | kotlin.test (commonTest) + JUnit 4 (server test via `ktor-server-test-host`) + existing template tests | +| Config file | `composeApp/src/commonTest/kotlin/ComposeAppCommonTest.kt`, `shared/src/commonTest/kotlin/SharedCommonTest.kt`, `server/src/test/kotlin/dev/ulfrx/recipe/ApplicationTest.kt` (all present) | +| Quick run command | `./gradlew :server:test :composeApp:jvmTest :shared:jvmTest` (JVM-only subset — <30s) | +| Full suite command | `./gradlew check` (runs `spotlessCheck` + all test tasks across all targets) | + +Phase 1 primarily adds **build-level** verification (Gradle tasks succeed, file structure correct, version literals absent) rather than **unit tests**. The existing `ApplicationTest.kt` is updated to cover the `/health` endpoint. + +### Phase Requirements → Test Map + +| Req ID | Behavior | Test Type | Automated Command | File Exists? | +|--------|----------|-----------|-------------------|-------------| +| INFRA-01 | No version literals in any `build.gradle.kts` | shell grep | `! grep -rE '(version[[:space:]]*=[[:space:]]*"[0-9]\|"[0-9]+\.[0-9])' composeApp/build.gradle.kts server/build.gradle.kts shared/build.gradle.kts build-logic/src/main/kotlin/` | ❌ Wave 0 (script `tools/verify-no-version-literals.sh`) | +| INFRA-01 | `gradle/libs.versions.toml` is single source of truth | manual visual + grep | `grep -rE "libs\\.(versions\|plugins\|bundles)" build-logic/src/main/kotlin/` returns all version lookups | ✅ existing catalog | +| INFRA-02 | Convention plugins apply without duplication or errors | Gradle build | `./gradlew :composeApp:help :server:help :shared:help` (each emits applied-plugins section including recipe.*) | ❌ Wave 0 (plugins don't exist yet) | +| INFRA-02 | Adding a new KMP module only needs `id("recipe.kotlin.multiplatform")` | visual review of plugin | Demonstrated by the refactored `shared/build.gradle.kts` being ≤15 lines after refactor | Target for Wave 2 | +| INFRA-03 | `gradle.properties` contains the two iOS flags | grep | `grep -E '^kotlin\\.native\\.binary\\.(gc=cms\|objcDisposeOnMain=false)$' gradle.properties \| wc -l` returns `2` | ❌ Wave 0 | +| INFRA-03 | iOS simulator build boots without legacy memory-manager warnings | build log inspection | `./gradlew :composeApp:linkDebugFrameworkIosSimulatorArm64 --info 2>&1 \| grep -i 'legacy\|freeze' \| grep -v '^$'` returns empty | Target for Wave 2 (iOS tasks) | +| INFRA-06 | `shared/commonMain` has no Ktor/Compose/SQLDelight imports | grep | `! grep -rE '^import (io\\.ktor\|androidx\\.compose\|org\\.jetbrains\\.compose\|app\\.cash\\.sqldelight)' shared/src/commonMain/kotlin/` | ❌ Wave 0 (script `tools/verify-shared-pure.sh`) | +| INFRA-06 | `shared/` package scaffold exists | file existence | `test -d shared/src/commonMain/kotlin/dev/ulfrx/recipe/shared` | Target for Wave 2 | + +### Success Criteria → Test Map (SC1–SC5 from ROADMAP.md) + +| SC | Success Statement | Automated Command | Pass Criteria | +|----|-------------------|-------------------|---------------| +| SC1 | `./gradlew build` succeeds across composeApp, server, shared; produces iOS framework and Android APK | `./gradlew build` then `test -f composeApp/build/outputs/apk/debug/composeApp-debug.apk && test -d composeApp/build/bin/iosSimulatorArm64/debugFramework/ComposeApp.framework` | exit 0 + both files/dirs present | +| SC2 | No version literals in any build.gradle.kts | `tools/verify-no-version-literals.sh` | exit 0 | +| SC3 | iOS `gradle.properties` carries the two flags; simulator debug launch has no legacy-MM warnings | `tools/verify-ios-flags.sh` (grep gradle.properties) + optional simulator boot | both flags present; simulator warning grep empty | +| SC4 | build-logic convention plugins apply to every module | `./gradlew :composeApp:help -q \| grep 'recipe.kotlin.multiplatform'` etc. | each plugin shows in its applicable modules' help output | +| SC5 | `shared/commonMain` contains only domain models + DTOs | `tools/verify-shared-pure.sh` | exit 0 | + +Additional acceptance beyond ROADMAP SC list: + +| Check | Automated Command | Pass Criteria | +|-------|-------------------|---------------| +| Server `/health` returns 200 JSON `{"status":"ok"}` | `docker compose up -d postgres && ./gradlew :server:run &; sleep 5; curl -s http://localhost:8080/health \| grep -o '"status":"ok"'; kill %1` | curl returns expected substring | +| Server fails loudly if Postgres missing | `docker compose down; ./gradlew :server:run` | server exits non-zero within ~10s with "Database unreachable" in logs | +| Spotless formatting clean | `./gradlew spotlessCheck` | exit 0 | +| `./gradlew check` runs full suite | `./gradlew check` | exit 0 | +| Koin starts without error in JVM target | `./gradlew :composeApp:jvmTest` (existing template test runs composition path) | exit 0; no KoinApplicationAlreadyStartedException | + +### Sampling Rate + +- **Per task commit:** `./gradlew spotlessCheck :server:test :shared:jvmTest` (fast subset, <30s) +- **Per wave merge:** `./gradlew build` (full build including iOS framework link and Android APK) +- **Phase gate:** `./gradlew check` + manual server `/health` curl + iOS simulator boot verification + +### Wave 0 Gaps + +- [ ] `tools/verify-no-version-literals.sh` — shell script grepping for version literals outside catalog +- [ ] `tools/verify-shared-pure.sh` — shell script grepping for forbidden imports in shared/commonMain +- [ ] `tools/verify-ios-flags.sh` — shell script grepping gradle.properties for the two K/N flags +- [ ] `build-logic/` directory scaffold with 5 empty placeholder `.gradle.kts` files +- [ ] `server/src/main/resources/application.conf` (does not exist yet) +- [ ] `server/src/main/resources/db/migration/.gitkeep` (directory placeholder) +- [ ] `docker-compose.yml` at repo root +- [ ] Extended `ApplicationTest.kt` covering `/health` endpoint +- [ ] `composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/di/Koin.kt` + `AppModule.kt` +- [ ] `composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/logging/Logging.kt` +- [ ] `composeApp/src/androidMain/kotlin/dev/ulfrx/recipe/MainApplication.kt` + `AndroidManifest.xml` registration +- [ ] `composeApp/src/iosMain/kotlin/dev/ulfrx/recipe/di/KoinIos.kt` +- [ ] `iosApp/iosApp/iOSApp.swift` — modify to call `KoinIosKt.doInitKoin()` + +## Security Domain + +Phase 1 is infrastructure-only — no authentication, no user data, no network-facing multi-tenant endpoints. The `/health` route is unauthenticated by design (observability); it reveals only server liveness, not implementation detail. + +### Applicable ASVS Categories + +| ASVS Category | Applies | Standard Control | +|---------------|---------|-----------------| +| V2 Authentication | no (Phase 2) | ktor-server-auth-jwt (Phase 2) | +| V3 Session Management | no (Phase 2) | — | +| V4 Access Control | no (Phase 3) | household scoping (Phase 3) | +| V5 Input Validation | no (no request bodies yet) | kotlinx.serialization validation (future) | +| V6 Cryptography | no | — | +| V7 Error Handling | partial | Database fails loudly with opaque message (no stack trace in HTTP response) | +| V8 Data Protection | partial | `.env` / `application.conf` defaults use non-secret localhost creds; never check real secrets into git | +| V12 API Security | n/a | `/health` is the only endpoint, intentionally unauthenticated | +| V14 Configuration | yes | HOCON env-var overrides (`${?DATABASE_URL}`) ensure production creds come from environment, not from git | + +### Known Threat Patterns for this stack (Phase 1 subset) + +| Pattern | STRIDE | Standard Mitigation | +|---------|--------|---------------------| +| Secret in `application.conf` committed to git | Information Disclosure | Defaults must be non-secret (`recipe/recipe/recipe` localhost only). Real secrets arrive via env vars in Phase 11. Add `*.env` to `.gitignore` if `.env` route chosen. | +| Flyway clean wiping prod data | Destruction / Tampering | `cleanDisabled = true` in both plugin config and `Database.kt` programmatic call (Pattern 7 + Code Examples). | +| Unauthenticated `/health` leaking runtime details | Information Disclosure | Body is `{"status":"ok"}` only — no version, no commit hash, no uptime. (Leave build identifiers out until Phase 11.) | +| Postgres port 5432 exposed on 0.0.0.0 | Exposure | `docker-compose.yml` binds `5432:5432` on host; document in README that this is dev-local only, firewall required for any accidental multi-host use. | + +Other security threats (auth bypass, SQL injection, CSRF, XSS) have no surface area in Phase 1 and are deferred to the phases that introduce them. + +## Sources + +### Primary (HIGH confidence) +- [Koin KMP setup docs (Context7 /insertkoinio/koin)](https://github.com/insertkoinio/koin/blob/main/docs/reference/koin-core/kmp-setup.md) — Koin BOM coords, `initKoin()` pattern, iOS SwiftUI entry point +- [Koin CMP quickstart](https://github.com/insertkoinio/koin/blob/main/docs/quickstart/cmp.md) — `KoinKt.doInitKoin()` Swift wrapper +- [Kermit docs (Context7 /touchlab/kermit)](https://github.com/touchlab/kermit/blob/main/website/docs/configuration/LOGGER_SETUP.md) — `Logger.setTag("...")` global config +- [Ktor Content Negotiation + kotlinx.serialization](https://github.com/ktorio/ktor-documentation/blob/main/topics/server-testing.md) — exact `install(ContentNegotiation) { json() }` pattern +- [Ktor HOCON env-var config (Context7)](https://github.com/ktorio/ktor-documentation/blob/main/topics/heroku.md) — `port = 8080; port = ${?PORT}` pattern for fallback defaults +- [Gradle version catalogs from buildSrc / build-logic](https://github.com/gradle/gradle/blob/master/platforms/documentation/docs/src/docs/userguide/reference/dependency-management/centralizing-dependencies/version_catalogs.adoc) — `VersionCatalogsExtension` access pattern +- [Gradle precompiled script plugins](https://github.com/gradle/gradle/blob/master/platforms/documentation/docs/src/docs/userguide/reference/plugin-development/implementing_gradle_plugins_precompiled.adoc) — `.gradle.kts` plugin authoring +- [Flyway programmatic Java API](https://github.com/flyway/flyway/blob/main/documentation/Reference/Usage/API%20%28Java%29.md) — `Flyway.configure().load().migrate()` signature +- [Flyway Gradle plugin config](https://context7.com/flyway/flyway/llms.txt) — `org.flywaydb.flyway` plugin ID and DSL +- [Spotless Gradle + ktlint config](https://github.com/diffplug/spotless/blob/main/plugin-gradle/README.md) — `spotless { kotlin { ktlint() } }` +- [Kotlin/Native binary options (kotlinlang.org)](https://kotlinlang.org/docs/native-binary-options.html) — `kotlin.native.binary.gc=cms` and `objcDisposeOnMain=false` +- [Kotlin/Native memory manager](https://kotlinlang.org/docs/native-memory-manager.html) — new MM default since Kotlin 1.9; PITFALLS.md #1/#2 context +- [Kotlin compiler options (kotlinlang.org)](https://kotlinlang.org/docs/gradle-compiler-options.html) — `compilerOptions { allWarningsAsErrors.set(true) }` for Kotlin 2.x + +### Secondary (MEDIUM confidence — cross-verified with primary sources) +- [Maven Central `io.insert-koin/koin-bom`](https://central.sonatype.com/artifact/io.insert-koin/koin-bom) — confirmed 4.2.1 latest as of 2026-04 +- [MVNRepository `co.touchlab/kermit`](https://mvnrepository.com/artifact/co.touchlab/kermit) — confirmed 2.1.0 latest +- [Gradle plugin portal: `com.diffplug.spotless`](https://plugins.gradle.org/plugin/com.diffplug.spotless) — confirmed 8.4.0 latest +- [Gradle plugin portal: `org.flywaydb.flyway`](https://plugins.gradle.org/plugin/org.flywaydb.flyway) — confirmed 12.4.0 latest +- [VersionCatalogSample GitHub reference](https://github.com/mozhgan-peyvand/VersionCatalogSample) — convention-plugin-access-to-catalog pattern +- [droidcon: Gradle Kotlin convention plugins](https://www.droidcon.com/2023/05/31/gradle-kotlin-convention-plugins-for-modularized-structure-shared-build-logic/) — multi-module convention plugin structure + +### Tertiary (training + synthesis) +- Kotlin 2.3.20 DSL behavior and `compilerOptions` provider properties — training + kotlinlang.org cross-checks +- `kotlin.native.binary.*` defaults under Kotlin 2.x — kotlinlang.org + PITFALLS.md #1 synthesis + +## Metadata + +**Confidence breakdown:** +- Standard stack: HIGH — all versions verified against Maven Central / Gradle plugin portal during this session +- Convention plugin mechanics: HIGH — directly sourced from Gradle docs, including the critical `VersionCatalogsExtension` access pattern +- Koin KMP bootstrap: HIGH — Koin 4.x docs explicitly show the `initKoin()` / `doInitKoin()` pattern; settles D-14 Claude's-discretion item +- Kermit setup: HIGH — single-line tag + default platform writers is the documented canonical path +- Ktor `/health` + HOCON: HIGH — exact code + exact HOCON syntax both verified +- Flyway programmatic API + Gradle plugin: HIGH — both documented, both pinned to 12.4.0 +- iOS binary flags: HIGH — PITFALLS.md #1 + kotlinlang.org binary options reference +- Docker Compose service shape: HIGH — trivial postgres:16 pattern +- "What NOT to do" pitfalls: MEDIUM-HIGH — most are verified from docs; some (A4 android-library plugin on shared/, A6 hot-reload order) are conservative-assumption recommendations awaiting Wave 2 verification + +**Research date:** 2026-04-24 +**Valid until:** 2026-05-24 (4 weeks). Stack is stable; only risk is a Kotlin/CMP minor bump or a Koin 4.2.x regression. Re-verify before Phase 2 if this phase stretches beyond the valid-until window.