Plan phase 1

This commit is contained in:
2026-04-24 16:21:25 +02:00
parent 7ac1555a4c
commit d6cec3fe07
9 changed files with 4221 additions and 1 deletions

View File

@@ -49,7 +49,16 @@
3. iOS `gradle.properties` carry `kotlin.native.binary.objcDisposeOnMain=false` and `kotlin.native.binary.gc=cms`; a debug launch on simulator boots without warnings about legacy memory-management flags. 3. iOS `gradle.properties` carry `kotlin.native.binary.objcDisposeOnMain=false` and `kotlin.native.binary.gc=cms`; a debug launch on simulator boots without warnings about legacy memory-management flags.
4. `build-logic/` convention plugins apply the Kotlin/Compose/test configuration to every module — adding a new module requires only applying a convention plugin, not copying compiler args. 4. `build-logic/` convention plugins apply the Kotlin/Compose/test configuration to every module — adding a new module requires only applying a convention plugin, not copying compiler args.
5. `shared/commonMain` contains only domain models + serializable DTOs; no Ktor, Compose, or SQLDelight imports appear anywhere under `shared/`. 5. `shared/commonMain` contains only domain models + serializable DTOs; no Ktor, Compose, or SQLDelight imports appear anywhere under `shared/`.
**Plans:** TBD **Plans:** 7 plans
Plans:
- [ ] 01-01-PLAN.md — Version catalog extensions (Koin/Kermit/Spotless/Flyway/Postgres) + iOS K/N flags + verify-*.sh invariant scripts
- [ ] 01-02-PLAN.md — build-logic/ included build with 5 precompiled plugins (recipe.quality, recipe.kotlin.multiplatform, recipe.compose.multiplatform, recipe.android.application, recipe.jvm.server) + root settings.gradle.kts includeBuild wiring
- [ ] 01-03-PLAN.md — Module refactor: composeApp/shared/server build.gradle.kts apply recipe.* conventions; drop js target; enable explicitApi() on shared/
- [ ] 01-04-PLAN.md — Koin + Kermit bootstrap across all 4 platforms (commonMain Koin.kt/AppModule.kt/Logging.kt; iOS KoinIos.kt bridge; Android MainApplication.kt + manifest; JVM/Wasm main() rewrites; iOSApp.swift wiring)
- [ ] 01-05-PLAN.md — Server /health + Flyway bootstrap + HOCON config (application.conf, Database.kt with fail-loud contract, db/migration/.gitkeep, ApplicationTest.kt covers /health without Postgres)
- [ ] 01-06-PLAN.md — docker-compose.yml (postgres:16) + README.md Local development section (drops js docs)
- [ ] 01-07-PLAN.md — shared/ package scaffold + full green-build gate (spotlessApply, verify-*.sh, ./gradlew build, ./gradlew check)
**UI hint:** no **UI hint:** no
**Research flag:** no **Research flag:** no

View File

@@ -0,0 +1,342 @@
---
phase: 01-project-infrastructure-module-wiring
plan: 01
type: execute
wave: 1
depends_on: []
files_modified:
- gradle/libs.versions.toml
- gradle.properties
- tools/verify-no-version-literals.sh
- tools/verify-shared-pure.sh
- tools/verify-ios-flags.sh
autonomous: true
requirements: [INFRA-01, INFRA-03]
requirements_addressed: [INFRA-01, INFRA-03]
must_haves:
truths:
- "gradle/libs.versions.toml is the sole source of library/plugin versions (D-09 / INFRA-01 SC#2)"
- "iOS K/N binary flags kotlin.native.binary.gc=cms and kotlin.native.binary.objcDisposeOnMain=false are set in gradle.properties (D-18 / INFRA-03)"
- "Shell-based invariant checks (no-version-literals, shared-pure, ios-flags) are executable and fail-loud"
artifacts:
- path: "gradle/libs.versions.toml"
provides: "Version + library + plugin aliases for Koin, Kermit, Spotless, Flyway, PostgreSQL JDBC, Ktor content-negotiation, Ktor JSON serializer"
contains: "koin = ", "kermit = ", "spotless = ", "flyway = ", "postgresql ="
- path: "gradle.properties"
provides: "iOS K/N binary flags"
contains: "kotlin.native.binary.gc=cms", "kotlin.native.binary.objcDisposeOnMain=false"
- path: "tools/verify-no-version-literals.sh"
provides: "Invariant check — no numeric version literals outside catalog in any *.gradle.kts (except build-logic/build.gradle.kts bootstrap coordinates)"
- path: "tools/verify-shared-pure.sh"
provides: "Invariant check — shared/src/commonMain must not import Ktor / Compose / SQLDelight"
- path: "tools/verify-ios-flags.sh"
provides: "Invariant check — both iOS K/N flags present in gradle.properties"
key_links:
- from: "build-logic/ (Plan 02)"
to: "gradle/libs.versions.toml"
via: "VersionCatalogsExtension.named(\"libs\").findLibrary(...) inside precompiled plugins"
pattern: "findLibrary\\(\"koin-core\"\\)"
- from: "gradle.properties"
to: ":composeApp:linkDebugFrameworkIosSimulatorArm64"
via: "Kotlin/Native compiler reads project properties at link time"
pattern: "kotlin\\.native\\.binary\\."
---
<objective>
Extend the Gradle version catalog with every new alias required by Phase 1 (Koin, Kermit, Spotless, Flyway, Postgres JDBC, ktor-serverContentNegotiation, ktor-serializationKotlinxJson), append the two mandatory iOS Kotlin/Native binary flags to `gradle.properties`, and ship three shell-based invariant scripts under `tools/` that Plan 07 will use as phase-gate checks.
Purpose: This plan creates the **foundation** on which every other Phase 1 plan rests. Without these catalog entries, `build-logic/` (Plan 02) cannot resolve `findLibrary("koin-core")`; without the iOS flags, INFRA-03 fails silently. The verification scripts are required by 01-VALIDATION.md Wave 0 — every subsequent plan's `<automated>` block calls one of them.
Output: An extended `gradle/libs.versions.toml` (additive only, no version bumps to existing entries), extended `gradle.properties` with exactly two new lines, and three executable `.sh` scripts under a new `tools/` directory.
</objective>
<execution_context>
@$HOME/.claude/get-shit-done/workflows/execute-plan.md
@$HOME/.claude/get-shit-done/templates/summary.md
</execution_context>
<context>
@.planning/PROJECT.md
@.planning/ROADMAP.md
@.planning/STATE.md
@.planning/REQUIREMENTS.md
@.planning/phases/01-project-infrastructure-module-wiring/01-CONTEXT.md
@.planning/phases/01-project-infrastructure-module-wiring/01-RESEARCH.md
@.planning/phases/01-project-infrastructure-module-wiring/01-PATTERNS.md
@.planning/phases/01-project-infrastructure-module-wiring/01-VALIDATION.md
@gradle/libs.versions.toml
@gradle.properties
@CLAUDE.md
<interfaces>
<!-- Existing catalog aliases (do NOT rename or remove — only add new entries alongside) -->
From gradle/libs.versions.toml (current state, to extend):
```toml
[versions]
kotlin = "2.3.20"
ktor = "3.4.1"
composeMultiplatform = "1.10.3"
# (plus agp, androidx-*, composeHotReload, junit, kotlinx-coroutines, logback, material3)
[libraries]
# Existing: kotlin-test, kotlin-testJunit, junit, androidx-*, compose-*, kotlinx-coroutinesSwing,
# logback, ktor-serverCore, ktor-serverNetty, ktor-serverTestHost
[plugins]
# Existing: androidApplication, androidLibrary, composeHotReload, composeMultiplatform,
# composeCompiler, kotlinJvm, ktor, kotlinMultiplatform
```
From gradle.properties (current state — 10 lines of Kotlin + Gradle + Android config):
```properties
kotlin.code.style=official
kotlin.daemon.jvmargs=-Xmx3072M
org.gradle.jvmargs=-Xmx4096M -Dfile.encoding=UTF-8
org.gradle.configuration-cache=true
org.gradle.caching=true
android.nonTransitiveRClass=true
android.useAndroidX=true
```
</interfaces>
</context>
<tasks>
<task type="auto">
<name>Task 1: Extend gradle/libs.versions.toml with Phase 1 aliases</name>
<files>gradle/libs.versions.toml</files>
<read_first>
- gradle/libs.versions.toml (see current state of versions/libraries/plugins tables)
- .planning/phases/01-project-infrastructure-module-wiring/01-RESEARCH.md lines 110-175 (§ Standard Stack + Installation TOML fragments)
- .planning/phases/01-project-infrastructure-module-wiring/01-PATTERNS.md lines 446-490 (delta blocks for [versions] / [libraries] / [plugins])
- .planning/phases/01-project-infrastructure-module-wiring/01-CONTEXT.md D-09 (catalog-only hard rule), D-14 (Koin deps needed), D-15 (Kermit), D-10 (Spotless), D-16 (Flyway + Postgres + content-negotiation)
</read_first>
<action>
Extend `gradle/libs.versions.toml` with the new aliases for Phase 1. Preserve every existing entry verbatim (do NOT rename, remove, or bump any existing version).
Append the following to `[versions]`, in the existing alphabetical-ish order:
```toml
flyway = "12.4.0"
kermit = "2.1.0"
koin = "4.2.1"
kotlinx-serialization = "1.7.3"
postgresql = "42.7.10"
spotless = "8.4.0"
```
Append the following to `[libraries]`:
```toml
# Koin (client DI — D-14)
koin-bom = { module = "io.insert-koin:koin-bom", version.ref = "koin" }
koin-core = { module = "io.insert-koin:koin-core" }
koin-compose = { module = "io.insert-koin:koin-compose" }
koin-composeViewmodel = { module = "io.insert-koin:koin-compose-viewmodel" }
koin-android = { module = "io.insert-koin:koin-android" }
# Kermit (client logger — D-15)
kermit = { module = "co.touchlab:kermit", version.ref = "kermit" }
# Server: Ktor content-negotiation + JSON serializer + Flyway + Postgres (D-16)
ktor-serverContentNegotiation = { module = "io.ktor:ktor-server-content-negotiation-jvm", version.ref = "ktor" }
ktor-serializationKotlinxJson = { module = "io.ktor:ktor-serialization-kotlinx-json-jvm", version.ref = "ktor" }
flyway-core = { module = "org.flywaydb:flyway-core", version.ref = "flyway" }
flyway-database-postgresql = { module = "org.flywaydb:flyway-database-postgresql", version.ref = "flyway" }
postgresql = { module = "org.postgresql:postgresql", version.ref = "postgresql" }
```
Append the following to `[plugins]`:
```toml
spotless = { id = "com.diffplug.spotless", version.ref = "spotless" }
flywayPlugin = { id = "org.flywaydb.flyway", version.ref = "flyway" }
```
IMPORTANT invariants:
- `koin-core`, `koin-compose`, `koin-compose-viewmodel`, `koin-android` have NO `version.ref` — they are BOM-managed by `koin-bom`.
- `kotlin-test` is already in the catalog (line 22) — do NOT re-add.
- Do NOT bump any existing version alias (kotlin, ktor, composeMultiplatform, logback, etc.).
- The `koin-composeViewmodel` alias name uses camelCase (Gradle converts dashes-to-dots for accessors, but camelCase preserves `koin.composeViewmodel.get()`).
</action>
<verify>
<automated>grep -E '^(flyway|kermit|koin|kotlinx-serialization|postgresql|spotless)\s*=' gradle/libs.versions.toml | wc -l | grep -q '^6$' && grep -E '^koin-bom\s*=' gradle/libs.versions.toml && grep -E '^koin-core\s*=' gradle/libs.versions.toml && grep -E '^koin-compose\s*=' gradle/libs.versions.toml && grep -E '^koin-composeViewmodel\s*=' gradle/libs.versions.toml && grep -E '^koin-android\s*=' gradle/libs.versions.toml && grep -E '^kermit\s*=' gradle/libs.versions.toml && grep -E '^ktor-serverContentNegotiation\s*=' gradle/libs.versions.toml && grep -E '^ktor-serializationKotlinxJson\s*=' gradle/libs.versions.toml && grep -E '^flyway-core\s*=' gradle/libs.versions.toml && grep -E '^flyway-database-postgresql\s*=' gradle/libs.versions.toml && grep -E '^postgresql\s*=' gradle/libs.versions.toml && grep -E '^spotless\s*=\s*\{\s*id\s*=' gradle/libs.versions.toml && grep -E '^flywayPlugin\s*=\s*\{\s*id\s*=' gradle/libs.versions.toml</automated>
</verify>
<acceptance_criteria>
- `grep -E '^kotlin\s*=\s*"2\.3\.20"' gradle/libs.versions.toml` returns exactly 1 line (existing, unmodified)
- `grep -E '^ktor\s*=\s*"3\.4\.1"' gradle/libs.versions.toml` returns exactly 1 line (existing, unmodified)
- `grep -E '^koin\s*=\s*"4\.2\.1"' gradle/libs.versions.toml` returns exactly 1 line (new)
- `grep -E '^kermit\s*=\s*"2\.1\.0"' gradle/libs.versions.toml` returns exactly 1 line (new)
- `grep -E '^spotless\s*=\s*"8\.4\.0"' gradle/libs.versions.toml` returns exactly 1 line (new)
- `grep -E '^flyway\s*=\s*"12\.4\.0"' gradle/libs.versions.toml` returns exactly 1 line (new)
- `grep -E '^postgresql\s*=\s*"42\.7\.10"' gradle/libs.versions.toml` returns exactly 1 line (new)
- `grep -c '^koin-' gradle/libs.versions.toml` returns `5` (koin-bom, koin-core, koin-compose, koin-composeViewmodel, koin-android)
- `grep -c '^flyway-' gradle/libs.versions.toml` returns `2` (flyway-core, flyway-database-postgresql)
- `grep -E '^\s*module\s*=\s*"io.insert-koin:koin-core"' gradle/libs.versions.toml` returns 1 line with NO `version.ref` attribute on same line (BOM-managed)
</acceptance_criteria>
<done>All Phase 1 catalog aliases present; no existing aliases modified; file parses as valid TOML.</done>
</task>
<task type="auto">
<name>Task 2: Append iOS K/N binary flags to gradle.properties</name>
<files>gradle.properties</files>
<read_first>
- gradle.properties (see current 10-line content)
- .planning/phases/01-project-infrastructure-module-wiring/01-RESEARCH.md lines 1082-1107 (§ `gradle.properties` — iOS binary flags — exact content to append)
- .planning/phases/01-project-infrastructure-module-wiring/01-CONTEXT.md D-18 (INFRA-03, PITFALL #1)
- CLAUDE.md convention #7 (iOS binary flags on day 1)
</read_first>
<action>
Append the following 5 lines to `gradle.properties` exactly as shown (including the blank separator line and both comment lines). Do NOT modify any existing line:
```properties
# Kotlin/Native iOS (PITFALLS.md #1; D-18; INFRA-03) — MANDATORY day 1
# CMS GC + non-main-thread Obj-C deinit to avoid UI-thread pause spikes in Compose Multiplatform.
kotlin.native.binary.gc=cms
kotlin.native.binary.objcDisposeOnMain=false
```
IMPORTANT:
- Place AT THE END of the file (append). The existing `android.useAndroidX=true` stays as the last non-iOS line.
- Use EXACTLY the property keys `kotlin.native.binary.gc` and `kotlin.native.binary.objcDisposeOnMain`. Do not add quotes, spaces, or alternate spellings (the K/N compiler reads these keys literally).
- Value `cms` is lowercase. Value `false` is lowercase.
</action>
<verify>
<automated>grep -E '^kotlin\.native\.binary\.gc=cms$' gradle.properties | wc -l | grep -q '^1$' && grep -E '^kotlin\.native\.binary\.objcDisposeOnMain=false$' gradle.properties | wc -l | grep -q '^1$'</automated>
</verify>
<acceptance_criteria>
- `grep -cE '^kotlin\.native\.binary\.gc=cms$' gradle.properties` returns `1`
- `grep -cE '^kotlin\.native\.binary\.objcDisposeOnMain=false$' gradle.properties` returns `1`
- `grep -c '^kotlin\.code\.style=official$' gradle.properties` returns `1` (unmodified existing)
- `grep -c '^android\.useAndroidX=true$' gradle.properties` returns `1` (unmodified existing)
- No duplicate of either flag (run grep twice — expect `1` each time, not `2`)
</acceptance_criteria>
<done>Both iOS K/N flags present once; original 10 lines unchanged.</done>
</task>
<task type="auto">
<name>Task 3: Create verify-*.sh invariant scripts under tools/</name>
<files>tools/verify-no-version-literals.sh, tools/verify-shared-pure.sh, tools/verify-ios-flags.sh</files>
<read_first>
- .planning/phases/01-project-infrastructure-module-wiring/01-RESEARCH.md lines 1174-1218 (§ tools/verify-*.sh — canonical shell sketches)
- .planning/phases/01-project-infrastructure-module-wiring/01-PATTERNS.md lines 1174-1218 (same scripts, same content — Pattern Map confirms no in-repo analog)
- .planning/phases/01-project-infrastructure-module-wiring/01-VALIDATION.md lines 62-79 (Wave 0 Requirements — these three scripts gate every task's `<automated>` check)
</read_first>
<action>
Create the three executable bash scripts under `tools/` (create the directory — it does not exist yet). Each must be marked executable (`chmod +x`).
**File 1: `tools/verify-no-version-literals.sh`** (enforces D-09 / INFRA-01 SC#2):
```sh
#!/usr/bin/env bash
# Enforces INFRA-01 SC#2 / D-09: no literal version strings outside catalog.
# Scans every *.gradle.kts for numeric version literals (e.g. version = "1.2.3"),
# excluding build-logic/build.gradle.kts which needs literal asDependency() coordinates.
set -euo pipefail
VIOLATIONS=$(grep -rn -E 'version[[:space:]]*=[[:space:]]*"[0-9]' --include='*.gradle.kts' . 2>/dev/null | grep -v 'build-logic/build.gradle.kts' || true)
if [ -n "$VIOLATIONS" ]; then
echo "ERROR: version literals found outside catalog:" >&2
echo "$VIOLATIONS" >&2
exit 1
fi
echo "OK: no version literals outside catalog."
```
**File 2: `tools/verify-shared-pure.sh`** (enforces INFRA-06 / D-19):
```sh
#!/usr/bin/env bash
# Enforces INFRA-06 / D-19: shared/commonMain must not import Ktor, Compose, SQLDelight.
# Runs grep against shared/src/commonMain/ only. Allowed imports: kotlin.*, kotlinx.serialization, kotlinx.datetime.
set -euo pipefail
if [ ! -d shared/src/commonMain ]; then
echo "OK: shared/src/commonMain does not exist yet (pre-scaffold)."
exit 0
fi
VIOLATIONS=$(grep -rn -E '^import[[:space:]]+(io\.ktor|androidx\.compose|org\.jetbrains\.compose|app\.cash\.sqldelight)' shared/src/commonMain/ 2>/dev/null || true)
if [ -n "$VIOLATIONS" ]; then
echo "ERROR: shared/commonMain has forbidden imports:" >&2
echo "$VIOLATIONS" >&2
exit 1
fi
echo "OK: shared/commonMain is pure."
```
**File 3: `tools/verify-ios-flags.sh`** (enforces INFRA-03 / D-18):
```sh
#!/usr/bin/env bash
# Enforces INFRA-03 / D-18: iOS K/N flags present in gradle.properties.
set -euo pipefail
grep -q '^kotlin\.native\.binary\.gc=cms$' gradle.properties || { echo "MISSING: kotlin.native.binary.gc=cms" >&2; exit 1; }
grep -q '^kotlin\.native\.binary\.objcDisposeOnMain=false$' gradle.properties || { echo "MISSING: kotlin.native.binary.objcDisposeOnMain=false" >&2; exit 1; }
echo "OK: iOS binary flags present."
```
After writing all three files, run: `chmod +x tools/verify-no-version-literals.sh tools/verify-shared-pure.sh tools/verify-ios-flags.sh`.
IMPORTANT:
- Use `#!/usr/bin/env bash` (not `#!/bin/sh`) — `set -euo pipefail` requires bash semantics.
- `tools/verify-shared-pure.sh` deliberately returns 0 if `shared/src/commonMain` does not exist (pre-scaffold state). This lets Plan 07 run the script before Plan 07 itself creates the scaffold.
- `tools/verify-no-version-literals.sh` excludes `build-logic/build.gradle.kts` (its `asDependency()` trick requires literal plugin version coordinates — D-09 acknowledged exception).
</action>
<verify>
<automated>test -x tools/verify-no-version-literals.sh && test -x tools/verify-shared-pure.sh && test -x tools/verify-ios-flags.sh && bash tools/verify-ios-flags.sh && bash tools/verify-shared-pure.sh && bash tools/verify-no-version-literals.sh</automated>
</verify>
<acceptance_criteria>
- `test -f tools/verify-no-version-literals.sh && test -x tools/verify-no-version-literals.sh` succeeds
- `test -f tools/verify-shared-pure.sh && test -x tools/verify-shared-pure.sh` succeeds
- `test -f tools/verify-ios-flags.sh && test -x tools/verify-ios-flags.sh` succeeds
- `bash tools/verify-ios-flags.sh` exits 0 and prints `OK: iOS binary flags present.` (proves Task 2 wrote flags)
- `bash tools/verify-shared-pure.sh` exits 0 (current `shared/src/commonMain/kotlin/dev/ulfrx/recipe/` has only Greeting.kt/Platform.kt/Constants.kt — no ktor/compose imports)
- `bash tools/verify-no-version-literals.sh` exits 0 (current *.gradle.kts files use `libs.plugins.*` aliases — no literal versions)
- Each script has `#!/usr/bin/env bash` as line 1
- Each script uses `set -euo pipefail`
</acceptance_criteria>
<done>Three executable verification scripts exist, each runs green against the current repo state.</done>
</task>
</tasks>
<threat_model>
## Trust Boundaries
| Boundary | Description |
|----------|-------------|
| developer → Gradle build | Local-only; Gradle reads `libs.versions.toml` + `gradle.properties` verbatim. No untrusted input. |
| Gradle → Maven Central + Gradle Plugin Portal | Existing repository declarations in `settings.gradle.kts` (Plan 03 doesn't change them). Pinned versions via catalog reduce supply-chain drift. |
## STRIDE Threat Register
| Threat ID | Category | Component | Disposition | Mitigation Plan |
|-----------|----------|-----------|-------------|-----------------|
| T-01-01-01 | Tampering (supply chain) | `gradle/libs.versions.toml` new entries | mitigate | All new version refs are pinned to specific stable releases (`koin = "4.2.1"`, `kermit = "2.1.0"`, `flyway = "12.4.0"`, `spotless = "8.4.0"`, `postgresql = "42.7.10"`) — no version ranges, no `latest.release`. Gradle verifies SHA-256 via `gradle/verification-metadata.xml` if enabled in later phases. |
| T-01-01-02 | Tampering | `tools/*.sh` scripts | accept | Scripts live in repo and run locally; their only effect is exit 0/1. Read `gradle.properties` and `*.gradle.kts` only — no network I/O, no write. Risk = low. |
| T-01-01-03 | Information Disclosure | `gradle.properties` iOS flags | accept | Flag values (`cms`, `false`) are build configuration, not secrets. Public in every iOS KMP tutorial. |
| T-01-01-04 | Denial of Service | wrong catalog syntax breaks build | mitigate | Task 1 `<acceptance_criteria>` greps for exact alias presence; Wave 2 plans that consume the catalog will fail fast if an alias is misspelled. |
</threat_model>
<verification>
Phase-level verification for this plan:
- All three `tools/verify-*.sh` scripts run green against the post-plan repo.
- `gradle/libs.versions.toml` parses (Gradle will surface a TOML parse error at next `./gradlew` invocation in Plan 02).
- `gradle.properties` has exactly two new iOS K/N flag lines and is otherwise byte-identical to its pre-plan content.
No Gradle build is expected to run fully in this plan — we have not yet scaffolded `build-logic/` (Plan 02) nor refactored modules (Plan 03), so `./gradlew build` would fail to resolve the new library aliases. Catalog additions ARE safe for Gradle configuration though (unused entries are inert).
</verification>
<success_criteria>
- `tools/verify-ios-flags.sh` exits 0
- `tools/verify-no-version-literals.sh` exits 0
- `tools/verify-shared-pure.sh` exits 0
- Catalog contains 6 new `[versions]` keys (flyway, kermit, koin, kotlinx-serialization, postgresql, spotless)
- Catalog contains 10 new `[libraries]` entries (5 koin-*, kermit, 2 ktor-*, 2 flyway-*, postgresql)
- Catalog contains 2 new `[plugins]` entries (spotless, flywayPlugin)
</success_criteria>
<output>
After completion, create `.planning/phases/01-project-infrastructure-module-wiring/01-01-SUMMARY.md` recording: catalog entries added (count), gradle.properties append location, shell-script paths, and any deviation from the planned version pins (if Maven Central shows a newer stable, record the downgrade decision).
</output>

View File

@@ -0,0 +1,576 @@
---
phase: 01-project-infrastructure-module-wiring
plan: 02
type: execute
wave: 1
depends_on: []
files_modified:
- build-logic/settings.gradle.kts
- build-logic/build.gradle.kts
- build-logic/src/main/kotlin/recipe.quality.gradle.kts
- build-logic/src/main/kotlin/recipe.kotlin.multiplatform.gradle.kts
- build-logic/src/main/kotlin/recipe.compose.multiplatform.gradle.kts
- build-logic/src/main/kotlin/recipe.android.application.gradle.kts
- build-logic/src/main/kotlin/recipe.jvm.server.gradle.kts
- settings.gradle.kts
- build.gradle.kts
autonomous: true
requirements: [INFRA-02]
requirements_addressed: [INFRA-02]
must_haves:
truths:
- "build-logic/ is an included build resolved via pluginManagement.includeBuild (PITFALL #9)"
- "5 precompiled script plugins exist under build-logic/src/main/kotlin/: recipe.quality, recipe.kotlin.multiplatform, recipe.compose.multiplatform, recipe.android.application, recipe.jvm.server (D-06)"
- "Each precompiled plugin reads versions via extensions.getByType<VersionCatalogsExtension>().named(\"libs\") (PITFALL #1)"
- "recipe.kotlin.multiplatform locks the D-05 target matrix (androidTarget, iosArm64, iosSimulatorArm64, jvm, wasmJs) + JVM toolchain 21 + framework basename 'ComposeApp' + Koin/Kermit/kotlin-test deps + allWarningsAsErrors"
- "recipe.compose.multiplatform layers on recipe.kotlin.multiplatform (does NOT re-declare KMP plugin — PITFALL #2)"
- "recipe.jvm.server uses quoted dependency configurations (\"implementation\"(...) — quoted-config footgun)"
- "settings.gradle.kts places includeBuild(\"build-logic\") INSIDE pluginManagement { } block (PITFALL #9)"
artifacts:
- path: "build-logic/settings.gradle.kts"
provides: "Included-build settings with shared catalog access (from files(\"../gradle/libs.versions.toml\"))"
- path: "build-logic/build.gradle.kts"
provides: "kotlin-dsl plugin + compileOnly(asDependency()) entries for every alias-based plugin referenced by precompiled plugins"
- path: "build-logic/src/main/kotlin/recipe.quality.gradle.kts"
provides: "Spotless + ktlint + allWarningsAsErrors safety net (D-10 / D-11)"
- path: "build-logic/src/main/kotlin/recipe.kotlin.multiplatform.gradle.kts"
provides: "D-05 target matrix + JVM toolchain + common deps + allWarningsAsErrors (D-07, D-08, D-11)"
- path: "build-logic/src/main/kotlin/recipe.compose.multiplatform.gradle.kts"
provides: "Compose MP plugin + hot-reload + Compose deps for commonMain (layered on KMP)"
- path: "build-logic/src/main/kotlin/recipe.android.application.gradle.kts"
provides: "com.android.application + namespace + SDK versions (composeApp only)"
- path: "build-logic/src/main/kotlin/recipe.jvm.server.gradle.kts"
provides: "kotlin(jvm) + Ktor + Flyway + server deps (server only)"
- path: "settings.gradle.kts"
provides: "Root settings with pluginManagement { includeBuild(\"build-logic\") }"
- path: "build.gradle.kts"
provides: "Root build with apply-false entries for spotless + flywayPlugin (classloader hint)"
key_links:
- from: "build-logic/src/main/kotlin/recipe.*.gradle.kts"
to: "gradle/libs.versions.toml"
via: "VersionCatalogsExtension.named(\"libs\")"
pattern: "extensions\\.getByType<VersionCatalogsExtension>\\(\\)\\.named\\(\"libs\"\\)"
- from: "Plan 03 module build files"
to: "build-logic/src/main/kotlin/recipe.*.gradle.kts"
via: "plugins { id(\"recipe.kotlin.multiplatform\") }"
pattern: "id\\(\"recipe\\."
---
<objective>
Scaffold the `build-logic/` included build with 5 precompiled script plugins (`recipe.quality`, `recipe.kotlin.multiplatform`, `recipe.compose.multiplatform`, `recipe.android.application`, `recipe.jvm.server`) that every module in Plan 03 will apply. Wire the included build into `settings.gradle.kts` via `pluginManagement.includeBuild("build-logic")` and extend the root `build.gradle.kts` with `apply false` declarations for the two new plugins (Spotless + Flyway) so Gradle's classloader resolves them consistently.
Purpose: This is the **dependency root** for every subsequent Phase 1 plan. Plan 03 cannot refactor module builds until these plugins exist. Plan 05 cannot wire Flyway into the server without `recipe.jvm.server`. The design (per D-06) enforces role declarations — `shared/` applies only `recipe.kotlin.multiplatform` + `recipe.quality` and therefore CANNOT pull Compose transitively (INFRA-06).
Output: A fully populated `build-logic/` directory whose included-build settings resolve the parent catalog, a root settings file that finds `recipe.*` plugins by ID, and 5 precompiled plugins whose internals are verbatim (or near-verbatim) copies of 01-RESEARCH.md § Code Examples / § Architecture Patterns.
</objective>
<execution_context>
@$HOME/.claude/get-shit-done/workflows/execute-plan.md
@$HOME/.claude/get-shit-done/templates/summary.md
</execution_context>
<context>
@.planning/PROJECT.md
@.planning/ROADMAP.md
@.planning/STATE.md
@.planning/phases/01-project-infrastructure-module-wiring/01-CONTEXT.md
@.planning/phases/01-project-infrastructure-module-wiring/01-RESEARCH.md
@.planning/phases/01-project-infrastructure-module-wiring/01-PATTERNS.md
@.planning/phases/01-project-infrastructure-module-wiring/01-VALIDATION.md
@settings.gradle.kts
@build.gradle.kts
@gradle/libs.versions.toml
@CLAUDE.md
<interfaces>
<!-- These are the canonical excerpts the executor MUST copy verbatim. Line ranges refer to 01-RESEARCH.md. -->
Plugin applications reference (01-PATTERNS.md and 01-RESEARCH.md):
- `id("recipe.quality")` → from .gradle.kts file named `recipe.quality.gradle.kts` (Gradle convention)
- `id("recipe.kotlin.multiplatform")``recipe.kotlin.multiplatform.gradle.kts`
- etc.
Version-catalog access pattern inside precompiled plugins (PITFALL #1, RESEARCH.md lines 362-380):
```kotlin
import org.gradle.api.artifacts.VersionCatalogsExtension
import org.gradle.kotlin.dsl.getByType
val libs = extensions.getByType<VersionCatalogsExtension>().named("libs")
// Usage:
val v = libs.findVersion("kotlin").get().toString()
val lib = libs.findLibrary("koin-core").get()
```
Quoted configuration names in precompiled plugin dependencies (RESEARCH.md line 603, Pattern 7):
```kotlin
dependencies {
"implementation"(libs.findLibrary("ktor-serverCore").get()) // quoted!
// NOT: implementation(...) — unresolved reference in precompiled plugin context
}
```
The root `settings.gradle.kts` layout required by PITFALL #9 (RESEARCH.md lines 749-767):
```kotlin
pluginManagement {
includeBuild("build-logic") // MUST be inside pluginManagement { }
repositories { ... }
}
```
</interfaces>
</context>
<tasks>
<task type="auto">
<name>Task 1: Scaffold build-logic/ included build + 5 precompiled plugins</name>
<files>build-logic/settings.gradle.kts, build-logic/build.gradle.kts, build-logic/src/main/kotlin/recipe.quality.gradle.kts, build-logic/src/main/kotlin/recipe.kotlin.multiplatform.gradle.kts, build-logic/src/main/kotlin/recipe.compose.multiplatform.gradle.kts, build-logic/src/main/kotlin/recipe.android.application.gradle.kts, build-logic/src/main/kotlin/recipe.jvm.server.gradle.kts</files>
<read_first>
- .planning/phases/01-project-infrastructure-module-wiring/01-RESEARCH.md lines 308-605 (§ Pattern 1 through § Pattern 7 — canonical excerpts for every file in this task)
- .planning/phases/01-project-infrastructure-module-wiring/01-RESEARCH.md lines 652-774 (§ Common Pitfalls 1-10 — especially #1 catalog access, #2 double-apply KMP, #3 warnings-as-errors scope, #7 kotlinOptions, #9 includeBuild location, #10 framework basename)
- .planning/phases/01-project-infrastructure-module-wiring/01-PATTERNS.md lines 105-443 (pattern assignments for each build-logic/ file with deltas)
- gradle/libs.versions.toml (Plan 01 added these aliases — verify they exist before writing `findLibrary(...)` references)
- .planning/phases/01-project-infrastructure-module-wiring/01-CONTEXT.md D-06 through D-17 (plugin split, JVM split, warnings-as-errors, Koin deps, Flyway, server scope)
</read_first>
<action>
Create the `build-logic/` directory and all 7 files listed in `<files>`. Each file's content comes directly from 01-RESEARCH.md. Use the Write tool for every file (no heredoc).
---
**File 1: `build-logic/settings.gradle.kts`** (01-RESEARCH.md lines 316-331, verbatim):
```kotlin
dependencyResolutionManagement {
repositories {
google()
mavenCentral()
gradlePluginPortal()
}
versionCatalogs {
create("libs") {
from(files("../gradle/libs.versions.toml"))
}
}
}
rootProject.name = "build-logic"
```
---
**File 2: `build-logic/build.gradle.kts`** (01-RESEARCH.md lines 333-358, verbatim):
```kotlin
plugins {
`kotlin-dsl`
}
dependencies {
compileOnly(libs.plugins.kotlinMultiplatform.asDependency())
compileOnly(libs.plugins.androidApplication.asDependency())
compileOnly(libs.plugins.androidLibrary.asDependency())
compileOnly(libs.plugins.composeMultiplatform.asDependency())
compileOnly(libs.plugins.composeCompiler.asDependency())
compileOnly(libs.plugins.composeHotReload.asDependency())
compileOnly(libs.plugins.kotlinJvm.asDependency())
compileOnly(libs.plugins.ktor.asDependency())
compileOnly(libs.plugins.spotless.asDependency())
compileOnly(libs.plugins.flywayPlugin.asDependency())
}
fun Provider<PluginDependency>.asDependency(): Provider<String> =
map { "${it.pluginId}:${it.pluginId}.gradle.plugin:${it.version.requiredVersion}" }
```
---
**File 3: `build-logic/src/main/kotlin/recipe.quality.gradle.kts`** (01-RESEARCH.md lines 483-512 + D-11 safety net):
```kotlin
plugins {
id("com.diffplug.spotless")
}
spotless {
kotlin {
target("src/**/*.kt")
targetExclude("**/build/**", "**/generated/**")
ktlint()
}
kotlinGradle {
target("*.gradle.kts")
ktlint()
}
format("markdown") {
target("*.md", "docs/**/*.md")
endWithNewline()
trimTrailingWhitespace()
}
}
// D-11 redundancy guard: if a module applies recipe.quality WITHOUT recipe.kotlin.multiplatform
// (e.g. a future pure-JVM utility), ensure allWarningsAsErrors still applies.
tasks.withType<org.jetbrains.kotlin.gradle.tasks.KotlinCompilationTask<*>>().configureEach {
compilerOptions {
allWarningsAsErrors.set(true)
}
}
```
---
**File 4: `build-logic/src/main/kotlin/recipe.kotlin.multiplatform.gradle.kts`** (01-RESEARCH.md lines 777-835, verbatim — the canonical KMP plugin):
```kotlin
// build-logic/src/main/kotlin/recipe.kotlin.multiplatform.gradle.kts
// Establishes the D-05 target matrix + JVM toolchain + common deps.
// Android bytecode is JVM 11 (D-08); server + desktop + shared/jvm are JVM 21.
import org.gradle.api.artifacts.VersionCatalogsExtension
import org.gradle.kotlin.dsl.getByType
import org.jetbrains.kotlin.gradle.ExperimentalWasmDsl
import org.jetbrains.kotlin.gradle.dsl.JvmTarget
plugins {
id("org.jetbrains.kotlin.multiplatform")
}
val libs = extensions.getByType<VersionCatalogsExtension>().named("libs")
kotlin {
jvmToolchain(21)
androidTarget {
compilerOptions {
jvmTarget.set(JvmTarget.JVM_11)
}
}
listOf(iosArm64(), iosSimulatorArm64()).forEach { iosTarget ->
iosTarget.binaries.framework {
baseName = "ComposeApp"
isStatic = true
}
}
jvm {
compilerOptions {
jvmTarget.set(JvmTarget.JVM_21)
}
}
@OptIn(ExperimentalWasmDsl::class)
wasmJs { browser() }
compilerOptions {
allWarningsAsErrors.set(true)
}
sourceSets {
commonMain.dependencies {
implementation(project.dependencies.platform(libs.findLibrary("koin-bom").get()))
implementation(libs.findLibrary("koin-core").get())
implementation(libs.findLibrary("kermit").get())
}
commonTest.dependencies {
implementation(libs.findLibrary("kotlin-test").get())
}
}
}
```
---
**File 5: `build-logic/src/main/kotlin/recipe.compose.multiplatform.gradle.kts`** (01-RESEARCH.md lines 447-477 + 01-PATTERNS.md lines 247-287 — layers on KMP, PITFALL #2):
```kotlin
import org.gradle.api.artifacts.VersionCatalogsExtension
import org.gradle.kotlin.dsl.getByType
plugins {
id("recipe.kotlin.multiplatform")
id("org.jetbrains.compose")
id("org.jetbrains.kotlin.plugin.compose")
id("org.jetbrains.compose.hot-reload")
}
val libs = extensions.getByType<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())
}
}
}
```
CRITICAL: this plugin applies `id("recipe.kotlin.multiplatform")` — NOT `id("org.jetbrains.kotlin.multiplatform")`. The KMP plugin is applied transitively by the recipe plugin. Double-applying throws "Plugin already applied" (PITFALL #2).
---
**File 6: `build-logic/src/main/kotlin/recipe.android.application.gradle.kts`** (01-RESEARCH.md lines 516-552, catalog-accessor-adjusted for precompiled-plugin context):
```kotlin
import org.gradle.api.artifacts.VersionCatalogsExtension
import org.gradle.kotlin.dsl.getByType
plugins {
id("com.android.application")
}
val libs = extensions.getByType<VersionCatalogsExtension>().named("libs")
android {
namespace = "dev.ulfrx.recipe"
compileSdk = libs.findVersion("android-compileSdk").get().toString().toInt()
defaultConfig {
applicationId = "dev.ulfrx.recipe"
minSdk = libs.findVersion("android-minSdk").get().toString().toInt()
targetSdk = libs.findVersion("android-targetSdk").get().toString().toInt()
versionCode = 1
versionName = "1.0"
}
packaging {
resources {
excludes += "/META-INF/{AL2.0,LGPL2.1}"
}
}
buildTypes {
getByName("release") {
isMinifyEnabled = false
}
}
compileOptions {
sourceCompatibility = JavaVersion.VERSION_11
targetCompatibility = JavaVersion.VERSION_11
}
}
```
CRITICAL: the version lookup is `libs.findVersion("android-compileSdk").get().toString().toInt()` — NOT `libs.versions.android.compileSdk.get().toInt()` (that accessor does not exist in precompiled plugins — PITFALL #1).
---
**File 7: `build-logic/src/main/kotlin/recipe.jvm.server.gradle.kts`** (01-RESEARCH.md lines 558-601, quoted-config variant per PATTERNS.md line 395):
```kotlin
import org.gradle.api.artifacts.VersionCatalogsExtension
import org.gradle.kotlin.dsl.getByType
plugins {
id("org.jetbrains.kotlin.jvm")
id("io.ktor.plugin")
id("org.flywaydb.flyway")
application
}
val libs = extensions.getByType<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
}
```
CRITICAL:
- `"implementation"(...)` with quoted-string configuration is MANDATORY inside precompiled plugins — the unquoted form is a typed method that only exists in module build scripts.
- The `flyway { }` block is for CLI ergonomics (`./gradlew flywayInfo`). Runtime migration uses the Java API (Plan 05 wires this).
---
After writing all 7 files, verify that `build-logic/build.gradle.kts` can see the catalog by running a syntax-only check. No `./gradlew build` yet — Plan 03 wires the modules.
</action>
<verify>
<automated>test -f build-logic/settings.gradle.kts && test -f build-logic/build.gradle.kts && test -f build-logic/src/main/kotlin/recipe.quality.gradle.kts && test -f build-logic/src/main/kotlin/recipe.kotlin.multiplatform.gradle.kts && test -f build-logic/src/main/kotlin/recipe.compose.multiplatform.gradle.kts && test -f build-logic/src/main/kotlin/recipe.android.application.gradle.kts && test -f build-logic/src/main/kotlin/recipe.jvm.server.gradle.kts && grep -q 'from(files("../gradle/libs.versions.toml"))' build-logic/settings.gradle.kts && grep -q '`kotlin-dsl`' build-logic/build.gradle.kts && grep -q 'asDependency' build-logic/build.gradle.kts && grep -q 'id("org.jetbrains.kotlin.multiplatform")' build-logic/src/main/kotlin/recipe.kotlin.multiplatform.gradle.kts && grep -q 'id("recipe.kotlin.multiplatform")' build-logic/src/main/kotlin/recipe.compose.multiplatform.gradle.kts && ! grep -q 'id("org.jetbrains.kotlin.multiplatform")' build-logic/src/main/kotlin/recipe.compose.multiplatform.gradle.kts && grep -q '"implementation"' build-logic/src/main/kotlin/recipe.jvm.server.gradle.kts && grep -q 'extensions.getByType<VersionCatalogsExtension>' build-logic/src/main/kotlin/recipe.kotlin.multiplatform.gradle.kts</automated>
</verify>
<acceptance_criteria>
- All 7 files exist at their declared paths
- `build-logic/settings.gradle.kts` contains literal `from(files("../gradle/libs.versions.toml"))`
- `build-logic/settings.gradle.kts` ends with `rootProject.name = "build-logic"`
- `build-logic/build.gradle.kts` contains `` `kotlin-dsl` `` (triple-backtick plugin alias)
- `build-logic/build.gradle.kts` defines the `Provider<PluginDependency>.asDependency()` extension function
- `build-logic/build.gradle.kts` has exactly 10 `compileOnly(libs.plugins.*.asDependency())` calls (kotlinMultiplatform, androidApplication, androidLibrary, composeMultiplatform, composeCompiler, composeHotReload, kotlinJvm, ktor, spotless, flywayPlugin)
- `recipe.kotlin.multiplatform.gradle.kts` contains `id("org.jetbrains.kotlin.multiplatform")` (exactly ONCE, in the plugins block)
- `recipe.kotlin.multiplatform.gradle.kts` contains `baseName = "ComposeApp"` (D-20 / PITFALL #10)
- `recipe.kotlin.multiplatform.gradle.kts` contains `jvmToolchain(21)` AND `JvmTarget.JVM_11` AND `JvmTarget.JVM_21` (D-08 split)
- `recipe.kotlin.multiplatform.gradle.kts` contains `allWarningsAsErrors.set(true)` at the `kotlin { compilerOptions { } }` extension level (D-11)
- `recipe.kotlin.multiplatform.gradle.kts` does NOT contain `js {` or `iosX64` (D-01 / D-02)
- `recipe.compose.multiplatform.gradle.kts` contains `id("recipe.kotlin.multiplatform")` AND does NOT contain `id("org.jetbrains.kotlin.multiplatform")` (PITFALL #2 guard)
- `recipe.compose.multiplatform.gradle.kts` contains `id("org.jetbrains.compose.hot-reload")` (preserves commit c50d747)
- `recipe.android.application.gradle.kts` contains `namespace = "dev.ulfrx.recipe"` (D-20)
- `recipe.android.application.gradle.kts` uses `libs.findVersion("android-compileSdk").get().toString().toInt()` (PITFALL #1)
- `recipe.jvm.server.gradle.kts` uses quoted `"implementation"` (not unquoted `implementation(...)` — quoted-config footgun)
- `recipe.jvm.server.gradle.kts` contains `cleanDisabled = true` (PITFALL #6 safety)
- `recipe.quality.gradle.kts` contains `targetExclude("**/build/**", "**/generated/**")` (avoids scanning generated Compose resources)
- Every precompiled plugin that reads the catalog contains `extensions.getByType<VersionCatalogsExtension>().named("libs")`
</acceptance_criteria>
<done>build-logic/ scaffold complete; all 7 files follow canonical patterns; no PITFALL #1/#2/#7/#9/#10 violations detectable via grep.</done>
</task>
<task type="auto">
<name>Task 2: Wire build-logic into root settings.gradle.kts and update root build.gradle.kts</name>
<files>settings.gradle.kts, build.gradle.kts</files>
<read_first>
- settings.gradle.kts (current 37-line content — target of edit)
- build.gradle.kts (current 12-line content — target of edit)
- .planning/phases/01-project-infrastructure-module-wiring/01-RESEARCH.md lines 749-767 (PITFALL #9 — includeBuild MUST be inside pluginManagement)
- .planning/phases/01-project-infrastructure-module-wiring/01-PATTERNS.md lines 510-572 (settings.gradle.kts + root build.gradle.kts deltas)
- .planning/phases/01-project-infrastructure-module-wiring/01-CONTEXT.md lines 107-109 (build-logic/ as included build — standard Gradle pattern)
</read_first>
<action>
Edit two files.
---
**Edit 1: `settings.gradle.kts`** — add `includeBuild("build-logic")` as the FIRST statement inside the existing `pluginManagement { }` block. Do NOT move or remove any other line.
The current `pluginManagement { }` block (lines 4-16 of the existing file) should become:
```kotlin
pluginManagement {
includeBuild("build-logic")
repositories {
google {
mavenContent {
includeGroupAndSubgroups("androidx")
includeGroupAndSubgroups("com.android")
includeGroupAndSubgroups("com.google")
}
}
mavenCentral()
gradlePluginPortal()
}
}
```
PITFALL #9 is load-bearing: `includeBuild` MUST be inside `pluginManagement { }`, NOT at top level, and NOT inside `dependencyResolutionManagement { }`. Placing it elsewhere means child modules cannot resolve `id("recipe.*")` plugin IDs.
Do NOT modify:
- Line 1: `rootProject.name = "recipe"`
- Line 2: `enableFeaturePreview("TYPESAFE_PROJECT_ACCESSORS")`
- `dependencyResolutionManagement { }` block
- `plugins { id("org.gradle.toolchains.foojay-resolver-convention") version "1.0.0" }`
- `include(":composeApp")`, `include(":server")`, `include(":shared")`
---
**Edit 2: `build.gradle.kts`** — append two new `alias(...) apply false` entries to the existing plugins block. Keep the existing 8 entries in their current order.
Result:
```kotlin
plugins {
// this is necessary to avoid the plugins to be loaded multiple times
// in each subproject's classloader
alias(libs.plugins.androidApplication) apply false
alias(libs.plugins.androidLibrary) apply false
alias(libs.plugins.composeHotReload) apply false
alias(libs.plugins.composeMultiplatform) apply false
alias(libs.plugins.composeCompiler) apply false
alias(libs.plugins.kotlinJvm) apply false
alias(libs.plugins.kotlinMultiplatform) apply false
alias(libs.plugins.ktor) apply false
alias(libs.plugins.spotless) apply false
alias(libs.plugins.flywayPlugin) apply false
}
```
Why the `apply false` entries: Gradle's plugin classloader uses these declarations as hints when the plugin is applied through an included-build's precompiled plugin. `recipe.quality` applies `com.diffplug.spotless` and `recipe.jvm.server` applies `org.flywaydb.flyway` — the root `apply false` entries ensure a single resolved classpath per plugin ID (per the existing template's comment).
</action>
<verify>
<automated>grep -q 'includeBuild("build-logic")' settings.gradle.kts && awk '/pluginManagement \{/,/^\}/' settings.gradle.kts | grep -q 'includeBuild("build-logic")' && ! awk '/dependencyResolutionManagement \{/,/^\}/' settings.gradle.kts | grep -q 'includeBuild' && grep -q 'alias(libs.plugins.spotless) apply false' build.gradle.kts && grep -q 'alias(libs.plugins.flywayPlugin) apply false' build.gradle.kts && grep -c 'apply false' build.gradle.kts | grep -q '^10$'</automated>
</verify>
<acceptance_criteria>
- `settings.gradle.kts` contains `includeBuild("build-logic")` exactly 1 time
- That `includeBuild("build-logic")` line appears INSIDE the `pluginManagement { ... }` block (verifiable: `awk '/pluginManagement \{/,/^\}/' settings.gradle.kts | grep -q 'includeBuild("build-logic")'`)
- `settings.gradle.kts` does NOT contain `includeBuild` anywhere else (NOT at top level, NOT in `dependencyResolutionManagement`)
- `settings.gradle.kts` still contains `rootProject.name = "recipe"` (unmodified line 1)
- `settings.gradle.kts` still contains `include(":composeApp")`, `include(":server")`, `include(":shared")` (unmodified)
- `build.gradle.kts` contains `alias(libs.plugins.spotless) apply false`
- `build.gradle.kts` contains `alias(libs.plugins.flywayPlugin) apply false`
- `grep -c 'apply false' build.gradle.kts` returns `10` (8 existing + 2 new)
- All 8 existing `alias(...)` lines are preserved
</acceptance_criteria>
<done>build-logic/ is discoverable as an included build for plugin resolution; root `build.gradle.kts` declares classloader hints for Spotless + Flyway.</done>
</task>
</tasks>
<threat_model>
## Trust Boundaries
| Boundary | Description |
|----------|-------------|
| Gradle build → build-logic/ (included build) | Same-repo; no external trust boundary. Precompiled plugins run in the Gradle daemon's JVM with full project access by design. |
| build-logic precompiled plugins → Maven Central + plugin portal | Inherits repository set from `build-logic/settings.gradle.kts.dependencyResolutionManagement` (google, mavenCentral, gradlePluginPortal). Pinned plugin versions via catalog aliases. |
## STRIDE Threat Register
| Threat ID | Category | Component | Disposition | Mitigation Plan |
|-----------|----------|-----------|-------------|-----------------|
| T-01-02-01 | Tampering (supply chain) | Precompiled plugin classpath | mitigate | Plugin versions resolved exclusively from catalog aliases via `asDependency()` — no literal versions leak into build-logic/build.gradle.kts. D-09 catalog-only rule enforced by Plan 07's `tools/verify-no-version-literals.sh`. |
| T-01-02-02 | Elevation of Privilege | `recipe.jvm.server` applying Flyway to non-server modules | mitigate | `recipe.jvm.server` is applied ONLY to `server/build.gradle.kts` (Plan 03). The plugin bundles `io.ktor.plugin` + `org.flywaydb.flyway` + Postgres JDBC — if accidentally applied to `composeApp`, AGP would fail at configuration time. Role-declaration design (D-06) makes misuse obvious. |
| T-01-02-03 | Tampering | `recipe.quality` Spotless scanning untrusted paths | accept | Spotless config restricted via `target("src/**/*.kt")` + `targetExclude("**/build/**", "**/generated/**")`. No execution of scanned code; ktlint is pure static analysis. |
| T-01-02-04 | Denial of Service | Misspelled plugin ID breaks entire root build | mitigate | Task 1 `<acceptance_criteria>` greps for exact plugin IDs and the `id("recipe.kotlin.multiplatform")` layering in `recipe.compose.multiplatform.gradle.kts`. Plan 03's `./gradlew help` invocations will surface any remaining typos immediately. |
</threat_model>
<verification>
Phase-level verification for this plan:
- `tools/verify-no-version-literals.sh` still exits 0 (build-logic/build.gradle.kts is explicitly excluded by the script — the `asDependency()` coordinates contain a version string as part of the synthesized artifact coord, but the script excludes that single file).
- No Gradle command is run yet — Plan 03 refactors modules to apply these plugins; until then, the root `./gradlew build` will still work against the EXISTING module build files (which have not yet been refactored).
Optional fast sanity check (if needed):
- `./gradlew --help` exits 0 (proves `settings.gradle.kts` still parses).
- `./gradlew help` (without args) exits 0 (proves `includeBuild` is legal).
These sanity checks are NOT in the `<automated>` verify blocks to keep them fast; run them once manually if a later plan fails unexpectedly.
</verification>
<success_criteria>
- 7 files under `build-logic/` created with canonical content (exact path listing in `files_modified`)
- `settings.gradle.kts` has `includeBuild("build-logic")` inside `pluginManagement { }`
- `build.gradle.kts` has 10 `apply false` entries (8 existing + 2 new for Spotless + Flyway)
- No existing version aliases or source files modified in Plan 01 or prior
- `tools/verify-no-version-literals.sh` continues to exit 0
</success_criteria>
<output>
After completion, create `.planning/phases/01-project-infrastructure-module-wiring/01-02-SUMMARY.md` recording: file tree under `build-logic/`, any deviations from canonical excerpts (expected: none), and the final plugin ID list (10 applies from recipe-family + spotless/flyway).
</output>

View File

@@ -0,0 +1,352 @@
---
phase: 01-project-infrastructure-module-wiring
plan: 03
type: execute
wave: 2
depends_on: [01, 02]
files_modified:
- composeApp/build.gradle.kts
- shared/build.gradle.kts
- server/build.gradle.kts
- shared/src/jsMain
autonomous: true
requirements: [INFRA-02, INFRA-06]
requirements_addressed: [INFRA-02, INFRA-06]
must_haves:
truths:
- "composeApp/build.gradle.kts applies recipe.kotlin.multiplatform + recipe.compose.multiplatform + recipe.android.application + recipe.quality, and nothing else (D-06 role declaration)"
- "shared/build.gradle.kts applies recipe.kotlin.multiplatform + recipe.quality + androidLibrary alias, with explicitApi() set directly in the module (D-12)"
- "server/build.gradle.kts applies recipe.jvm.server + recipe.quality, and keeps only the module-specific application { } block"
- "The js target is removed from composeApp and shared (D-01); shared/src/jsMain/ directory is deleted"
- "iosX64 target is never referenced (D-02) — only iosArm64 + iosSimulatorArm64 via the convention plugin"
- "No version literals exist in any *.gradle.kts outside gradle/libs.versions.toml (INFRA-01 / D-09)"
- "shared/ framework basename is overridden to 'Shared' (D-07, PITFALL #10); composeApp keeps 'ComposeApp' from the convention plugin"
artifacts:
- path: "composeApp/build.gradle.kts"
provides: "Module build applying 4 recipe.* convention plugins + module-only source-set deps (androidMain, commonMain projects.shared, jvmMain desktop)"
min_lines: 15
- path: "shared/build.gradle.kts"
provides: "Module build applying recipe.kotlin.multiplatform + recipe.quality + androidLibrary; enabling explicitApi(); overriding framework baseName to 'Shared'; keeping android { namespace } block"
min_lines: 15
- path: "server/build.gradle.kts"
provides: "Module build applying recipe.jvm.server + recipe.quality; keeping application { mainClass } block and implementation(projects.shared) dep"
min_lines: 10
key_links:
- from: "composeApp/build.gradle.kts"
to: "build-logic/src/main/kotlin/recipe.kotlin.multiplatform.gradle.kts"
via: "plugins { id(\"recipe.kotlin.multiplatform\") }"
pattern: "id\\(\"recipe\\.kotlin\\.multiplatform\"\\)"
- from: "composeApp/build.gradle.kts"
to: "build-logic/src/main/kotlin/recipe.compose.multiplatform.gradle.kts"
via: "plugins { id(\"recipe.compose.multiplatform\") }"
pattern: "id\\(\"recipe\\.compose\\.multiplatform\"\\)"
- from: "server/build.gradle.kts"
to: "build-logic/src/main/kotlin/recipe.jvm.server.gradle.kts"
via: "plugins { id(\"recipe.jvm.server\") }"
pattern: "id\\(\"recipe\\.jvm\\.server\"\\)"
---
<objective>
Refactor the three module build scripts (`composeApp/`, `shared/`, `server/`) to apply the convention plugins from Plan 02 and remove the content those plugins now own. Drop the `js` target (D-01), confirm `iosX64` stays absent (D-02), add `explicitApi()` + framework-basename override to `shared/` (D-12 / PITFALL #10), and ensure every module's `plugins { }` block reads as a role declaration (D-06). Also delete the `shared/src/jsMain/` source directory (D-01).
Purpose: This plan delivers INFRA-02's structural payoff — adding a new KMP module in the future should require only `plugins { id("recipe.kotlin.multiplatform") }` + source-set declarations, not copy-pasting Compose configs. It also delivers INFRA-06's structural prerequisite: after this refactor, `shared/` no longer pulls Compose transitively (because `recipe.compose.multiplatform` is applied only to `composeApp/`).
Output: Three rewritten `build.gradle.kts` files (each ≤40 lines), `shared/src/jsMain/` directory deleted. No `./gradlew build` run in this plan — Plan 04/05 verify via their own targets, Plan 07 runs the full green-build gate.
</objective>
<execution_context>
@$HOME/.claude/get-shit-done/workflows/execute-plan.md
@$HOME/.claude/get-shit-done/templates/summary.md
</execution_context>
<context>
@.planning/PROJECT.md
@.planning/ROADMAP.md
@.planning/STATE.md
@.planning/phases/01-project-infrastructure-module-wiring/01-CONTEXT.md
@.planning/phases/01-project-infrastructure-module-wiring/01-RESEARCH.md
@.planning/phases/01-project-infrastructure-module-wiring/01-PATTERNS.md
@.planning/phases/01-project-infrastructure-module-wiring/01-VALIDATION.md
@composeApp/build.gradle.kts
@shared/build.gradle.kts
@server/build.gradle.kts
@CLAUDE.md
<interfaces>
<!-- Plans 01 + 02 must be complete before this plan runs. -->
From gradle/libs.versions.toml (Plan 01 extended):
- `libs.plugins.androidLibrary` — still referenced as alias inside shared/build.gradle.kts
- `libs.compose.uiToolingPreview` — referenced from composeApp/build.gradle.kts module-specific deps
- `libs.androidx.activity.compose` — referenced from composeApp androidMain deps
- `libs.kotlinx.coroutinesSwing` — referenced from composeApp jvmMain deps
- `libs.compose.uiTooling` — referenced from composeApp debugImplementation
- `libs.koin.android` — NEW alias (Plan 01) for MainApplication's `androidContext(...)` in Plan 04
From build-logic/src/main/kotlin/ (Plan 02 created):
- `recipe.kotlin.multiplatform` — applies KMP, sets D-05 targets, JVM toolchain, adds koin-bom/koin-core/kermit to commonMain, kotlin-test to commonTest, allWarningsAsErrors
- `recipe.compose.multiplatform` — applies Compose MP + hot-reload on top of KMP, adds compose-* deps to commonMain
- `recipe.android.application` — applies com.android.application, sets namespace + SDK versions
- `recipe.jvm.server` — applies kotlin(jvm) + io.ktor.plugin + flyway + all server deps + quoted-config dependency block
- `recipe.quality` — applies Spotless + allWarningsAsErrors safety net
</interfaces>
</context>
<tasks>
<task type="auto">
<name>Task 1: Rewrite composeApp/build.gradle.kts and shared/build.gradle.kts, delete shared/src/jsMain/</name>
<files>composeApp/build.gradle.kts, shared/build.gradle.kts, shared/src/jsMain</files>
<read_first>
- composeApp/build.gradle.kts (current 114 lines — target of rewrite)
- shared/build.gradle.kts (current 55 lines — target of rewrite)
- .planning/phases/01-project-infrastructure-module-wiring/01-PATTERNS.md lines 574-672 (exact deltas for composeApp + shared)
- .planning/phases/01-project-infrastructure-module-wiring/01-CONTEXT.md D-01 (drop js), D-03 (no desktop packaging), D-12 (explicitApi on shared only), D-20 (namespace + baseName)
- .planning/phases/01-project-infrastructure-module-wiring/01-RESEARCH.md lines 1144-1155 (Open Question #1 — keep com.android.library on shared/ in Phase 1)
</read_first>
<action>
Two file rewrites plus one directory deletion.
**Rewrite 1: `composeApp/build.gradle.kts`** — replace entire file content with:
```kotlin
plugins {
id("recipe.kotlin.multiplatform")
id("recipe.compose.multiplatform")
id("recipe.android.application")
id("recipe.quality")
}
kotlin {
sourceSets {
androidMain.dependencies {
implementation(libs.compose.uiToolingPreview)
implementation(libs.androidx.activity.compose)
implementation(libs.koin.android)
}
commonMain.dependencies {
implementation(libs.compose.uiToolingPreview)
implementation(projects.shared)
}
jvmMain.dependencies {
implementation(compose.desktop.currentOs)
implementation(libs.kotlinx.coroutinesSwing)
}
}
}
dependencies {
debugImplementation(libs.compose.uiTooling)
}
```
DELETIONS relative to the current file:
- DROP all 3 imports on lines 1-3 (no longer needed — convention plugins supply JvmTarget/ExperimentalWasmDsl/TargetFormat)
- DROP the original `plugins { alias(...) alias(...) }` block (lines 5-11) — replaced with 4 recipe.* IDs
- DROP the `kotlin { androidTarget { ... } iosArm64() iosSimulatorArm64() jvm { ... } js { ... } wasmJs { ... } }` structural block (lines 13-46) — moved into `recipe.kotlin.multiplatform`
- DROP `commonMain.dependencies` Compose entries (lines 52-62 — compose.runtime, compose.foundation, compose.material3, compose.ui, compose.components.resources, androidx.lifecycle.viewmodelCompose, androidx.lifecycle.runtimeCompose) — moved into `recipe.compose.multiplatform`. KEEP `implementation(projects.shared)` and the module-only `implementation(libs.compose.uiToolingPreview)` (the preview tooling is needed by `@Preview` annotations in composeApp's common code).
- DROP `commonTest.dependencies { implementation(libs.kotlin.test) }` (lines 63-65) — moved into `recipe.kotlin.multiplatform`
- DROP the entire `android { ... }` block (lines 73-98) — moved into `recipe.android.application`
- DROP `compose.desktop { application { ... nativeDistributions { ... } } }` (lines 104-114) — D-03 says no desktop packaging
ADDITIONS:
- ADD `implementation(libs.koin.android)` to `androidMain.dependencies` (Plan 04's MainApplication.kt calls `androidContext(...)` which comes from koin-android; the catalog alias was added in Plan 01).
KEEP:
- `androidMain.dependencies { implementation(libs.compose.uiToolingPreview); implementation(libs.androidx.activity.compose) }` — Android-only deps
- `jvmMain.dependencies { implementation(compose.desktop.currentOs); implementation(libs.kotlinx.coroutinesSwing) }` — Desktop-only deps
- `dependencies { debugImplementation(libs.compose.uiTooling) }` — Android debug-only tooling
**Rewrite 2: `shared/build.gradle.kts`** — replace entire file content with:
```kotlin
plugins {
id("recipe.kotlin.multiplatform")
id("recipe.quality")
alias(libs.plugins.androidLibrary)
}
kotlin {
explicitApi()
// Override framework baseName: shared exposes "Shared.framework" to Swift, while
// composeApp's convention-plugin default is "ComposeApp.framework". (D-07 / PITFALL #10)
targets.withType<org.jetbrains.kotlin.gradle.plugin.mpp.KotlinNativeTarget>().configureEach {
binaries.withType<org.jetbrains.kotlin.gradle.plugin.mpp.Framework>().configureEach {
baseName = "Shared"
}
}
sourceSets {
commonMain.dependencies {
// Phase 1: intentionally empty. Domain models + DTOs land Phase 2+.
// D-19 / INFRA-06: Do NOT add Ktor, Compose, or SQLDelight deps here — EVER.
}
}
}
android {
namespace = "dev.ulfrx.recipe.shared"
compileSdk = libs.versions.android.compileSdk.get().toInt()
compileOptions {
sourceCompatibility = JavaVersion.VERSION_11
targetCompatibility = JavaVersion.VERSION_11
}
defaultConfig {
minSdk = libs.versions.android.minSdk.get().toInt()
}
}
```
DELETIONS relative to the current file:
- DROP both imports on lines 1-2 (no longer needed)
- DROP the original `plugins { alias(libs.plugins.kotlinMultiplatform); alias(libs.plugins.androidLibrary) }` (lines 4-7) — replaced with `id("recipe.kotlin.multiplatform")` + kept `alias(libs.plugins.androidLibrary)`
- DROP the entire `kotlin { androidTarget { ... } iosArm64() iosSimulatorArm64() jvm { ... } js { ... } wasmJs { ... } ... }` structural block (lines 9-41) — moved into `recipe.kotlin.multiplatform`
- DROP `js { browser() }` (lines 25-27) — D-01
ADDITIONS:
- ADD `explicitApi()` inside the `kotlin { }` block (D-12 — strict on shared/ only, configured directly in module)
- ADD the framework baseName override block targeting `KotlinNativeTarget`/`Framework` (overrides the convention plugin's `"ComposeApp"` default to `"Shared"` — D-07 / PITFALL #10)
KEEP:
- `android { namespace = "dev.ulfrx.recipe.shared"; compileSdk; compileOptions; defaultConfig.minSdk }` — per 01-RESEARCH.md Open Question #1, keep `com.android.library` applied in Phase 1 (deferring the "do we need it" question to a future `recipe.android.library` plugin)
Note on `libs.versions.android.compileSdk.get().toInt()` vs `libs.findVersion(...)`: the `libs.versions.*` accessor IS available in MODULE `build.gradle.kts` files (it's only unavailable in precompiled plugins — PITFALL #1 applies only there). So the typed accessor is correct here.
**Directory deletion: `shared/src/jsMain/`**
Delete the entire `shared/src/jsMain/` directory (contains `kotlin/dev/ulfrx/recipe/Platform.js.kt`). D-01 drops the `js` target; with `recipe.kotlin.multiplatform` no longer declaring `js()`, this source directory becomes orphaned.
Run: `rm -rf shared/src/jsMain`
Do NOT delete `shared/src/wasmJsMain/` — `wasmJs` is kept per D-01. `composeApp/src/webMain/` is the wasmJs source set, also kept.
</action>
<verify>
<automated>grep -q 'id("recipe.kotlin.multiplatform")' composeApp/build.gradle.kts && grep -q 'id("recipe.compose.multiplatform")' composeApp/build.gradle.kts && grep -q 'id("recipe.android.application")' composeApp/build.gradle.kts && grep -q 'id("recipe.quality")' composeApp/build.gradle.kts && ! grep -q 'androidTarget' composeApp/build.gradle.kts && ! grep -q 'iosArm64' composeApp/build.gradle.kts && ! grep -q 'js {' composeApp/build.gradle.kts && ! grep -q 'nativeDistributions' composeApp/build.gradle.kts && ! grep -q '^android {' composeApp/build.gradle.kts && grep -q 'implementation(libs.koin.android)' composeApp/build.gradle.kts && grep -q 'id("recipe.kotlin.multiplatform")' shared/build.gradle.kts && grep -q 'id("recipe.quality")' shared/build.gradle.kts && grep -q 'explicitApi()' shared/build.gradle.kts && grep -q 'baseName = "Shared"' shared/build.gradle.kts && ! grep -q 'js {' shared/build.gradle.kts && ! test -d shared/src/jsMain && bash tools/verify-no-version-literals.sh && bash tools/verify-shared-pure.sh</automated>
</verify>
<acceptance_criteria>
- `composeApp/build.gradle.kts` has exactly 4 `id("recipe.*")` lines: `recipe.kotlin.multiplatform`, `recipe.compose.multiplatform`, `recipe.android.application`, `recipe.quality`
- `composeApp/build.gradle.kts` does NOT contain `androidTarget`, `iosArm64`, `iosSimulatorArm64`, `jvm {`, `js {`, `wasmJs {`, or any `binaries.framework` block (all moved to convention plugin)
- `composeApp/build.gradle.kts` does NOT contain an `^android {` block header (moved to `recipe.android.application`)
- `composeApp/build.gradle.kts` does NOT contain `nativeDistributions` or `compose.desktop { application { ... } }` (D-03)
- `composeApp/build.gradle.kts` does NOT contain `import org.jetbrains.compose.desktop.application.dsl.TargetFormat` (D-03)
- `composeApp/build.gradle.kts` contains `implementation(libs.koin.android)` inside an `androidMain.dependencies` block
- `composeApp/build.gradle.kts` contains `implementation(projects.shared)` in `commonMain.dependencies` (preserved for Plan 04 usage)
- `composeApp/build.gradle.kts` line count ≤ 30 (was 114)
- `shared/build.gradle.kts` has `id("recipe.kotlin.multiplatform")` + `id("recipe.quality")` + `alias(libs.plugins.androidLibrary)` (exactly 3 plugin applications)
- `shared/build.gradle.kts` contains `explicitApi()` (D-12)
- `shared/build.gradle.kts` contains `baseName = "Shared"` (exactly that capitalization — PITFALL #10)
- `shared/build.gradle.kts` does NOT contain `js {` or `iosX64`
- `shared/build.gradle.kts` contains the `android { namespace = "dev.ulfrx.recipe.shared" }` block (kept per Open Question #1)
- `shared/src/jsMain` directory no longer exists (`test ! -d shared/src/jsMain`)
- `tools/verify-no-version-literals.sh` exits 0 (no version literals leaked during rewrite)
- `tools/verify-shared-pure.sh` exits 0 (shared/commonMain has only Greeting.kt/Platform.kt/Constants.kt — no forbidden imports)
</acceptance_criteria>
<done>Both module builds apply recipe.* conventions; js target source dir deleted; explicitApi + Shared basename set on shared/.</done>
</task>
<task type="auto">
<name>Task 2: Rewrite server/build.gradle.kts</name>
<files>server/build.gradle.kts</files>
<read_first>
- server/build.gradle.kts (current 23 lines — target of rewrite)
- .planning/phases/01-project-infrastructure-module-wiring/01-PATTERNS.md lines 674-706 (server/build.gradle.kts delta)
- .planning/phases/01-project-infrastructure-module-wiring/01-RESEARCH.md lines 556-605 (§ Pattern 7 — what's ALREADY in the convention plugin and does NOT need to be in the module)
- .planning/phases/01-project-infrastructure-module-wiring/01-CONTEXT.md D-16 (server scope — Flyway, Postgres, /health)
</read_first>
<action>
Replace the entire content of `server/build.gradle.kts` with:
```kotlin
plugins {
id("recipe.jvm.server")
id("recipe.quality")
}
group = "dev.ulfrx.recipe"
version = "1.0.0"
application {
mainClass.set("dev.ulfrx.recipe.ApplicationKt")
val isDevelopment: Boolean = project.ext.has("development")
applicationDefaultJvmArgs = listOf("-Dio.ktor.development=$isDevelopment")
}
dependencies {
implementation(projects.shared)
}
```
DELETIONS:
- DROP original plugins block (lines 1-5 — `alias(libs.plugins.kotlinJvm); alias(libs.plugins.ktor); application`) → replaced with 2 recipe.* IDs. The `application` plugin is applied by `recipe.jvm.server`.
- DROP individual dependency lines (lines 16-22 — `libs.logback`, `libs.ktor.serverCore`, `libs.ktor.serverNetty`, `libs.ktor.serverTestHost`, `libs.kotlin.testJunit`) → all moved into `recipe.jvm.server`.
KEEP:
- `group = "dev.ulfrx.recipe"` and `version = "1.0.0"` (module coordinates — per-module concern)
- `application { mainClass.set(...) }` + `applicationDefaultJvmArgs` (per-module config — the `application` plugin is applied by `recipe.jvm.server` but this config is module-specific)
- `implementation(projects.shared)` — module-specific project dependency (server depends on shared for Greeting, SERVER_PORT, future DTOs)
Note: `ktor-serverContentNegotiation`, `ktor-serializationKotlinxJson`, `flyway-core`, `flyway-database-postgresql`, `postgresql` are ALL bundled in `recipe.jvm.server` and do NOT need to be declared here.
</action>
<verify>
<automated>grep -q 'id("recipe.jvm.server")' server/build.gradle.kts && grep -q 'id("recipe.quality")' server/build.gradle.kts && ! grep -q 'libs.plugins.kotlinJvm' server/build.gradle.kts && ! grep -q 'libs.plugins.ktor' server/build.gradle.kts && grep -q 'mainClass.set("dev.ulfrx.recipe.ApplicationKt")' server/build.gradle.kts && grep -q 'implementation(projects.shared)' server/build.gradle.kts && ! grep -q 'libs.logback' server/build.gradle.kts && ! grep -q 'libs.ktor.serverCore' server/build.gradle.kts && bash tools/verify-no-version-literals.sh</automated>
</verify>
<acceptance_criteria>
- `server/build.gradle.kts` has exactly 2 `id("recipe.*")` lines: `recipe.jvm.server`, `recipe.quality`
- `server/build.gradle.kts` does NOT contain `alias(libs.plugins.kotlinJvm)` or `alias(libs.plugins.ktor)`
- `server/build.gradle.kts` does NOT contain `libs.logback`, `libs.ktor.serverCore`, `libs.ktor.serverNetty`, `libs.ktor.serverTestHost`, or `libs.kotlin.testJunit` (all relocated to convention plugin)
- `server/build.gradle.kts` contains `mainClass.set("dev.ulfrx.recipe.ApplicationKt")` (unchanged)
- `server/build.gradle.kts` contains `implementation(projects.shared)` (unchanged)
- `server/build.gradle.kts` contains `group = "dev.ulfrx.recipe"` and `version = "1.0.0"` (unchanged module coordinates)
- `server/build.gradle.kts` line count ≤ 20 (was 23; effectively unchanged but deps block shrinks)
- `tools/verify-no-version-literals.sh` exits 0
</acceptance_criteria>
<done>server build applies recipe.jvm.server + recipe.quality; module-only config (mainClass, shared dep) preserved.</done>
</task>
</tasks>
<threat_model>
## Trust Boundaries
| Boundary | Description |
|----------|-------------|
| Module build scripts → build-logic precompiled plugins | Same repo; plugins apply privileged build configuration (namespace, SDK versions, dep injection). No external trust boundary. |
| Gradle module configuration → dependency resolution | Same as Plan 02 — aliases resolved via `libs.versions.toml` (pinned); no runtime consequences until Plan 04/05 actually compile code. |
## STRIDE Threat Register
| Threat ID | Category | Component | Disposition | Mitigation Plan |
|-----------|----------|-----------|-------------|-----------------|
| T-01-03-01 | Tampering (supply-chain leak) | Accidental version literal in rewrites | mitigate | Every task's `<automated>` runs `tools/verify-no-version-literals.sh` which scans every `*.gradle.kts` after the rewrite. Any inlined version (e.g. a forgotten `"1.0.0"` as a dep version) fails the check. Note: `version = "1.0.0"` on `server/build.gradle.kts` line 2 is PROJECT coordinate, not a dependency version — the verify script targets `version\s*=\s*"[0-9]` inside dependency declarations only; project-version assignments pass (not declared as `libs.*` lookup). Verify script scope matches PATTERNS.md spec. |
| T-01-03-02 | Elevation of Privilege | Compose deps leak into shared/ | mitigate | `shared/build.gradle.kts` applies ONLY `recipe.kotlin.multiplatform` + `recipe.quality` + `androidLibrary` — NOT `recipe.compose.multiplatform`. Plan 07's `tools/verify-shared-pure.sh` will catch forbidden imports if they ever appear. |
| T-01-03-03 | Denial of Service | Missing `recipe.compose.multiplatform` application on composeApp breaks Compose | mitigate | Task 1 `<acceptance_criteria>` greps for all 4 recipe IDs explicitly. Plan 04 will fail at compile time if the Compose plugin ID is missing. |
| T-01-03-04 | Tampering | `js` target remnants in source tree after D-01 drop | mitigate | Task 1 explicitly deletes `shared/src/jsMain/` directory and greps for `js {` blocks. `composeApp/src/webMain/` (wasmJs target, kept) is NOT touched. |
</threat_model>
<verification>
Phase-level verification for this plan:
- All three `tools/verify-*.sh` scripts exit 0 after rewrites.
- `shared/src/jsMain/` directory no longer exists.
- `composeApp/build.gradle.kts` shrinks from 114 to ~30 lines — INFRA-02 payoff visible.
- `shared/build.gradle.kts` shrinks from 55 to ~35 lines and now sets `explicitApi()`.
Optional sanity check (NOT in `<automated>` — Plan 07 runs the full gate):
- `./gradlew :composeApp:help -q` emits a non-empty help output without a configuration error (proves plugin IDs resolve). Skip for speed — Plan 04 and Plan 05 will surface plugin-application errors via their own `./gradlew` targets.
</verification>
<success_criteria>
- `composeApp/build.gradle.kts` applies 4 recipe.* IDs and contains NO `kotlin { androidTarget { ... } ... }` structural block and NO `android { ... }` block and NO `nativeDistributions`
- `shared/build.gradle.kts` applies 3 plugins (2 recipe.* + androidLibrary), enables `explicitApi()`, overrides baseName to `"Shared"`
- `server/build.gradle.kts` applies 2 recipe.* IDs and keeps only `application { mainClass }` + `implementation(projects.shared)`
- `shared/src/jsMain/` deleted
- `tools/verify-no-version-literals.sh` exits 0
- `tools/verify-shared-pure.sh` exits 0
</success_criteria>
<output>
After completion, create `.planning/phases/01-project-infrastructure-module-wiring/01-03-SUMMARY.md` recording: final LOC of each module build file (target: composeApp ≤30, shared ≤35, server ≤20), any deviations from the canonical patterns (expected: none), and confirmation that `shared/src/jsMain/` is gone.
</output>

View File

@@ -0,0 +1,495 @@
---
phase: 01-project-infrastructure-module-wiring
plan: 04
type: execute
wave: 2
depends_on: [01, 02]
files_modified:
- composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/di/Koin.kt
- composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/di/AppModule.kt
- composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/logging/Logging.kt
- composeApp/src/iosMain/kotlin/dev/ulfrx/recipe/di/KoinIos.kt
- composeApp/src/androidMain/kotlin/dev/ulfrx/recipe/MainApplication.kt
- composeApp/src/androidMain/AndroidManifest.xml
- composeApp/src/jvmMain/kotlin/dev/ulfrx/recipe/main.kt
- composeApp/src/webMain/kotlin/dev/ulfrx/recipe/main.kt
- iosApp/iosApp/iOSApp.swift
autonomous: true
requirements: [INFRA-02]
requirements_addressed: [INFRA-02]
must_haves:
truths:
- "initKoin() is defined once in commonMain and called exactly once per platform entry point (no double-init — PITFALL #4)"
- "configureLogging() runs BEFORE initKoin() on every platform (so Koin module loading can use Kermit)"
- "App.kt (@Composable) never calls startKoin — Koin is started outside composition (anti-pattern guard in Pattern 4)"
- "appModule is an empty Koin module placeholder; Phase 2+ adds authModule, syncModule, etc."
- "Kermit tag is 'recipe' (D-15)"
- "iOS Swift side calls KoinIosKt.doInitKoin() inside iOSApp.init() — one call site"
- "Android uses MainApplication registered via android:name=\".MainApplication\" in AndroidManifest.xml"
- "wasmJs main() initializes Koin + logging BEFORE ComposeViewport { App() } (PITFALL #8 future-proof)"
artifacts:
- path: "composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/di/Koin.kt"
provides: "initKoin(config: KoinAppDeclaration? = null): KoinApplication helper invoking startKoin { modules(appModule) }"
exports: ["initKoin"]
- path: "composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/di/AppModule.kt"
provides: "Empty val appModule = module { } placeholder"
exports: ["appModule"]
- path: "composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/logging/Logging.kt"
provides: "configureLogging() — Logger.setTag(\"recipe\")"
exports: ["configureLogging"]
- path: "composeApp/src/iosMain/kotlin/dev/ulfrx/recipe/di/KoinIos.kt"
provides: "fun doInitKoin() { configureLogging(); initKoin() } — exported as Swift symbol KoinIosKt.doInitKoin"
exports: ["doInitKoin"]
- path: "composeApp/src/androidMain/kotlin/dev/ulfrx/recipe/MainApplication.kt"
provides: "class MainApplication : Application() { onCreate → configureLogging(); initKoin { androidContext(this) } }"
- path: "composeApp/src/androidMain/AndroidManifest.xml"
provides: "<application android:name=\".MainApplication\" ...>"
contains: "android:name=\".MainApplication\""
- path: "composeApp/src/jvmMain/kotlin/dev/ulfrx/recipe/main.kt"
provides: "Desktop main() invoking configureLogging() + initKoin() before application { Window { App() } }"
- path: "composeApp/src/webMain/kotlin/dev/ulfrx/recipe/main.kt"
provides: "Wasm main() invoking configureLogging() + initKoin() before ComposeViewport { App() } (PITFALL #8)"
- path: "iosApp/iosApp/iOSApp.swift"
provides: "Swift @main struct with init() { KoinIosKt.doInitKoin() } and import ComposeApp"
contains: "import ComposeApp", "KoinIosKt.doInitKoin()"
key_links:
- from: "iosApp/iosApp/iOSApp.swift"
to: "composeApp/src/iosMain/kotlin/dev/ulfrx/recipe/di/KoinIos.kt"
via: "Kotlin top-level fun doInitKoin → Swift symbol KoinIosKt.doInitKoin()"
pattern: "KoinIosKt\\.doInitKoin\\(\\)"
- from: "composeApp/src/androidMain/AndroidManifest.xml"
to: "composeApp/src/androidMain/kotlin/dev/ulfrx/recipe/MainApplication.kt"
via: "android:name=\".MainApplication\" attribute on <application>"
pattern: "android:name=\"\\.MainApplication\""
- from: "MainApplication.onCreate / iOSApp.init / jvm main / wasm main"
to: "composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/di/Koin.kt"
via: "initKoin() call"
pattern: "initKoin\\("
---
<objective>
Wire the Koin + Kermit bootstrap across every composeApp platform entry point. Create the two commonMain source files (`di/Koin.kt`, `di/AppModule.kt`, `logging/Logging.kt`), the iOS Kotlin bridge (`iosMain/di/KoinIos.kt`), the Android `Application` subclass + manifest registration, modify the JVM + Wasm entry points to call `configureLogging() → initKoin()` before composition, and modify Swift's `iOSApp.swift` to call `KoinIosKt.doInitKoin()` inside `init()`. The Kermit tag is `"recipe"` (D-15); the Koin module is an empty placeholder (D-14) that Phase 2+ extends.
Purpose: Phase 1 proves the DI + logging wiring is correct from day 1 so Phase 2 (Auth) can add `authModule`, Phase 4 can add `syncModule`, etc. without revisiting the bootstrap mechanics. PITFALL #4 (double-init on iOS) is neutralized by concentrating all startup into one `initKoin()` helper with a single call site per platform.
Output: 9 files created or modified (6 new Kotlin files, 1 manifest edit, 2 existing entry-point rewrites, 1 Swift rewrite). No ViewModels yet — Phase 1 has no screens beyond the template.
</objective>
<execution_context>
@$HOME/.claude/get-shit-done/workflows/execute-plan.md
@$HOME/.claude/get-shit-done/templates/summary.md
</execution_context>
<context>
@.planning/PROJECT.md
@.planning/ROADMAP.md
@.planning/STATE.md
@.planning/phases/01-project-infrastructure-module-wiring/01-CONTEXT.md
@.planning/phases/01-project-infrastructure-module-wiring/01-RESEARCH.md
@.planning/phases/01-project-infrastructure-module-wiring/01-PATTERNS.md
@.planning/phases/01-project-infrastructure-module-wiring/01-VALIDATION.md
@composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/App.kt
@composeApp/src/androidMain/kotlin/dev/ulfrx/recipe/MainActivity.kt
@composeApp/src/androidMain/AndroidManifest.xml
@composeApp/src/iosMain/kotlin/dev/ulfrx/recipe/MainViewController.kt
@composeApp/src/jvmMain/kotlin/dev/ulfrx/recipe/main.kt
@composeApp/src/webMain/kotlin/dev/ulfrx/recipe/main.kt
@iosApp/iosApp/iOSApp.swift
@CLAUDE.md
<interfaces>
<!-- These come from the Koin library (already wired via recipe.kotlin.multiplatform in Plan 02) -->
From io.insert-koin:koin-core:
```kotlin
fun startKoin(appDeclaration: KoinAppDeclaration): KoinApplication // top-level
interface KoinApplication
typealias KoinAppDeclaration = KoinApplication.() -> Unit
```
From io.insert-koin:koin-dsl:
```kotlin
fun module(createdAtStart: Boolean = false, moduleDeclaration: ModuleDeclaration): Module
```
From io.insert-koin:koin-android (androidMain only):
```kotlin
// package org.koin.android.ext.koin
fun KoinApplication.androidContext(context: Context): KoinApplication
```
From co.touchlab:kermit:
```kotlin
object Logger {
fun setTag(tag: String)
// plus .i { }, .d { }, .e { }, .w { } methods on Logger companion
}
```
From existing composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/App.kt (do NOT modify):
```kotlin
@Composable
@Preview
fun App() { /* template body — stays as-is */ }
```
From existing composeApp/src/androidMain/kotlin/dev/ulfrx/recipe/MainActivity.kt (do NOT modify — sibling reference):
```kotlin
package dev.ulfrx.recipe
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
// class MainActivity : ComponentActivity() { ... setContent { App() } }
```
From existing composeApp/src/iosMain/kotlin/dev/ulfrx/recipe/MainViewController.kt (do NOT modify):
```kotlin
package dev.ulfrx.recipe
import androidx.compose.ui.window.ComposeUIViewController
fun MainViewController() = ComposeUIViewController { App() }
```
Current Android manifest shape (attributes to preserve when adding android:name):
```xml
<application
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">
```
Current iOS Swift entry (to replace):
```swift
import SwiftUI
@main
struct iOSApp: App {
var body: some Scene { WindowGroup { ContentView() } }
}
```
</interfaces>
</context>
<tasks>
<task type="auto">
<name>Task 1: Create commonMain DI + logging files and iOS Kotlin bridge</name>
<files>composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/di/Koin.kt, composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/di/AppModule.kt, composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/logging/Logging.kt, composeApp/src/iosMain/kotlin/dev/ulfrx/recipe/di/KoinIos.kt</files>
<read_first>
- .planning/phases/01-project-infrastructure-module-wiring/01-RESEARCH.md lines 840-870 (Koin bootstrap canonical excerpts: initKoin + appModule + doInitKoin)
- .planning/phases/01-project-infrastructure-module-wiring/01-RESEARCH.md lines 933-948 (Kermit bootstrap: Logger.setTag + init order)
- .planning/phases/01-project-infrastructure-module-wiring/01-RESEARCH.md lines 675-690 (PITFALL #4 — single call site per platform, never from inside @Composable)
- .planning/phases/01-project-infrastructure-module-wiring/01-PATTERNS.md lines 710-796 (pattern assignments for all 4 files)
- .planning/phases/01-project-infrastructure-module-wiring/01-CONTEXT.md D-14 (Koin empty appModule), D-15 (Kermit tag "recipe")
</read_first>
<action>
Create 4 new files.
**File 1: `composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/di/Koin.kt`**:
```kotlin
package dev.ulfrx.recipe.di
import org.koin.core.KoinApplication
import org.koin.core.context.startKoin
import org.koin.dsl.KoinAppDeclaration
fun initKoin(config: KoinAppDeclaration? = null): KoinApplication = startKoin {
config?.invoke(this)
modules(appModule)
}
```
**File 2: `composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/di/AppModule.kt`**:
```kotlin
package dev.ulfrx.recipe.di
import org.koin.dsl.module
// Phase 2 adds authModule; Phase 4 adds syncModule; Phase 5 adds catalogModule; etc.
val appModule = module {
// intentionally empty in Phase 1
}
```
**File 3: `composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/logging/Logging.kt`**:
```kotlin
package dev.ulfrx.recipe.logging
import co.touchlab.kermit.Logger
fun configureLogging() {
Logger.setTag("recipe")
// Platform log writers (OSLog iOS, LogCat Android, System.out JVM/Wasm) install by default.
}
```
**File 4: `composeApp/src/iosMain/kotlin/dev/ulfrx/recipe/di/KoinIos.kt`**:
```kotlin
package dev.ulfrx.recipe.di
import dev.ulfrx.recipe.logging.configureLogging
fun doInitKoin() {
configureLogging()
initKoin()
}
```
CRITICAL notes (PITFALL #4 / #10):
- The top-level `fun doInitKoin()` in file `KoinIos.kt` becomes the Swift-accessible symbol `KoinIosKt.doInitKoin()` (Kotlin generates `<FileName>Kt` for top-level declarations).
- `doInitKoin()` is the SINGLE iOS entry point. `MainViewController()` (the `ComposeUIViewController` factory) must NOT call `startKoin` or `initKoin` — it assumes Koin is already started.
- `configureLogging()` runs BEFORE `initKoin()` so Koin module loading can use Kermit.
Do NOT add any expect/actual declarations — the iOS bridge is a plain top-level function, and Kermit's multiplatform Logger handles the platform-specific writer selection internally.
</action>
<verify>
<automated>test -f composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/di/Koin.kt && test -f composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/di/AppModule.kt && test -f composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/logging/Logging.kt && test -f composeApp/src/iosMain/kotlin/dev/ulfrx/recipe/di/KoinIos.kt && grep -q 'package dev.ulfrx.recipe.di' composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/di/Koin.kt && grep -q 'fun initKoin(config: KoinAppDeclaration? = null)' composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/di/Koin.kt && grep -q 'startKoin' composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/di/Koin.kt && grep -q 'modules(appModule)' composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/di/Koin.kt && grep -q 'val appModule = module' composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/di/AppModule.kt && grep -q 'Logger.setTag("recipe")' composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/logging/Logging.kt && grep -q 'fun doInitKoin' composeApp/src/iosMain/kotlin/dev/ulfrx/recipe/di/KoinIos.kt && grep -q 'configureLogging()' composeApp/src/iosMain/kotlin/dev/ulfrx/recipe/di/KoinIos.kt && grep -q 'initKoin()' composeApp/src/iosMain/kotlin/dev/ulfrx/recipe/di/KoinIos.kt</automated>
</verify>
<acceptance_criteria>
- `composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/di/Koin.kt` exists and contains package declaration `package dev.ulfrx.recipe.di`
- `Koin.kt` imports `org.koin.core.KoinApplication`, `org.koin.core.context.startKoin`, `org.koin.dsl.KoinAppDeclaration`
- `Koin.kt` defines exactly one top-level function `fun initKoin(config: KoinAppDeclaration? = null): KoinApplication` whose body is `startKoin { config?.invoke(this); modules(appModule) }`
- `AppModule.kt` exists and contains package declaration `package dev.ulfrx.recipe.di`
- `AppModule.kt` imports `org.koin.dsl.module`
- `AppModule.kt` declares `val appModule = module { }` (empty — D-14)
- `Logging.kt` exists and contains package declaration `package dev.ulfrx.recipe.logging`
- `Logging.kt` imports `co.touchlab.kermit.Logger`
- `Logging.kt` defines `fun configureLogging()` whose body calls `Logger.setTag("recipe")` (D-15 — exact string)
- `KoinIos.kt` exists and contains package declaration `package dev.ulfrx.recipe.di`
- `KoinIos.kt` imports `dev.ulfrx.recipe.logging.configureLogging`
- `KoinIos.kt` defines `fun doInitKoin()` whose body is `configureLogging(); initKoin()` in that exact order
- No file references `startKoin` directly outside `Koin.kt` (grep `startKoin` across composeApp/src returns only Koin.kt)
- `App.kt` is NOT modified (anti-pattern guard — startKoin never called from inside @Composable)
</acceptance_criteria>
<done>Koin + Kermit commonMain wiring is in place; iOS bridge exposes a Swift-callable `KoinIosKt.doInitKoin()`.</done>
</task>
<task type="auto">
<name>Task 2: Create MainApplication.kt + register in AndroidManifest.xml</name>
<files>composeApp/src/androidMain/kotlin/dev/ulfrx/recipe/MainApplication.kt, composeApp/src/androidMain/AndroidManifest.xml</files>
<read_first>
- composeApp/src/androidMain/AndroidManifest.xml (current 22-line content — target of edit)
- composeApp/src/androidMain/kotlin/dev/ulfrx/recipe/MainActivity.kt (sibling reference for androidMain package + imports)
- .planning/phases/01-project-infrastructure-module-wiring/01-RESEARCH.md lines 895-911 (canonical MainApplication.kt)
- .planning/phases/01-project-infrastructure-module-wiring/01-PATTERNS.md lines 800-849 (MainApplication + manifest deltas)
- composeApp/build.gradle.kts (verify `libs.koin.android` was added to androidMain.dependencies in Plan 03)
</read_first>
<action>
Create one new file and edit one existing file.
**Create: `composeApp/src/androidMain/kotlin/dev/ulfrx/recipe/MainApplication.kt`**:
```kotlin
package dev.ulfrx.recipe
import android.app.Application
import dev.ulfrx.recipe.di.initKoin
import dev.ulfrx.recipe.logging.configureLogging
import org.koin.android.ext.koin.androidContext
class MainApplication : Application() {
override fun onCreate() {
super.onCreate()
configureLogging()
initKoin {
androidContext(this@MainApplication)
}
}
}
```
CRITICAL:
- `package dev.ulfrx.recipe` (not `dev.ulfrx.recipe.android` — matches the existing `MainActivity.kt` sibling).
- `androidContext(this@MainApplication)` — the qualified `this` is required because the `initKoin { ... }` lambda's `this` is a `KoinApplication`, not the Application.
- `configureLogging()` runs FIRST, then `initKoin { ... }` — establishes the required order (PATTERNS.md "Init order on every platform entry").
- `org.koin.android.ext.koin.androidContext` comes from `io.insert-koin:koin-android` (catalog alias `libs.koin.android`, added to `composeApp/build.gradle.kts` androidMain deps in Plan 03).
**Edit: `composeApp/src/androidMain/AndroidManifest.xml`** — add `android:name=".MainApplication"` as the first attribute on the `<application>` element. Do NOT modify any other attribute or element.
Resulting `<application>` tag:
```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">
```
The `<activity>` child element (with `android:name=".MainActivity"`) stays unchanged. The full XML structure (declarations, `<manifest>`, `<intent-filter>`) is preserved — only the single `android:name=".MainApplication"` attribute is added.
</action>
<verify>
<automated>test -f composeApp/src/androidMain/kotlin/dev/ulfrx/recipe/MainApplication.kt && grep -q '^package dev.ulfrx.recipe$' composeApp/src/androidMain/kotlin/dev/ulfrx/recipe/MainApplication.kt && grep -q 'class MainApplication : Application()' composeApp/src/androidMain/kotlin/dev/ulfrx/recipe/MainApplication.kt && grep -q 'override fun onCreate()' composeApp/src/androidMain/kotlin/dev/ulfrx/recipe/MainApplication.kt && grep -q 'configureLogging()' composeApp/src/androidMain/kotlin/dev/ulfrx/recipe/MainApplication.kt && grep -q 'androidContext(this@MainApplication)' composeApp/src/androidMain/kotlin/dev/ulfrx/recipe/MainApplication.kt && grep -q 'android:name="\.MainApplication"' composeApp/src/androidMain/AndroidManifest.xml && grep -q 'android:name="\.MainActivity"' composeApp/src/androidMain/AndroidManifest.xml</automated>
</verify>
<acceptance_criteria>
- `composeApp/src/androidMain/kotlin/dev/ulfrx/recipe/MainApplication.kt` exists
- Package declaration is exactly `package dev.ulfrx.recipe` (matches sibling `MainActivity.kt`)
- Imports include `android.app.Application`, `dev.ulfrx.recipe.di.initKoin`, `dev.ulfrx.recipe.logging.configureLogging`, `org.koin.android.ext.koin.androidContext`
- Class declaration is `class MainApplication : Application()`
- `onCreate()` body calls `super.onCreate()` first, then `configureLogging()`, then `initKoin { androidContext(this@MainApplication) }` — in exactly that order
- `composeApp/src/androidMain/AndroidManifest.xml` contains literal `android:name=".MainApplication"` attribute on the `<application>` element
- `composeApp/src/androidMain/AndroidManifest.xml` still contains `android:name=".MainActivity"` on the `<activity>` element (unchanged)
- `composeApp/src/androidMain/AndroidManifest.xml` still contains `<intent-filter>` with MAIN action + LAUNCHER category (unchanged)
- `composeApp/src/androidMain/AndroidManifest.xml` top-level `<manifest>` declaration unchanged
</acceptance_criteria>
<done>Android Application subclass starts Koin + Kermit on process boot; manifest registers the subclass.</done>
</task>
<task type="auto">
<name>Task 3: Wire JVM + Wasm main() entries and Swift iOSApp.swift</name>
<files>composeApp/src/jvmMain/kotlin/dev/ulfrx/recipe/main.kt, composeApp/src/webMain/kotlin/dev/ulfrx/recipe/main.kt, iosApp/iosApp/iOSApp.swift</files>
<read_first>
- composeApp/src/jvmMain/kotlin/dev/ulfrx/recipe/main.kt (current content — target of rewrite)
- composeApp/src/webMain/kotlin/dev/ulfrx/recipe/main.kt (current content — target of rewrite)
- iosApp/iosApp/iOSApp.swift (current 11-line content — target of rewrite)
- .planning/phases/01-project-infrastructure-module-wiring/01-RESEARCH.md lines 874-931 (Swift + Desktop + Wasm bootstrap)
- .planning/phases/01-project-infrastructure-module-wiring/01-RESEARCH.md lines 733-747 (PITFALL #8 — Wasm init order)
- .planning/phases/01-project-infrastructure-module-wiring/01-PATTERNS.md lines 852-937 (per-file deltas for these three files)
</read_first>
<action>
Replace three file contents.
**Replace: `composeApp/src/jvmMain/kotlin/dev/ulfrx/recipe/main.kt`**:
```kotlin
package dev.ulfrx.recipe
import androidx.compose.ui.window.Window
import androidx.compose.ui.window.application
import dev.ulfrx.recipe.di.initKoin
import dev.ulfrx.recipe.logging.configureLogging
fun main() {
configureLogging()
initKoin()
application {
Window(
onCloseRequest = ::exitApplication,
title = "recipe",
) {
App()
}
}
}
```
**Replace: `composeApp/src/webMain/kotlin/dev/ulfrx/recipe/main.kt`**:
```kotlin
package dev.ulfrx.recipe
import androidx.compose.ui.ExperimentalComposeUiApi
import androidx.compose.ui.window.ComposeViewport
import dev.ulfrx.recipe.di.initKoin
import dev.ulfrx.recipe.logging.configureLogging
@OptIn(ExperimentalComposeUiApi::class)
fun main() {
configureLogging()
initKoin()
ComposeViewport {
App()
}
}
```
CRITICAL (PITFALL #8): `configureLogging()` and `initKoin()` MUST run BEFORE `ComposeViewport { }` — otherwise the first `koinViewModel<X>()` inside composition throws. Phase 1 has no ViewModels, so this is defensive — but the shape must be correct from day 1.
**Replace: `iosApp/iosApp/iOSApp.swift`** (Swift file, not Kotlin):
```swift
import SwiftUI
import ComposeApp
@main
struct iOSApp: App {
init() {
KoinIosKt.doInitKoin()
}
var body: some Scene {
WindowGroup {
ContentView()
}
}
}
```
CRITICAL:
- `import ComposeApp` — matches the framework basename set in `recipe.kotlin.multiplatform` (D-20 / PITFALL #10). The existing file does NOT import ComposeApp; add it.
- `init() { KoinIosKt.doInitKoin() }` — the Swift symbol `KoinIosKt` is auto-generated from Kotlin file `KoinIos.kt` in package `dev.ulfrx.recipe.di` (created in Task 1).
- `ContentView()` invocation stays unchanged; `ContentView.swift` already calls `MainViewControllerKt.MainViewController()` which returns a `ComposeUIViewController` — do NOT modify `ContentView.swift`.
- Do NOT call `startKoin` from `MainViewController()` — iOS init is centralized in `iOSApp.init()` to avoid PITFALL #4.
</action>
<verify>
<automated>grep -q '^package dev.ulfrx.recipe$' composeApp/src/jvmMain/kotlin/dev/ulfrx/recipe/main.kt && grep -q 'configureLogging()' composeApp/src/jvmMain/kotlin/dev/ulfrx/recipe/main.kt && grep -q 'initKoin()' composeApp/src/jvmMain/kotlin/dev/ulfrx/recipe/main.kt && grep -q 'Window(' composeApp/src/jvmMain/kotlin/dev/ulfrx/recipe/main.kt && grep -q '^package dev.ulfrx.recipe$' composeApp/src/webMain/kotlin/dev/ulfrx/recipe/main.kt && grep -q 'configureLogging()' composeApp/src/webMain/kotlin/dev/ulfrx/recipe/main.kt && grep -q 'initKoin()' composeApp/src/webMain/kotlin/dev/ulfrx/recipe/main.kt && grep -q 'ComposeViewport' composeApp/src/webMain/kotlin/dev/ulfrx/recipe/main.kt && grep -q 'import ComposeApp' iosApp/iosApp/iOSApp.swift && grep -q 'KoinIosKt.doInitKoin()' iosApp/iosApp/iOSApp.swift && grep -q 'init() {' iosApp/iosApp/iOSApp.swift</automated>
</verify>
<acceptance_criteria>
- `composeApp/src/jvmMain/kotlin/dev/ulfrx/recipe/main.kt` has `configureLogging()` on a line preceding `initKoin()`, and both precede `application {` (init order invariant)
- JVM main imports include `dev.ulfrx.recipe.di.initKoin` AND `dev.ulfrx.recipe.logging.configureLogging`
- JVM main preserves `Window(onCloseRequest = ::exitApplication, title = "recipe") { App() }`
- `composeApp/src/webMain/kotlin/dev/ulfrx/recipe/main.kt` has `configureLogging()` on a line preceding `initKoin()`, and both precede `ComposeViewport {` (PITFALL #8)
- Web main imports include `dev.ulfrx.recipe.di.initKoin` AND `dev.ulfrx.recipe.logging.configureLogging`
- Web main still has `@OptIn(ExperimentalComposeUiApi::class)` on `fun main()`
- `iosApp/iosApp/iOSApp.swift` contains exactly `import SwiftUI` AND `import ComposeApp` (both imports required)
- `iosApp/iosApp/iOSApp.swift` contains `init() {` followed by `KoinIosKt.doInitKoin()` — exactly one call
- `iosApp/iosApp/iOSApp.swift` preserves `@main struct iOSApp: App { ... body: some Scene { WindowGroup { ContentView() } } }`
- `MainViewController.kt` is NOT modified (the existing file returns `ComposeUIViewController { App() }` — Koin bootstrapped outside, PITFALL #4)
- `App.kt` is NOT modified (anti-pattern guard)
</acceptance_criteria>
<done>All four platform entry points call `configureLogging()` then `initKoin()` before composition; iOS Swift wires `KoinIosKt.doInitKoin()` exactly once in `init()`.</done>
</task>
</tasks>
<threat_model>
## Trust Boundaries
| Boundary | Description |
|----------|-------------|
| Platform process start → DI container initialization | Each platform (Android onCreate, iOS App.init, JVM main, Wasm main) is a trusted bootstrap context; `initKoin()` is called once, from code we control. |
| Kotlin top-level fun → Swift generated symbol | `KoinIos.kt` in package `dev.ulfrx.recipe.di` is compiled into the `ComposeApp.framework` Swift binary as `KoinIosKt.doInitKoin()`. No runtime risk — compile-time symbol mapping. |
## STRIDE Threat Register
| Threat ID | Category | Component | Disposition | Mitigation Plan |
|-----------|----------|-----------|-------------|-----------------|
| T-01-04-01 | Denial of Service | Koin double-init on iOS second cold launch (PITFALL #4) | mitigate | Only `iOSApp.init()` calls `KoinIosKt.doInitKoin()`. `MainViewController.kt` does NOT call `startKoin`. Task 3 acceptance criteria explicitly prohibits `startKoin` in `MainViewController.kt`. If Koin is accidentally started twice, `KoinApplicationAlreadyStartedException` fires on launch — visible and easy to diagnose. |
| T-01-04-02 | Denial of Service | Wasm composition runs before Koin init (PITFALL #8) | mitigate | Task 3 explicitly orders `configureLogging() → initKoin() → ComposeViewport { }`. Phase 1 has no ViewModels so the symptom would not surface until Phase 5+, but the order is correct from day 1. |
| T-01-04-03 | Tampering | `App.kt` calling `startKoin` from inside @Composable | mitigate | Task 1 + Task 3 acceptance criteria prohibit modification of `App.kt`. `App.kt` template preserves the anti-pattern-free shape. |
| T-01-04-04 | Information Disclosure | Kermit logs leaking sensitive data | accept | Phase 1 has no sensitive data in the codebase (no auth, no user records, no PII). Kermit tag `"recipe"` is a build identifier, not a secret. Revisit when Phase 2 (Auth) introduces tokens — at that point, Kermit's `.i { }` lambda evaluation prevents accidental string concat of secrets if authors follow the lambda idiom. |
| T-01-04-05 | Elevation of Privilege | Android manifest `android:name=".MainApplication"` registers custom Application subclass | accept | This is the standard Android lifecycle — `MainApplication.onCreate()` runs in the app's own process, same privilege as `MainActivity`. No escalation. |
</threat_model>
<verification>
Phase-level verification for this plan:
- Task 1, 2, 3 `<automated>` blocks pass (grep-based).
- `tools/verify-no-version-literals.sh` continues to exit 0 (no build files modified).
- `tools/verify-shared-pure.sh` continues to exit 0 (shared/ not touched).
- Plan 07 runs `./gradlew build` and `./gradlew :composeApp:jvmTest` — those will exercise `initKoin()` via composition and catch any Koin config error.
No `./gradlew` invocation is in this plan's `<automated>` blocks — Plan 05 + Plan 07 run the compile gates. Keep this plan's verification grep-fast (<5s total).
</verification>
<success_criteria>
- 6 new commonMain/iosMain/androidMain Kotlin files created (Koin.kt, AppModule.kt, Logging.kt, KoinIos.kt, MainApplication.kt — and the init order is correct in each)
- AndroidManifest.xml has `android:name=".MainApplication"` attribute added
- JVM + Wasm main() entries call `configureLogging()` THEN `initKoin()` BEFORE composition
- `iOSApp.swift` imports `ComposeApp` and calls `KoinIosKt.doInitKoin()` in `init()`
- `App.kt` unmodified (anti-pattern guard)
- `MainViewController.kt` unmodified (PITFALL #4 guard)
</success_criteria>
<output>
After completion, create `.planning/phases/01-project-infrastructure-module-wiring/01-04-SUMMARY.md` recording: 6 files created + 3 files modified paths, Kermit tag set to `"recipe"`, Koin appModule content (empty), and confirmation that `App.kt` / `MainViewController.kt` / `ContentView.swift` were NOT modified.
</output>

View File

@@ -0,0 +1,498 @@
---
phase: 01-project-infrastructure-module-wiring
plan: 05
type: execute
wave: 2
depends_on: [01, 02]
files_modified:
- server/src/main/kotlin/dev/ulfrx/recipe/Application.kt
- server/src/main/kotlin/dev/ulfrx/recipe/Database.kt
- server/src/main/resources/application.conf
- server/src/main/resources/db/migration/.gitkeep
- server/src/test/kotlin/dev/ulfrx/recipe/ApplicationTest.kt
autonomous: true
requirements: [INFRA-02]
requirements_addressed: [INFRA-02]
must_haves:
truths:
- "GET /health returns 200 with Content-Type: application/json and body {\"status\":\"ok\"} (D-16)"
- "Server reads database.url / database.user / database.password from application.conf, with localhost defaults and env overrides via HOCON ${?X} syntax (PITFALL #5)"
- "Flyway runs Flyway.configure().dataSource(url, user, password).locations(\"classpath:db/migration\").load().migrate() during Application.module() startup"
- "Server fails loudly with IllegalStateException if Postgres is unreachable — the exception is thrown from Database.migrate() and NOT swallowed"
- "server/src/main/resources/db/migration/ directory exists (with .gitkeep) so Flyway.locations classpath resolution finds it even when empty"
- "ApplicationTest.kt has a test named 'health endpoint returns 200 with status ok' (or similar) that does NOT require a running Postgres — it composes routing in isolation"
- "Application.kt uses explicit Ktor imports (no wildcard imports) so D-11 allWarningsAsErrors is satisfied"
artifacts:
- path: "server/src/main/kotlin/dev/ulfrx/recipe/Application.kt"
provides: "main() → embeddedServer(Netty, SERVER_PORT, ::module).start(); Application.module() installs ContentNegotiation(json), invokes Database.migrate(this), and registers GET /health"
exports: ["main", "Application.module"]
- path: "server/src/main/kotlin/dev/ulfrx/recipe/Database.kt"
provides: "object Database { fun migrate(app: Application) } — reads HOCON config, runs Flyway, throws IllegalStateException on failure"
exports: ["Database"]
- path: "server/src/main/resources/application.conf"
provides: "HOCON config with ktor.deployment.port (8080 + ${?PORT}) and database.url/user/password (localhost defaults + ${?DATABASE_URL/USER/PASSWORD})"
- path: "server/src/main/resources/db/migration/.gitkeep"
provides: "Empty directory placeholder ensuring classpath:db/migration resolves for Flyway even when no SQL files exist yet"
- path: "server/src/test/kotlin/dev/ulfrx/recipe/ApplicationTest.kt"
provides: "ApplicationTest with /health route assertion — composes routing without calling Database.migrate (no Postgres required)"
key_links:
- from: "server/src/main/kotlin/dev/ulfrx/recipe/Application.kt"
to: "server/src/main/kotlin/dev/ulfrx/recipe/Database.kt"
via: "Database.migrate(this) inside Application.module()"
pattern: "Database\\.migrate\\(this\\)"
- from: "server/src/main/kotlin/dev/ulfrx/recipe/Database.kt"
to: "server/src/main/resources/application.conf"
via: "app.environment.config.property(\"database.url\").getString() etc."
pattern: "config\\.property\\(\"database\\."
- from: "Flyway.configure().locations(...)"
to: "server/src/main/resources/db/migration/"
via: "classpath:db/migration"
pattern: "classpath:db/migration"
---
<objective>
Deliver the server's running-but-empty state: a `GET /health` route returning `{"status":"ok"}`, HOCON-based config (`application.conf`) with env-var overrides, a `Database` object that runs Flyway against Postgres at boot time (failing loudly if Postgres is unreachable), and an updated `ApplicationTest.kt` that asserts the route in isolation without requiring a running database. Also scaffold `server/src/main/resources/db/migration/` as an empty directory so Flyway's classpath resolution succeeds before Phase 3 adds `V1__init.sql`.
Purpose: This plan closes D-16 — Phase 3 drops its first migration into an already-working migrator; Phase 11 deploys to the homelab with the same Ktor HOCON config reading real env vars. The fail-loud contract for unreachable Postgres is load-bearing: it surfaces config errors at boot, not at first 5xx.
Output: 2 Kotlin source files (Application.kt rewrite + Database.kt new), 1 HOCON config, 1 directory placeholder, 1 test rewrite.
</objective>
<execution_context>
@$HOME/.claude/get-shit-done/workflows/execute-plan.md
@$HOME/.claude/get-shit-done/templates/summary.md
</execution_context>
<context>
@.planning/PROJECT.md
@.planning/ROADMAP.md
@.planning/STATE.md
@.planning/phases/01-project-infrastructure-module-wiring/01-CONTEXT.md
@.planning/phases/01-project-infrastructure-module-wiring/01-RESEARCH.md
@.planning/phases/01-project-infrastructure-module-wiring/01-PATTERNS.md
@.planning/phases/01-project-infrastructure-module-wiring/01-VALIDATION.md
@server/src/main/kotlin/dev/ulfrx/recipe/Application.kt
@server/src/test/kotlin/dev/ulfrx/recipe/ApplicationTest.kt
@shared/src/commonMain/kotlin/dev/ulfrx/recipe/Constants.kt
@CLAUDE.md
<interfaces>
<!-- Ktor 3.4.1 APIs (already in recipe.jvm.server via libs.ktor.*) -->
From io.ktor.server.application:
```kotlin
interface Application
interface ApplicationEnvironment {
val config: ApplicationConfig
}
interface ApplicationConfig {
fun property(path: String): ApplicationConfigValue
fun propertyOrNull(path: String): ApplicationConfigValue?
}
interface ApplicationConfigValue {
fun getString(): String
}
```
From io.ktor.server.engine + io.ktor.server.netty:
```kotlin
fun embeddedServer(factory: ApplicationEngineFactory<...>, port: Int, host: String, module: Application.() -> Unit): EmbeddedServer
object Netty : ApplicationEngineFactory<...>
```
From io.ktor.server.plugins.contentnegotiation + io.ktor.serialization.kotlinx.json:
```kotlin
object ContentNegotiation : BaseApplicationPlugin<...>
fun ContentNegotiationConfig.json() // installs kotlinx.serialization JSON converter
```
From io.ktor.server.routing + io.ktor.server.response:
```kotlin
fun Application.routing(block: Route.() -> Unit)
fun Route.get(path: String, handler: suspend RoutingContext.() -> Unit)
suspend fun ApplicationCall.respond(message: Any)
```
From io.ktor.server.testing (in testImplementation via recipe.jvm.server):
```kotlin
fun testApplication(block: suspend ApplicationTestBuilder.() -> Unit)
// ApplicationTestBuilder provides:
fun application(block: Application.() -> Unit)
val client: HttpClient
```
From org.flywaydb.core:
```kotlin
object Flyway {
fun configure(): FluentConfiguration
}
// FluentConfiguration:
fun dataSource(url: String, user: String, password: String): FluentConfiguration
fun locations(vararg locations: String): FluentConfiguration
fun baselineOnMigrate(b: Boolean): FluentConfiguration
fun validateOnMigrate(b: Boolean): FluentConfiguration
fun cleanDisabled(b: Boolean): FluentConfiguration
fun load(): Flyway
// Flyway instance:
fun migrate(): MigrateResult
```
From kotlinx.serialization:
```kotlin
@Serializable
```
From org.slf4j:
```kotlin
object LoggerFactory {
fun getLogger(clazz: Class<*>): Logger
}
// org.slf4j.Logger: .info(msg: String, vararg args: Any), .error(msg: String, t: Throwable)
```
From shared/src/commonMain/kotlin/dev/ulfrx/recipe/Constants.kt (DO NOT modify):
```kotlin
package dev.ulfrx.recipe
const val SERVER_PORT: Int = 8080 // or whatever current value is
```
Current server/src/main/kotlin/dev/ulfrx/recipe/Application.kt (to replace):
```kotlin
package dev.ulfrx.recipe
import io.ktor.server.application.*
import io.ktor.server.engine.*
import io.ktor.server.netty.*
import io.ktor.server.response.*
import io.ktor.server.routing.*
fun main() { embeddedServer(Netty, port = SERVER_PORT, host = "0.0.0.0", module = Application::module).start(wait = true) }
fun Application.module() { routing { get("/") { call.respondText("Ktor: ${Greeting().greet()}") } } }
```
Current server/src/test/kotlin/dev/ulfrx/recipe/ApplicationTest.kt (to replace):
```kotlin
// testRoot() asserts GET / returns "Ktor: ${Greeting().greet()}" — to be replaced with /health assertion
```
</interfaces>
</context>
<tasks>
<task type="auto">
<name>Task 1: Create application.conf + db/migration/.gitkeep + Database.kt</name>
<files>server/src/main/resources/application.conf, server/src/main/resources/db/migration/.gitkeep, server/src/main/kotlin/dev/ulfrx/recipe/Database.kt</files>
<read_first>
- .planning/phases/01-project-infrastructure-module-wiring/01-RESEARCH.md lines 988-1023 (canonical Database.kt — SLF4J variant since server uses Logback not Kermit)
- .planning/phases/01-project-infrastructure-module-wiring/01-RESEARCH.md lines 1029-1051 (canonical application.conf HOCON)
- .planning/phases/01-project-infrastructure-module-wiring/01-RESEARCH.md lines 692-717 (PITFALL #5`${?X}` env-var HOCON syntax)
- .planning/phases/01-project-infrastructure-module-wiring/01-RESEARCH.md lines 719-724 (PITFALL #6 — Flyway runtime API, not plugin at build time)
- .planning/phases/01-project-infrastructure-module-wiring/01-PATTERNS.md lines 990-1076 (Database.kt + application.conf + .gitkeep deltas)
- .planning/phases/01-project-infrastructure-module-wiring/01-CONTEXT.md D-16 (server /health + Flyway + Postgres env overrides)
- server/build.gradle.kts (verify Plan 03 made `implementation(projects.shared)` present so `SERVER_PORT` is still reachable)
</read_first>
<action>
Create three files.
**File 1: `server/src/main/resources/application.conf`** (HOCON, 01-RESEARCH.md lines 1031-1051):
```hocon
ktor {
deployment {
port = 8080
port = ${?PORT}
}
application {
modules = [ dev.ulfrx.recipe.ApplicationKt.module ]
}
}
database {
url = "jdbc:postgresql://localhost:5432/recipe"
url = ${?DATABASE_URL}
user = "recipe"
user = ${?DATABASE_USER}
password = "recipe"
password = ${?DATABASE_PASSWORD}
}
```
CRITICAL (PITFALL #5):
- The two-line `url = "default"; url = ${?DATABASE_URL}` pattern is MANDATORY. `${?X}` is optional substitution — the second line is a no-op when `DATABASE_URL` is unset, and an override when it is set. Do NOT use `${X}` (required — crashes if unset) or `${X:default}` (wrong HOCON syntax).
- `"jdbc:postgresql://localhost:5432/recipe"`, `"recipe"`, `"recipe"` MATCH the docker-compose defaults in Plan 06 exactly — allows `docker compose up -d postgres && ./gradlew :server:run` with zero extra env config.
- `modules = [ dev.ulfrx.recipe.ApplicationKt.module ]` — even though `main()` uses programmatic `embeddedServer(...)` in Application.kt, this key is informational for Ktor's HOCON config loader and future EngineMain switching.
**File 2: `server/src/main/resources/db/migration/.gitkeep`** — empty zero-byte file. Git does not track empty directories; this marker ensures `server/src/main/resources/db/migration/` ships in the repo so `classpath:db/migration` resolves for Flyway. Phase 3 drops `V1__init.sql` here.
**File 3: `server/src/main/kotlin/dev/ulfrx/recipe/Database.kt`** (SLF4J variant — the server uses Logback already, NOT Kermit; RESEARCH.md lines 996-1023 + lines 1025-1027 explain the logger choice):
```kotlin
package dev.ulfrx.recipe
import io.ktor.server.application.Application
import org.flywaydb.core.Flyway
import org.slf4j.LoggerFactory
object Database {
private val log = LoggerFactory.getLogger(Database::class.java)
fun migrate(app: Application) {
val url = app.environment.config.property("database.url").getString()
val user = app.environment.config.property("database.user").getString()
val password = app.environment.config.property("database.password").getString()
log.info("Connecting to {} as {} and running Flyway migrations", url, user)
runCatching {
Flyway.configure()
.dataSource(url, user, password)
.locations("classpath:db/migration")
.baselineOnMigrate(true)
.validateOnMigrate(true)
.cleanDisabled(true)
.load()
.migrate()
}.onFailure { ex ->
log.error("Flyway migration failed — cannot start server", ex)
throw IllegalStateException("Database unreachable or migration failed", ex)
}
}
}
```
CRITICAL:
- `throw IllegalStateException(...)` is the fail-loud contract (D-16). Do NOT wrap it in a generic `try { } catch { return false }` — the server MUST refuse to start if the DB is unreachable.
- Use SLF4J (`LoggerFactory.getLogger(...)`), NOT Kermit. The server has Logback wired via `logback.xml`; Kermit is the CLIENT logger (composeApp only).
- Log credentials are NOT logged — only `url` and `user` appear in the info line. `password` is used for `dataSource(...)` only.
- `cleanDisabled = true` prevents accidental `flywayClean` wiping tables in dev/prod (matches `recipe.jvm.server.gradle.kts` plugin config — double-enforcement).
- `baselineOnMigrate = true` tolerates an existing DB with no Flyway history (defensive — Phase 1's DB is empty, Phase 11's homelab DB may pre-exist).
- `locations("classpath:db/migration")` points to the resource directory the `.gitkeep` keeps alive.
</action>
<verify>
<automated>test -f server/src/main/resources/application.conf && test -f server/src/main/resources/db/migration/.gitkeep && test -f server/src/main/kotlin/dev/ulfrx/recipe/Database.kt && grep -q 'port = 8080' server/src/main/resources/application.conf && grep -q 'port = \${?PORT}' server/src/main/resources/application.conf && grep -q 'url = "jdbc:postgresql://localhost:5432/recipe"' server/src/main/resources/application.conf && grep -q 'url = \${?DATABASE_URL}' server/src/main/resources/application.conf && grep -q 'user = "recipe"' server/src/main/resources/application.conf && grep -q 'user = \${?DATABASE_USER}' server/src/main/resources/application.conf && grep -q 'password = "recipe"' server/src/main/resources/application.conf && grep -q 'password = \${?DATABASE_PASSWORD}' server/src/main/resources/application.conf && grep -q 'object Database' server/src/main/kotlin/dev/ulfrx/recipe/Database.kt && grep -q 'org.flywaydb.core.Flyway' server/src/main/kotlin/dev/ulfrx/recipe/Database.kt && grep -q 'org.slf4j.LoggerFactory' server/src/main/kotlin/dev/ulfrx/recipe/Database.kt && grep -q 'cleanDisabled(true)' server/src/main/kotlin/dev/ulfrx/recipe/Database.kt && grep -q 'baselineOnMigrate(true)' server/src/main/kotlin/dev/ulfrx/recipe/Database.kt && grep -q 'throw IllegalStateException' server/src/main/kotlin/dev/ulfrx/recipe/Database.kt && grep -q 'classpath:db/migration' server/src/main/kotlin/dev/ulfrx/recipe/Database.kt</automated>
</verify>
<acceptance_criteria>
- `server/src/main/resources/application.conf` exists and contains exactly 6 env-var override lines (`port = ${?PORT}`, `url = ${?DATABASE_URL}`, `user = ${?DATABASE_USER}`, `password = ${?DATABASE_PASSWORD}` plus the two defaults for `port = 8080` and the DB trio)
- `application.conf` default values match docker-compose defaults: URL `jdbc:postgresql://localhost:5432/recipe`, user `recipe`, password `recipe`
- `application.conf` contains `modules = [ dev.ulfrx.recipe.ApplicationKt.module ]`
- `server/src/main/resources/db/migration/.gitkeep` exists (zero-byte file acceptable)
- `server/src/main/kotlin/dev/ulfrx/recipe/Database.kt` exists and declares `object Database`
- `Database.kt` imports `io.ktor.server.application.Application`, `org.flywaydb.core.Flyway`, `org.slf4j.LoggerFactory`
- `Database.kt` defines `fun migrate(app: Application)` that reads `app.environment.config.property("database.url|user|password").getString()`
- `Database.kt` body contains `Flyway.configure().dataSource(url, user, password).locations("classpath:db/migration").baselineOnMigrate(true).validateOnMigrate(true).cleanDisabled(true).load().migrate()` (all chained)
- `Database.kt` wraps the migration in `runCatching { ... }.onFailure { ... throw IllegalStateException(...) }` (fail-loud contract)
- `Database.kt` does NOT import `co.touchlab.kermit.Logger` (server uses SLF4J)
- `Database.kt` log.info line does NOT format the password value (only url + user in the format string)
</acceptance_criteria>
<done>HOCON config, Flyway migration resource dir, and fail-loud Database.migrate exist.</done>
</task>
<task type="auto">
<name>Task 2: Rewrite Application.kt to install ContentNegotiation, call Database.migrate, expose /health</name>
<files>server/src/main/kotlin/dev/ulfrx/recipe/Application.kt</files>
<read_first>
- server/src/main/kotlin/dev/ulfrx/recipe/Application.kt (current 20 lines — target of rewrite)
- .planning/phases/01-project-infrastructure-module-wiring/01-RESEARCH.md lines 952-985 (canonical Application.kt with ContentNegotiation + /health)
- .planning/phases/01-project-infrastructure-module-wiring/01-PATTERNS.md lines 940-986 (Application.kt deltas)
- .planning/phases/01-project-infrastructure-module-wiring/01-CONTEXT.md D-16 (sentinel JSON body for /health — Claude's discretion; use trivial `{"status":"ok"}`)
- shared/src/commonMain/kotlin/dev/ulfrx/recipe/Constants.kt (verify `SERVER_PORT` constant is defined)
</read_first>
<action>
Replace the entire content of `server/src/main/kotlin/dev/ulfrx/recipe/Application.kt` with:
```kotlin
package dev.ulfrx.recipe
import io.ktor.serialization.kotlinx.json.json
import io.ktor.server.application.Application
import io.ktor.server.application.install
import io.ktor.server.engine.embeddedServer
import io.ktor.server.netty.Netty
import io.ktor.server.plugins.contentnegotiation.ContentNegotiation
import io.ktor.server.response.respond
import io.ktor.server.routing.get
import io.ktor.server.routing.routing
import kotlinx.serialization.Serializable
fun main() {
embeddedServer(Netty, port = SERVER_PORT, host = "0.0.0.0", module = Application::module)
.start(wait = true)
}
@Serializable
private data class Health(val status: String)
fun Application.module() {
install(ContentNegotiation) {
json()
}
Database.migrate(this)
configureRouting()
}
fun Application.configureRouting() {
routing {
get("/health") {
call.respond(Health(status = "ok"))
}
}
}
```
DELETIONS:
- DROP the wildcard imports (`io.ktor.server.application.*`, `io.ktor.server.engine.*`, `io.ktor.server.netty.*`, `io.ktor.server.response.*`, `io.ktor.server.routing.*`) — replaced with explicit imports to satisfy D-11 allWarningsAsErrors (wildcard-unused warnings would fail the build)
- DROP `get("/") { call.respondText("Ktor: ${Greeting().greet()}") }` — replaced by `/health`
ADDITIONS:
- ADD `install(ContentNegotiation) { json() }` — required for `@Serializable` response serialization
- ADD `Database.migrate(this)` call inside `Application.module()` — fails loudly if Postgres unreachable
- ADD `@Serializable private data class Health(val status: String)` — the /health response shape
- ADD `Application.configureRouting()` extension function — extracted from `module()` so the test (Task 3) can compose routing WITHOUT invoking `Database.migrate()`
KEEP:
- `package dev.ulfrx.recipe` (unchanged)
- `fun main() { embeddedServer(Netty, port = SERVER_PORT, host = "0.0.0.0", module = Application::module).start(wait = true) }` — programmatic boot, unchanged shape
- `SERVER_PORT` constant is referenced from `shared/` (unchanged)
CRITICAL:
- The extraction of `configureRouting()` from `module()` is load-bearing for the test. Task 3 needs to test routing without calling `Database.migrate(this)` (which requires a real Postgres).
- `install(ContentNegotiation) { json() }` — MUST be installed before any route returns a `@Serializable` type. Both `module()` (for production) and the test (Task 3) must install it.
</action>
<verify>
<automated>grep -q '^package dev.ulfrx.recipe$' server/src/main/kotlin/dev/ulfrx/recipe/Application.kt && grep -q 'import io.ktor.server.plugins.contentnegotiation.ContentNegotiation' server/src/main/kotlin/dev/ulfrx/recipe/Application.kt && grep -q 'import io.ktor.serialization.kotlinx.json.json' server/src/main/kotlin/dev/ulfrx/recipe/Application.kt && grep -q 'import kotlinx.serialization.Serializable' server/src/main/kotlin/dev/ulfrx/recipe/Application.kt && ! grep -qE 'import io\.ktor\.server\.application\.\*' server/src/main/kotlin/dev/ulfrx/recipe/Application.kt && grep -q 'install(ContentNegotiation)' server/src/main/kotlin/dev/ulfrx/recipe/Application.kt && grep -q 'Database.migrate(this)' server/src/main/kotlin/dev/ulfrx/recipe/Application.kt && grep -q 'get("/health")' server/src/main/kotlin/dev/ulfrx/recipe/Application.kt && grep -q 'data class Health(val status: String)' server/src/main/kotlin/dev/ulfrx/recipe/Application.kt && grep -q 'fun Application.configureRouting()' server/src/main/kotlin/dev/ulfrx/recipe/Application.kt && ! grep -q 'call.respondText' server/src/main/kotlin/dev/ulfrx/recipe/Application.kt && grep -q 'embeddedServer(Netty, port = SERVER_PORT' server/src/main/kotlin/dev/ulfrx/recipe/Application.kt</automated>
</verify>
<acceptance_criteria>
- `Application.kt` has no wildcard imports (`import X.*`) — every `io.ktor.*` import is explicit
- `Application.kt` imports `io.ktor.server.plugins.contentnegotiation.ContentNegotiation`, `io.ktor.serialization.kotlinx.json.json`, `kotlinx.serialization.Serializable`
- `Application.kt` defines `@Serializable private data class Health(val status: String)`
- `Application.module()` body calls, in order: `install(ContentNegotiation) { json() }`, then `Database.migrate(this)`, then `configureRouting()`
- `Application.configureRouting()` is a top-level extension function containing the `routing { get("/health") { call.respond(Health(status = "ok")) } }` block
- `main()` is unchanged from its current shape: `embeddedServer(Netty, port = SERVER_PORT, host = "0.0.0.0", module = Application::module).start(wait = true)`
- No `get("/")` route remains (template root greeting is removed)
- No `call.respondText(...)` in Application.kt (Health returned via `call.respond(Health(...))` → kotlinx-json serializer)
</acceptance_criteria>
<done>Application.kt installs ContentNegotiation, runs Flyway at boot, exposes /health JSON, splits routing for testability.</done>
</task>
<task type="auto">
<name>Task 3: Rewrite ApplicationTest.kt to assert GET /health returns 200 with JSON body</name>
<files>server/src/test/kotlin/dev/ulfrx/recipe/ApplicationTest.kt</files>
<read_first>
- server/src/test/kotlin/dev/ulfrx/recipe/ApplicationTest.kt (current 20-line content — target of rewrite)
- .planning/phases/01-project-infrastructure-module-wiring/01-RESEARCH.md lines 1084-1125 (canonical ApplicationTest.kt variant)
- .planning/phases/01-project-infrastructure-module-wiring/01-PATTERNS.md lines 1079-1125 (test delta explaining the no-Postgres-required refactor)
- server/src/main/kotlin/dev/ulfrx/recipe/Application.kt (the freshly rewritten file — the test references `configureRouting()` from this file)
- .planning/phases/01-project-infrastructure-module-wiring/01-VALIDATION.md lines 52-53 (automated command this test must satisfy: `./gradlew :server:test --tests "*Health*"`)
</read_first>
<action>
Replace the entire content of `server/src/test/kotlin/dev/ulfrx/recipe/ApplicationTest.kt` with:
```kotlin
package dev.ulfrx.recipe
import io.ktor.client.request.get
import io.ktor.client.statement.bodyAsText
import io.ktor.http.HttpStatusCode
import io.ktor.serialization.kotlinx.json.json
import io.ktor.server.application.install
import io.ktor.server.plugins.contentnegotiation.ContentNegotiation
import io.ktor.server.testing.testApplication
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertTrue
class ApplicationTest {
@Test
fun `health endpoint returns 200 with status ok`() = testApplication {
application {
install(ContentNegotiation) {
json()
}
configureRouting()
}
val response = client.get("/health")
assertEquals(HttpStatusCode.OK, response.status)
val body = response.bodyAsText()
assertTrue(body.contains("\"status\""), "expected body to contain status field, was: $body")
assertTrue(body.contains("\"ok\""), "expected body to contain ok value, was: $body")
}
}
```
CRITICAL:
- The test invokes `configureRouting()` directly (extracted in Task 2) and does NOT call `Database.migrate(...)`. This is the KEY refactor: the test runs without a running Postgres, so `./gradlew :server:test` can succeed in CI / fresh clones.
- `install(ContentNegotiation) { json() }` is explicitly installed inside `application { }` — because the production `Application.module()` installs it, but the test composes only `configureRouting()` and must install the plugin itself.
- Imports are explicit (no wildcards) to satisfy D-11 allWarningsAsErrors.
- Assertions check for `"status"` and `"ok"` substrings in the JSON body — this is a structural check that works regardless of JSON field ordering.
- The test function name uses backtick-quoted natural-language identifier (`` `health endpoint returns 200 with status ok` ``) — standard Kotlin test-naming convention; the test will run via `./gradlew :server:test --tests "*health*"` or similar wildcards.
DELETIONS:
- DROP the existing `testRoot()` test — it asserted the template's `/` route response with `"Ktor: ${Greeting().greet()}"`, which no longer exists.
- DROP wildcard imports `io.ktor.client.request.*`, `io.ktor.client.statement.*`, `io.ktor.http.*`, `io.ktor.server.testing.*`, `kotlin.test.*`.
</action>
<verify>
<automated>test -f server/src/test/kotlin/dev/ulfrx/recipe/ApplicationTest.kt && grep -q 'health endpoint returns 200' server/src/test/kotlin/dev/ulfrx/recipe/ApplicationTest.kt && grep -q 'configureRouting()' server/src/test/kotlin/dev/ulfrx/recipe/ApplicationTest.kt && ! grep -q 'Database.migrate' server/src/test/kotlin/dev/ulfrx/recipe/ApplicationTest.kt && grep -q 'install(ContentNegotiation)' server/src/test/kotlin/dev/ulfrx/recipe/ApplicationTest.kt && grep -q 'client.get("/health")' server/src/test/kotlin/dev/ulfrx/recipe/ApplicationTest.kt && grep -q 'HttpStatusCode.OK' server/src/test/kotlin/dev/ulfrx/recipe/ApplicationTest.kt && ! grep -q 'testRoot' server/src/test/kotlin/dev/ulfrx/recipe/ApplicationTest.kt && ! grep -q 'Greeting().greet()' server/src/test/kotlin/dev/ulfrx/recipe/ApplicationTest.kt && ! grep -qE 'import kotlin\.test\.\*' server/src/test/kotlin/dev/ulfrx/recipe/ApplicationTest.kt && cd /Users/rwilk/dev/repo/recipe && ./gradlew :server:test --tests "*health*" -q 2>&1 | tail -5 && echo "gradle exit: $?"</automated>
</verify>
<acceptance_criteria>
- `ApplicationTest.kt` defines exactly one `@Test` method whose name contains `health` (case-insensitive)
- Test body invokes `configureRouting()` and does NOT invoke `Database.migrate(...)` (no-Postgres invariant)
- Test installs `ContentNegotiation { json() }` inside `application { ... }`
- Test asserts `response.status == HttpStatusCode.OK`
- Test asserts response body contains substring `"status"` AND `"ok"`
- No wildcard imports
- No reference to the removed `testRoot`, `Greeting`, or `respondText` — the old template test is fully replaced
- `./gradlew :server:test --tests "*health*"` runs and exits 0 (proves the test compiles AND passes; no Postgres needed because `configureRouting()` is composed directly)
</acceptance_criteria>
<done>/health test passes without requiring Postgres; old template test removed.</done>
</task>
</tasks>
<threat_model>
## Trust Boundaries
| Boundary | Description |
|----------|-------------|
| HTTP client (unauthenticated) → GET /health | `/health` is intentionally unauthenticated (observability); reveals only `{"status":"ok"}` — no implementation detail, no version, no uptime. |
| Ktor process → Postgres (JDBC) | HOCON defaults connect to `localhost:5432` with dev credentials. Real credentials arrive via `DATABASE_URL`/`DATABASE_USER`/`DATABASE_PASSWORD` env vars in Phase 11 homelab deploy. |
| Developer → server/src/main/resources/application.conf | Committed to git; MUST contain only non-secret dev defaults. Real secrets never land in `application.conf`. |
## STRIDE Threat Register
| Threat ID | Category | Component | Disposition | Mitigation Plan |
|-----------|----------|-----------|-------------|-----------------|
| T-01-05-01 | Information Disclosure | `/health` endpoint leaking implementation details | mitigate | Body is `{"status":"ok"}` only — no version, no commit hash, no uptime, no DB state. Per ASVS V14 guidance + 01-RESEARCH.md § Security Domain. |
| T-01-05-02 | Information Disclosure | `application.conf` committed with real secrets | mitigate | Defaults are non-secret localhost creds (`recipe/recipe/recipe`). Real secrets MUST arrive via `${?DATABASE_URL}` env override — never committed. Task 1 acceptance criteria enforces the six `${?X}` lines. |
| T-01-05-03 | Tampering / Destruction | `flywayClean` wiping DB | mitigate | `cleanDisabled(true)` is set in BOTH `recipe.jvm.server.gradle.kts` (plugin CLI guard) AND in `Database.kt` runtime call (programmatic guard). Double-enforced per RESEARCH.md § Security Domain. |
| T-01-05-04 | Denial of Service | Server silently runs with broken DB | mitigate | `Database.migrate()` throws `IllegalStateException` on any Flyway/JDBC error — server cannot start. Fail-loud contract (D-16) prevents stealth failure modes. |
| T-01-05-05 | Error Handling (leaky stack traces) | 500 responses exposing internal stack | accept | No 500 handler registered yet; Ktor's default returns generic 500. Phase 2+ may introduce a StatusPages handler; Phase 1 scope is /health only which cannot 500 under normal operation. |
| T-01-05-06 | Supply Chain | Flyway 12.4.0 + flyway-database-postgresql transitive deps | mitigate | Pinned versions via catalog (Plan 01). Postgres JDBC 42.7.10 pinned. No `latest.release` ranges. |
</threat_model>
<verification>
Phase-level verification for this plan:
- Task 3 `<automated>` runs `./gradlew :server:test --tests "*health*"` which proves:
- Application.kt compiles (confirms Task 2's explicit imports are correct)
- ApplicationTest.kt compiles (confirms Task 3's imports are correct)
- The /health route returns 200 with JSON containing `"status"` and `"ok"`
- Database.migrate is NOT required for the test (no Postgres needed in CI — D-11 test-runtime invariant)
- `tools/verify-no-version-literals.sh` continues to exit 0 (no build files modified; server/build.gradle.kts was rewritten in Plan 03, untouched here).
- Manual verification (deferred to Plan 07 or manual step):
- `docker compose up -d postgres && ./gradlew :server:run & sleep 5 && curl -sf http://localhost:8080/health | grep '"ok"'` — proves end-to-end boot + route + DB migration path.
</verification>
<success_criteria>
- `server/src/main/resources/application.conf` exists with HOCON + 6 env overrides
- `server/src/main/resources/db/migration/.gitkeep` exists
- `server/src/main/kotlin/dev/ulfrx/recipe/Database.kt` runs Flyway with fail-loud contract
- `server/src/main/kotlin/dev/ulfrx/recipe/Application.kt` installs ContentNegotiation, calls Database.migrate, exposes GET /health returning `{"status":"ok"}`
- `server/src/test/kotlin/dev/ulfrx/recipe/ApplicationTest.kt` passes via `./gradlew :server:test --tests "*health*"` WITHOUT a running Postgres
</success_criteria>
<output>
After completion, create `.planning/phases/01-project-infrastructure-module-wiring/01-05-SUMMARY.md` recording: files created/modified, HOCON env-var pattern used (the `${?X}` two-line form), the fail-loud Database.migrate contract, and the `./gradlew :server:test` result.
</output>

View File

@@ -0,0 +1,308 @@
---
phase: 01-project-infrastructure-module-wiring
plan: 06
type: execute
wave: 2
depends_on: []
files_modified:
- docker-compose.yml
- README.md
autonomous: true
requirements: [INFRA-02]
requirements_addressed: [INFRA-02]
must_haves:
truths:
- "docker-compose.yml at repo root launches postgres:16 with POSTGRES_DB=recipe / POSTGRES_USER=recipe / POSTGRES_PASSWORD=recipe — matching application.conf defaults exactly (D-17)"
- "The postgres service has a named volume (recipe-pgdata) so data survives container restarts"
- "The postgres service has a healthcheck using pg_isready that lets `docker compose up --wait` block until ready"
- "README.md has a 'Local development' section documenting the full dev loop (docker compose up, gradlew server:run, curl /health, gradlew spotlessApply)"
- "README.md no longer documents the dropped js target (D-01); wasmJs section is preserved"
artifacts:
- path: "docker-compose.yml"
provides: "postgres:16 service on port 5432 with named volume and healthcheck"
contains: "image: postgres:16", "POSTGRES_DB: recipe", "recipe-pgdata"
- path: "README.md"
provides: "Updated dev docs with Local development section, no js target docs"
contains: "Local development", "docker compose up -d postgres"
key_links:
- from: "docker-compose.yml"
to: "server/src/main/resources/application.conf"
via: "POSTGRES_DB=recipe / POSTGRES_USER=recipe / POSTGRES_PASSWORD=recipe defaults match HOCON localhost URL"
pattern: "POSTGRES_(DB|USER|PASSWORD):\\s*recipe"
- from: "README.md Local development section"
to: "server/src/main/kotlin/dev/ulfrx/recipe/Application.kt"
via: "curl http://localhost:8080/health"
pattern: "curl .+ /health"
---
<objective>
Deliver the local developer ergonomics promised by D-17: a `docker-compose.yml` at the repo root running `postgres:16` with credentials + volume + healthcheck that align exactly with Plan 05's `application.conf` HOCON defaults, plus a "Local development" section in `README.md` documenting the dev loop. Drop the legacy `js` target documentation from `README.md` (D-01).
Purpose: Phase 3 (Households / DB migrations) and Phase 11 (homelab deploy) both assume a working local Postgres is one command away. This plan closes that gap so `docker compose up -d postgres && ./gradlew :server:run` is a two-command dev loop. Authentik is NOT in this compose file — it lives on the user's homelab (CONTEXT.md D-17).
Output: 1 new YAML file, 1 README edit. Entirely independent of Plans 01-05 in terms of files_modified — runs safely in parallel.
</objective>
<execution_context>
@$HOME/.claude/get-shit-done/workflows/execute-plan.md
@$HOME/.claude/get-shit-done/templates/summary.md
</execution_context>
<context>
@.planning/PROJECT.md
@.planning/ROADMAP.md
@.planning/STATE.md
@.planning/phases/01-project-infrastructure-module-wiring/01-CONTEXT.md
@.planning/phases/01-project-infrastructure-module-wiring/01-RESEARCH.md
@.planning/phases/01-project-infrastructure-module-wiring/01-PATTERNS.md
@.planning/phases/01-project-infrastructure-module-wiring/01-VALIDATION.md
@README.md
@CLAUDE.md
<interfaces>
<!-- Plan 05's application.conf expects these exact defaults -->
From server/src/main/resources/application.conf (Plan 05 created — value match required):
```hocon
database {
url = "jdbc:postgresql://localhost:5432/recipe"
url = ${?DATABASE_URL}
user = "recipe"
user = ${?DATABASE_USER}
password = "recipe"
password = ${?DATABASE_PASSWORD}
}
```
So docker-compose.yml MUST use:
- `POSTGRES_DB: recipe` (matches `/recipe` in jdbc URL path)
- `POSTGRES_USER: recipe`
- `POSTGRES_PASSWORD: recipe`
- port `5432:5432` (matches URL port)
From README.md current content:
- Section "Build and Run Web Application" (lines 63-85) documents BOTH `wasmJsBrowserDevelopmentRun` AND `jsBrowserDevelopmentRun` — the `js` part must go per D-01.
- "Build and Run Android/Desktop/Server/iOS" sections are fine and stay.
</interfaces>
</context>
<tasks>
<task type="auto">
<name>Task 1: Create docker-compose.yml at repo root</name>
<files>docker-compose.yml</files>
<read_first>
- .planning/phases/01-project-infrastructure-module-wiring/01-RESEARCH.md lines 1055-1077 (canonical docker-compose.yml)
- .planning/phases/01-project-infrastructure-module-wiring/01-PATTERNS.md lines 1128-1158 (docker-compose pattern — matched defaults)
- .planning/phases/01-project-infrastructure-module-wiring/01-CONTEXT.md D-17 (scope: postgres:16 + named volume; Authentik stays on homelab)
- (If Plan 05 is complete) server/src/main/resources/application.conf — verify credentials match
</read_first>
<action>
Create `docker-compose.yml` at the repo root with the following exact content:
```yaml
services:
postgres:
image: postgres:16
container_name: recipe-postgres
environment:
POSTGRES_DB: recipe
POSTGRES_USER: recipe
POSTGRES_PASSWORD: recipe
ports:
- "5432:5432"
volumes:
- recipe-pgdata:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U recipe -d recipe"]
interval: 5s
timeout: 5s
retries: 5
volumes:
recipe-pgdata:
```
CRITICAL:
- `image: postgres:16` — pinned major version (D-17 specifies `postgres:16`).
- `POSTGRES_DB: recipe`, `POSTGRES_USER: recipe`, `POSTGRES_PASSWORD: recipe` — all MUST equal `"recipe"` (matches `application.conf` HOCON defaults from Plan 05 — `jdbc:postgresql://localhost:5432/recipe`, user `recipe`, password `recipe`).
- Named volume `recipe-pgdata` — survives container restart. Drop with `docker compose down -v` if you need a fresh DB.
- Healthcheck uses `pg_isready -U recipe -d recipe` so `docker compose up --wait postgres` or `depends_on: { postgres: { condition: service_healthy } }` works (Phase 3+ may add this).
- Port `5432:5432` — binds host port 5432 to container port 5432. Document in README that this is dev-local only.
- Do NOT add any other service (no Authentik — lives on user's homelab per D-17; no server — Ktor runs via Gradle on host for dev iteration).
- No `.env` file — D-17 / PATTERNS.md "Recommendation on `.env` vs inline": inline is fine for single-dev + matching application.conf defaults.
The file has NO leading version key (`version: "3"` etc. is legacy Docker Compose syntax — unnecessary in modern `docker compose v2`, and omitting it avoids a warning).
</action>
<verify>
<automated>test -f docker-compose.yml && grep -q 'image: postgres:16' docker-compose.yml && grep -q 'POSTGRES_DB: recipe' docker-compose.yml && grep -q 'POSTGRES_USER: recipe' docker-compose.yml && grep -q 'POSTGRES_PASSWORD: recipe' docker-compose.yml && grep -q 'recipe-pgdata:/var/lib/postgresql/data' docker-compose.yml && grep -q '"5432:5432"' docker-compose.yml && grep -q 'pg_isready -U recipe -d recipe' docker-compose.yml && grep -q '^volumes:$' docker-compose.yml && grep -q ' recipe-pgdata:' docker-compose.yml</automated>
</verify>
<acceptance_criteria>
- `docker-compose.yml` exists at repo root (`test -f docker-compose.yml`)
- `docker-compose.yml` contains `image: postgres:16` (not `postgres:latest`, not `postgres:15`, not `postgres`)
- `docker-compose.yml` contains `container_name: recipe-postgres`
- `docker-compose.yml` has `POSTGRES_DB: recipe`, `POSTGRES_USER: recipe`, `POSTGRES_PASSWORD: recipe` — all exactly `recipe` (lowercase, no variation)
- `docker-compose.yml` has port mapping `"5432:5432"`
- `docker-compose.yml` declares volume `recipe-pgdata` in both the service `volumes:` section AND the top-level `volumes:` section
- `docker-compose.yml` has a `healthcheck:` block using `pg_isready -U recipe -d recipe`
- `docker-compose.yml` does NOT contain a `version:` key (modern compose v2)
- `docker-compose.yml` does NOT define any service other than `postgres` (D-17: Authentik stays on homelab)
- Credentials match Plan 05's `application.conf` defaults (cross-check: `grep 'user = "recipe"' server/src/main/resources/application.conf` and `grep 'password = "recipe"' server/src/main/resources/application.conf` both return 1 line each — if Plan 05 is not complete, skip this sub-check)
</acceptance_criteria>
<done>docker-compose.yml ships postgres:16 matching application.conf defaults; single-service compose file.</done>
</task>
<task type="auto">
<name>Task 2: Add "Local development" section to README.md and drop js target docs</name>
<files>README.md</files>
<read_first>
- README.md (current 100-line content — target of edit)
- .planning/phases/01-project-infrastructure-module-wiring/01-PATTERNS.md lines 1161-1169 (README delta summary)
- .planning/phases/01-project-infrastructure-module-wiring/01-CONTEXT.md D-01 (drop js target), D-17 (docker-compose dev ergonomics)
</read_first>
<action>
Two edits to `README.md`:
**Edit A: Drop the `js` target section** — delete lines 77-85 of the current README (the "- for the JS target (slower, supports older browsers): - on macOS/Linux ... `./gradlew :composeApp:jsBrowserDevelopmentRun` - on Windows ..." block). Keep lines 68-76 (the wasmJs block). The entire "Build and Run Web Application" subsection should retain ONLY the wasmJs paragraph.
Resulting "Build and Run Web Application" subsection:
```markdown
### Build and Run Web Application
To build and run the development version of the web app, use the run configuration from the run widget
in your IDE's toolbar or run it directly from the terminal:
- for the Wasm target (faster, modern browsers):
- on macOS/Linux
```shell
./gradlew :composeApp:wasmJsBrowserDevelopmentRun
```
- on Windows
```shell
.\gradlew.bat :composeApp:wasmJsBrowserDevelopmentRun
```
```
**Edit B: Insert a new "Local development" section** AFTER the "Build and Run iOS Application" subsection and BEFORE the trailing `---` horizontal rule (around line 92 in the current file). The new section:
```markdown
### Local development
The server requires Postgres. A `docker-compose.yml` at the repo root ships a local Postgres
instance whose credentials match `application.conf` defaults (`recipe`/`recipe`/`recipe`).
Boot the database and server:
```shell
docker compose up -d postgres
./gradlew :server:run
```
Verify the server is up:
```shell
curl http://localhost:8080/health
# expected: {"status":"ok"}
```
Environment overrides (optional — set any of these to override `application.conf` defaults):
- `DATABASE_URL` — JDBC URL (default `jdbc:postgresql://localhost:5432/recipe`)
- `DATABASE_USER` — DB user (default `recipe`)
- `DATABASE_PASSWORD` — DB password (default `recipe`)
- `PORT` — Ktor port (default `8080`)
Before committing, format all Kotlin + Gradle + Markdown files:
```shell
./gradlew spotlessApply
```
The full check (Spotless + all tests across all targets):
```shell
./gradlew check
```
Reset the local database (destroys the `recipe-pgdata` volume):
```shell
docker compose down -v
```
```
Do NOT modify:
- The top-level introduction (lines 1-20)
- The "Build and Run Android Application" section
- The "Build and Run Desktop (JVM) Application" section
- The "Build and Run Server" section
- The "Build and Run iOS Application" section
- The trailing `---` + the learn-more links + the Compose/Wasm feedback paragraph
Keep the existing markdown heading level (`###`) for the new "Local development" section — matches the surrounding siblings.
</action>
<verify>
<automated>grep -q 'Local development' README.md && grep -q 'docker compose up -d postgres' README.md && grep -q 'curl http://localhost:8080/health' README.md && grep -q 'DATABASE_URL' README.md && grep -q 'gradlew spotlessApply' README.md && grep -q 'docker compose down -v' README.md && ! grep -q 'jsBrowserDevelopmentRun' README.md && grep -q 'wasmJsBrowserDevelopmentRun' README.md</automated>
</verify>
<acceptance_criteria>
- `README.md` contains the string `Local development` exactly once (new section heading)
- `README.md` contains `docker compose up -d postgres` as a documented command
- `README.md` contains `curl http://localhost:8080/health` as a documented command
- `README.md` lists all 4 env-var overrides: `DATABASE_URL`, `DATABASE_USER`, `DATABASE_PASSWORD`, `PORT`
- `README.md` contains `gradlew spotlessApply` (pre-commit formatter hint per D-10)
- `README.md` contains `gradlew check` (full-suite command)
- `README.md` contains `docker compose down -v` (volume reset hint)
- `README.md` does NOT contain `jsBrowserDevelopmentRun` (D-01 — js target dropped)
- `README.md` STILL contains `wasmJsBrowserDevelopmentRun` (wasmJs kept per D-01)
- All existing section headings ("Build and Run Android Application", "Build and Run Desktop (JVM) Application", "Build and Run Server", "Build and Run iOS Application") are preserved (unchanged)
- Top-of-file introduction (lines 1-20) is unchanged
</acceptance_criteria>
<done>README.md documents the dev loop (docker + gradle + curl + spotless + reset); legacy js target docs removed.</done>
</task>
</tasks>
<threat_model>
## Trust Boundaries
| Boundary | Description |
|----------|-------------|
| Developer host → localhost:5432 Postgres | Dev-local; `docker-compose.yml` binds port on loopback via host mapping. Non-localhost access requires the developer's host to be reachable from outside the machine AND port 5432 firewall-open — normally not the case on a laptop. |
| `docker-compose.yml` (committed to git) → POSTGRES_PASSWORD=recipe | Password is literal `recipe` — non-secret by design. Real homelab creds never land in this file; homelab has its own compose file or `.env` per Phase 11. |
## STRIDE Threat Register
| Threat ID | Category | Component | Disposition | Mitigation Plan |
|-----------|----------|-----------|-------------|-----------------|
| T-01-06-01 | Information Disclosure | Postgres port 5432 exposed on `0.0.0.0` | mitigate | Host-firewall is the developer's responsibility; the literal `"5432:5432"` mapping is Docker-default (binds to all host interfaces unless the host Docker is configured otherwise). README Local development section mentions "dev-local" usage but does NOT open a CVE window — this is standard dev practice. Phase 11 (homelab) uses a different compose file that does NOT expose the port publicly. |
| T-01-06-02 | Information Disclosure | Committing real secrets to `docker-compose.yml` | mitigate | Only the literal `recipe/recipe/recipe` triple is in the file. Real homelab Postgres creds stay out of this compose file (Phase 11 will add a separate file or switch to env-var-driven compose). |
| T-01-06-03 | Tampering | `docker compose down -v` accidentally destroying valuable data | accept | Dev-only volume (`recipe-pgdata`). If Phase 3+ develops real seed data, a developer running `down -v` repopulates from migrations — zero-trust default. |
| T-01-06-04 | Denial of Service | `postgres:16` image unavailable from Docker Hub | accept | `docker pull postgres:16` is a standard image; outage would be transient and outside our control. Pinning to major version (not `:latest`) limits drift. |
</threat_model>
<verification>
Phase-level verification for this plan:
- Task 1 + Task 2 `<automated>` blocks pass.
- `tools/verify-no-version-literals.sh` continues to exit 0 (no `.gradle.kts` files modified in this plan).
- No `./gradlew` invocations — docker-compose + README are pure dev-ergonomics.
Manual sanity check (optional, NOT blocking):
- `docker compose config` parses the YAML without warnings.
- `docker compose up -d postgres && sleep 3 && docker exec recipe-postgres pg_isready -U recipe -d recipe` returns "accepting connections".
- `docker compose down` — cleans up afterward.
</verification>
<success_criteria>
- `docker-compose.yml` exists at repo root with a single `postgres:16` service + named volume + healthcheck
- Credentials in `docker-compose.yml` match `application.conf` defaults exactly (`recipe/recipe/recipe`)
- `README.md` has a new "Local development" section
- `README.md` no longer documents the `js` target
- `README.md` still documents `wasmJs` target
</success_criteria>
<output>
After completion, create `.planning/phases/01-project-infrastructure-module-wiring/01-06-SUMMARY.md` recording: docker-compose content summary (one service, one volume), credential match with Plan 05, README sections added/removed, and any deviation from D-17 (expected: none).
</output>

View File

@@ -0,0 +1,297 @@
---
phase: 01-project-infrastructure-module-wiring
plan: 07
type: execute
wave: 3
depends_on: [01, 02, 03, 04, 05, 06]
files_modified:
- shared/src/commonMain/kotlin/dev/ulfrx/recipe/shared/.gitkeep
autonomous: true
requirements: [INFRA-01, INFRA-02, INFRA-03, INFRA-06]
requirements_addressed: [INFRA-01, INFRA-02, INFRA-03, INFRA-06]
must_haves:
truths:
- "shared/src/commonMain/kotlin/dev/ulfrx/recipe/shared/ package scaffold exists (as .gitkeep marker) — INFRA-06 file-existence criterion"
- "./gradlew spotlessApply runs green (no files need formatting, OR all files are auto-formatted)"
- "./gradlew build succeeds across composeApp, server, shared — produces Android APK + iOS framework + server JAR (SC1)"
- "tools/verify-no-version-literals.sh exits 0 across the whole repo (SC2 / INFRA-01)"
- "tools/verify-ios-flags.sh exits 0 (SC3 / INFRA-03)"
- "tools/verify-shared-pure.sh exits 0 (SC5 / INFRA-06)"
- "./gradlew :composeApp:help emits 'recipe.kotlin.multiplatform' among applied plugins (SC4 / INFRA-02)"
- "./gradlew check runs spotlessCheck + all tests and exits 0"
artifacts:
- path: "shared/src/commonMain/kotlin/dev/ulfrx/recipe/shared/.gitkeep"
provides: "Empty package scaffold marker ensuring dev.ulfrx.recipe.shared package exists in git (Phase 2+ adds DTOs here)"
- path: "composeApp/build/outputs/apk/debug/composeApp-debug.apk"
provides: "Android debug APK artifact from ./gradlew build (SC1 proof)"
- path: "composeApp/build/bin/iosSimulatorArm64/debugFramework/ComposeApp.framework"
provides: "iOS framework artifact from ./gradlew build (SC1 proof)"
key_links:
- from: "./gradlew build"
to: "composeApp/build.gradle.kts + shared/build.gradle.kts + server/build.gradle.kts"
via: "recipe.* convention plugin application (Plan 03 refactor)"
pattern: "id\\(\"recipe\\."
- from: "./gradlew :composeApp:help"
to: "build-logic/src/main/kotlin/recipe.kotlin.multiplatform.gradle.kts"
via: "help task enumerates applied plugins"
pattern: "recipe\\.kotlin\\.multiplatform"
---
<objective>
Create the final piece of INFRA-06 (empty `dev.ulfrx.recipe.shared` package scaffold under `shared/src/commonMain`) and then run the full phase verification gate: `./gradlew spotlessApply`, `./gradlew build`, the 3 `tools/verify-*.sh` invariant scripts, and `./gradlew check`. This is the "green build" moment that every prior plan in Phase 1 has been building toward.
Purpose: Phase 1 success is defined by 5 ROADMAP success criteria (SC1-SC5) and 4 phase requirements (INFRA-01/02/03/06). Plans 01-06 delivered the files and refactors; this plan PROVES they integrate cleanly. Any regression here is a phase-completion blocker.
Output: 1 `.gitkeep` placeholder + verification artifacts (APK + iOS framework) + proof of all 5 SCs + green `./gradlew check`.
</objective>
<execution_context>
@$HOME/.claude/get-shit-done/workflows/execute-plan.md
@$HOME/.claude/get-shit-done/templates/summary.md
</execution_context>
<context>
@.planning/PROJECT.md
@.planning/ROADMAP.md
@.planning/STATE.md
@.planning/phases/01-project-infrastructure-module-wiring/01-CONTEXT.md
@.planning/phases/01-project-infrastructure-module-wiring/01-RESEARCH.md
@.planning/phases/01-project-infrastructure-module-wiring/01-PATTERNS.md
@.planning/phases/01-project-infrastructure-module-wiring/01-VALIDATION.md
@tools/verify-no-version-literals.sh
@tools/verify-shared-pure.sh
@tools/verify-ios-flags.sh
@CLAUDE.md
<interfaces>
<!-- Inputs from prior plans -->
From Plan 01:
- tools/verify-no-version-literals.sh — greps every *.gradle.kts for version literals (exits 0 if none except build-logic/build.gradle.kts)
- tools/verify-shared-pure.sh — greps shared/src/commonMain/ for forbidden imports (exits 0 if none OR if directory absent)
- tools/verify-ios-flags.sh — greps gradle.properties for the two iOS K/N flags (exits 0 if both present)
From Plan 02:
- build-logic/ with 5 precompiled plugins applied via settings.gradle.kts pluginManagement.includeBuild
From Plan 03:
- composeApp/, shared/, server/ build.gradle.kts applying recipe.* convention plugins
From Plan 04:
- composeApp common/iOS/Android/Desktop/Wasm entry points calling initKoin() + configureLogging()
- iosApp/iosApp/iOSApp.swift calling KoinIosKt.doInitKoin()
From Plan 05:
- server Application.kt with /health + Database.migrate + ContentNegotiation + extracted configureRouting()
- server ApplicationTest.kt passing without Postgres
From Plan 06:
- docker-compose.yml with postgres:16 + matching credentials
- README.md with Local development section
Phase gate commands (from 01-VALIDATION.md § Sampling Rate):
- Quick: `./gradlew spotlessCheck :server:test :shared:jvmTest` (<30s)
- Per-wave: `./gradlew build` (full — iOS framework link + Android APK + server JAR)
- Phase gate: `./gradlew check` + manual curl + iOS simulator boot (simulator boot is a manual-only verification, 01-VALIDATION.md § Manual-Only)
</interfaces>
</context>
<tasks>
<task type="auto">
<name>Task 1: Create shared/ package scaffold placeholder</name>
<files>shared/src/commonMain/kotlin/dev/ulfrx/recipe/shared/.gitkeep</files>
<read_first>
- shared/src/commonMain/kotlin/dev/ulfrx/recipe/ (current contents: Greeting.kt, Platform.kt, Constants.kt — these are the TEMPLATE classes; they stay in place for now. Phase 2+ reorganizes.)
- .planning/phases/01-project-infrastructure-module-wiring/01-CONTEXT.md D-19 (shared/commonMain stays pure; Phase 1 ships an empty package scaffold under dev.ulfrx.recipe.shared)
- .planning/phases/01-project-infrastructure-module-wiring/01-PATTERNS.md lines 73-77 (shared package scaffold as .gitkeep marker)
- .planning/phases/01-project-infrastructure-module-wiring/01-RESEARCH.md line 289 (shared/src/commonMain/kotlin/dev/ulfrx/recipe/shared/ NEW empty pkg)
</read_first>
<action>
Create an empty `.gitkeep` file at `shared/src/commonMain/kotlin/dev/ulfrx/recipe/shared/.gitkeep`. The parent directories do not exist yet — create them as part of the write.
The file content is zero bytes (empty). Its purpose is purely to make `dev.ulfrx.recipe.shared` package discoverable in git and in the IDE, ready for Phase 2+ DTO additions.
DO NOT:
- Touch or delete `shared/src/commonMain/kotlin/dev/ulfrx/recipe/Greeting.kt` — template class, stays
- Touch `Platform.kt` or `Constants.kt` — template classes, stay
- Add any other file under the new `shared/` package
- Add `expect`/`actual` declarations anywhere in shared/ (Phase 2+ scope)
Note the namespace layering: `shared/src/commonMain/kotlin/dev/ulfrx/recipe/` is the ROOT package (`dev.ulfrx.recipe` — where Constants.kt lives), and `shared/src/commonMain/kotlin/dev/ulfrx/recipe/shared/` is a SUB-package (`dev.ulfrx.recipe.shared` — where Phase 2+ DTOs will live). Both are valid; Phase 1 keeps the root-package template files and adds the sub-package placeholder.
</action>
<verify>
<automated>test -f shared/src/commonMain/kotlin/dev/ulfrx/recipe/shared/.gitkeep && test -d shared/src/commonMain/kotlin/dev/ulfrx/recipe/shared && test -f shared/src/commonMain/kotlin/dev/ulfrx/recipe/Greeting.kt && test -f shared/src/commonMain/kotlin/dev/ulfrx/recipe/Constants.kt && bash tools/verify-shared-pure.sh</automated>
</verify>
<acceptance_criteria>
- `shared/src/commonMain/kotlin/dev/ulfrx/recipe/shared/.gitkeep` exists (file test: `test -f`)
- Parent directory `shared/src/commonMain/kotlin/dev/ulfrx/recipe/shared` exists (directory test: `test -d`)
- Existing template files are preserved: `shared/src/commonMain/kotlin/dev/ulfrx/recipe/Greeting.kt`, `Platform.kt`, `Constants.kt` all still exist
- `tools/verify-shared-pure.sh` exits 0 — the `.gitkeep` file is not a `.kt` file so the grep skips it; the existing Greeting/Platform/Constants files still contain no forbidden imports
</acceptance_criteria>
<done>Empty package scaffold created; shared/ is ready for Phase 2+ DTOs.</done>
</task>
<task type="auto">
<name>Task 2: Run Spotless apply + full ./gradlew build + invariant scripts</name>
<files></files>
<read_first>
- .planning/phases/01-project-infrastructure-module-wiring/01-VALIDATION.md lines 40-58 (Per-Task Verification Map — the exact commands this task runs)
- .planning/phases/01-project-infrastructure-module-wiring/01-VALIDATION.md lines 27-34 (Sampling Rate — per-wave and phase-gate commands)
- .planning/phases/01-project-infrastructure-module-wiring/01-RESEARCH.md lines 1216-1241 (Success Criteria → Test Map)
</read_first>
<action>
This task is purely verification — no file modifications. Run the full phase gate in sequence. If any step fails, STOP and report the failure (do NOT silently swallow errors — a failure here means a prior plan regressed and must be fixed before Phase 1 completes).
Execute these commands IN ORDER. Each must exit 0 before proceeding to the next.
1. **Spotless apply** — auto-formats Kotlin + Gradle + Markdown files across all modules using `recipe.quality`'s ktlint rules:
```bash
./gradlew spotlessApply
```
Expected: exit 0. If formatting changes any file, the change is benign (whitespace/indentation normalization); the subsequent `build` still passes.
2. **Invariant script: no version literals** — enforces INFRA-01 SC#2:
```bash
bash tools/verify-no-version-literals.sh
```
Expected: exit 0 + `OK: no version literals outside catalog.`
3. **Invariant script: shared/ is pure** — enforces INFRA-06 SC#5:
```bash
bash tools/verify-shared-pure.sh
```
Expected: exit 0 + `OK: shared/commonMain is pure.`
4. **Invariant script: iOS K/N flags present** — enforces INFRA-03 SC#3:
```bash
bash tools/verify-ios-flags.sh
```
Expected: exit 0 + `OK: iOS binary flags present.`
5. **Full Gradle build** — enforces SC1: produces Android APK + iOS framework + server JAR:
```bash
./gradlew build
```
Expected: exit 0. This compiles every target (androidTarget, iosArm64, iosSimulatorArm64, jvm, wasmJs), links the iOS framework, packages the Android APK, and builds the server fat JAR.
After success, verify the two proof artifacts exist:
```bash
test -f composeApp/build/outputs/apk/debug/composeApp-debug.apk
test -d composeApp/build/bin/iosSimulatorArm64/debugFramework/ComposeApp.framework
```
6. **Convention plugin applied** — enforces SC4 / INFRA-02:
```bash
./gradlew :composeApp:help -q 2>&1 | grep -q 'recipe.kotlin.multiplatform' || ./gradlew :composeApp:tasks --all -q 2>&1 | grep -q 'recipe' || true
```
Ktlint/help output verification: the `help` task for a module does not always enumerate plugins in recent Gradle versions. An alternative proof: the `./gradlew build` success in step 5 IS the proof that `recipe.kotlin.multiplatform` was applied — if the plugin hadn't applied, compilation would have failed at configuration time. Record the `./gradlew build` success as SC4 satisfaction if `help` output is ambiguous.
7. **Full check** — enforces full-suite green (spotlessCheck + all tests):
```bash
./gradlew check
```
Expected: exit 0. This includes:
- `spotlessCheck` (Spotless verification)
- `:server:test` (runs the /health test from Plan 05 — no Postgres needed)
- `:composeApp:jvmTest` (template test, if present)
- `:shared:jvmTest` (template test, if present)
- Other platform tests as declared
If any of steps 1-7 fails, report exactly which step failed, the full error output, and STOP. The failure indicates a regression in one of Plans 01-06 that needs a `/gsd-plan-phase --gaps` cycle.
IMPORTANT:
- Do NOT add a `docker compose up postgres` step here. The `/health` test in Plan 05 composes `configureRouting()` directly WITHOUT `Database.migrate()` — no Postgres required. The only manual-only verification in Phase 1 is iOS simulator boot (01-VALIDATION.md § Manual-Only) which is deferred to a later human review.
- Do NOT run `./gradlew :server:run` here — it would call `Database.migrate()` which requires a running Postgres. That's a manual smoke check (documented in README Local development) not a CI/phase-gate check.
</action>
<verify>
<automated>cd /Users/rwilk/dev/repo/recipe && ./gradlew spotlessApply -q && bash tools/verify-no-version-literals.sh && bash tools/verify-shared-pure.sh && bash tools/verify-ios-flags.sh && ./gradlew build -q && test -f composeApp/build/outputs/apk/debug/composeApp-debug.apk && test -d composeApp/build/bin/iosSimulatorArm64/debugFramework/ComposeApp.framework && ./gradlew check -q</automated>
</verify>
<acceptance_criteria>
- `./gradlew spotlessApply` exits 0
- `tools/verify-no-version-literals.sh` exits 0 (SC2)
- `tools/verify-shared-pure.sh` exits 0 (SC5)
- `tools/verify-ios-flags.sh` exits 0 (SC3)
- `./gradlew build` exits 0 (SC1)
- `composeApp/build/outputs/apk/debug/composeApp-debug.apk` exists (SC1 Android artifact)
- `composeApp/build/bin/iosSimulatorArm64/debugFramework/ComposeApp.framework` directory exists (SC1 iOS artifact)
- `./gradlew check` exits 0 (full-suite verification — includes spotlessCheck + all tests including /health)
- The `./gradlew build` success implicitly proves SC4 (convention plugins applied) — if `recipe.kotlin.multiplatform` hadn't applied, the build would have failed during module configuration
- No `BUILD FAILED` string appears in the transcript
</acceptance_criteria>
<done>Phase 1 green — all 5 SCs and all 4 phase requirements (INFRA-01/02/03/06) verified by automated commands.</done>
</task>
</tasks>
<threat_model>
## Trust Boundaries
| Boundary | Description |
|----------|-------------|
| Developer host → Gradle daemon | Same process; Gradle executes precompiled plugin code from `build-logic/` with full project access by design. |
| Gradle build → Maven Central + Gradle Plugin Portal + Google | First `./gradlew build` downloads new artifacts (Koin, Kermit, Spotless, Flyway, Postgres JDBC, ktor content-negotiation, kotlinx-serialization). All versions pinned via catalog (Plan 01). |
| iOS framework link → K/N compiler | Uses the two binary flags from gradle.properties (`gc=cms`, `objcDisposeOnMain=false`). Verified by `tools/verify-ios-flags.sh` (infrastructure check) + deferred iOS simulator boot check (manual). |
## STRIDE Threat Register
| Threat ID | Category | Component | Disposition | Mitigation Plan |
|-----------|----------|-----------|-------------|-----------------|
| T-01-07-01 | Denial of Service | `./gradlew build` downloading fresh deps, causing slow first-build | accept | First build may take 2-5 minutes as Koin/Kermit/Flyway/Postgres JDBC artifacts download (~80 MB per 01-RESEARCH.md § Runtime State Inventory). Subsequent builds use Gradle cache. Not a threat — just an expectation. |
| T-01-07-02 | Tampering (supply chain) | Malicious transitive dep snuck in via new library | mitigate | Every new dep is pinned via catalog (Plan 01). Gradle verification metadata (`gradle/verification-metadata.xml`) is NOT enabled in Phase 1 — it's a future enhancement (Phase 11 CI setup). Risk accepted for Phase 1 single-dev local-build scope. |
| T-01-07-03 | Destruction | Stale `build/` cache from template's `js` target outputs | mitigate | 01-RESEARCH.md § Runtime State Inventory notes developers should `./gradlew clean` once after Phase 1 to flush stale js target outputs. Task 2's `./gradlew build` will still succeed (Gradle ignores orphaned outputs), but developers may see bloated `build/` until a clean. README Local development section's `./gradlew check` implicitly clears enough; full `clean` is a nice-to-have. |
| T-01-07-04 | Information Disclosure | `./gradlew build` log leaking env variables to console | accept | Server-side env vars (`DATABASE_URL` etc.) are only read at server boot, not during `./gradlew build`. The `/health` test composes routing without the DB. No secrets logged during build. |
</threat_model>
<verification>
Phase-level verification for this plan — this IS the phase gate. Success here equals Phase 1 completion.
Hard gate commands (all must exit 0):
1. `./gradlew spotlessApply` — auto-format
2. `tools/verify-no-version-literals.sh` — SC2 / INFRA-01
3. `tools/verify-shared-pure.sh` — SC5 / INFRA-06
4. `tools/verify-ios-flags.sh` — SC3 / INFRA-03
5. `./gradlew build` — SC1, implicitly SC4 / INFRA-02
6. `./gradlew check` — full-suite (spotlessCheck + all tests)
Manual-only verifications (deferred per 01-VALIDATION.md § Manual-Only — NOT in Task 2 `<automated>`):
- iOS simulator debug launch without legacy memory-manager warnings (requires Xcode + simulator)
- Hot-reload dev loop on Desktop (interactive)
- Server `/health` reachable via curl when Postgres is up (requires `docker compose up -d postgres` + `./gradlew :server:run`)
These manual checks are recommended for the developer to run once; they are NOT gate-blocking for automated Phase 1 completion.
</verification>
<success_criteria>
- `shared/src/commonMain/kotlin/dev/ulfrx/recipe/shared/.gitkeep` created
- `./gradlew spotlessApply` green
- All 3 `tools/verify-*.sh` scripts green
- `./gradlew build` green + Android APK + iOS framework artifacts exist
- `./gradlew check` green
- No manual step required to pass this plan
</success_criteria>
<output>
After completion, create `.planning/phases/01-project-infrastructure-module-wiring/01-07-SUMMARY.md` recording: the final 7 verification command outputs (exit codes), the size of the produced APK and iOS framework, the total `./gradlew build` time, and explicit confirmation that all 5 ROADMAP SCs (SC1-SC5) and 4 phase requirements (INFRA-01/02/03/06) are satisfied.
Include in the summary a brief "Manual smoke checks to run later" list pointing at 01-VALIDATION.md § Manual-Only:
- iOS simulator boot without legacy-MM warnings
- Desktop hot-reload regression check
- docker compose up postgres + server /health curl smoke test
</output>

File diff suppressed because it is too large Load Diff