---
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 && ./gradlew :server:test --tests "*health*" -q</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>
