Files
recipe/.planning/phases/01-project-infrastructure-module-wiring/01-05-PLAN.md
2026-04-29 20:54:01 +02:00

32 KiB

phase, plan, type, wave, depends_on, files_modified, autonomous, requirements, requirements_addressed, must_haves
phase plan type wave depends_on files_modified autonomous requirements requirements_addressed must_haves
01-project-infrastructure-module-wiring 05 execute 2
01
02
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
true
INFRA-02
INFRA-02
truths artifacts key_links
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
path provides exports
server/src/main/kotlin/dev/ulfrx/recipe/Application.kt main() → embeddedServer(Netty, SERVER_PORT, ::module).start(); Application.module() installs ContentNegotiation(json), invokes Database.migrate(this), and registers GET /health
main
Application.module
path provides exports
server/src/main/kotlin/dev/ulfrx/recipe/Database.kt object Database { fun migrate(app: Application) } — reads HOCON config, runs Flyway, throws IllegalStateException on failure
Database
path provides
server/src/main/resources/application.conf HOCON config with ktor.deployment.port (8080 + ${?PORT}) and database.url/user/password (localhost defaults + ${?DATABASE_URL/USER/PASSWORD})
path provides
server/src/main/resources/db/migration/.gitkeep Empty directory placeholder ensuring classpath:db/migration resolves for Flyway even when no SQL files exist yet
path provides
server/src/test/kotlin/dev/ulfrx/recipe/ApplicationTest.kt ApplicationTest with /health route assertion — composes routing without calling Database.migrate (no Postgres required)
from to via pattern
server/src/main/kotlin/dev/ulfrx/recipe/Application.kt server/src/main/kotlin/dev/ulfrx/recipe/Database.kt Database.migrate(this) inside Application.module() Database.migrate(this)
from to via pattern
server/src/main/kotlin/dev/ulfrx/recipe/Database.kt server/src/main/resources/application.conf app.environment.config.property("database.url").getString() etc. config.property("database.
from to via pattern
Flyway.configure().locations(...) server/src/main/resources/db/migration/ classpath:db/migration classpath:db/migration
Deliver the server's running-but-empty state: a `GET /health` route returning `{"status":"ok"}`, HOCON-based config (`application.conf`) with env-var overrides, a `Database` object that runs Flyway against Postgres at boot time (failing loudly if Postgres is unreachable), and an updated `ApplicationTest.kt` that asserts the route in isolation without requiring a running database. Also scaffold `server/src/main/resources/db/migration/` as an empty directory so Flyway's classpath resolution succeeds before Phase 3 adds `V1__init.sql`.

Purpose: This plan closes D-16 — Phase 3 drops its first migration into an already-working migrator; Phase 11 deploys to the homelab with the same Ktor HOCON config reading real env vars. The fail-loud contract for unreachable Postgres is load-bearing: it surfaces config errors at boot, not at first 5xx.

Output: 2 Kotlin source files (Application.kt rewrite + Database.kt new), 1 HOCON config, 1 directory placeholder, 1 test rewrite.

<execution_context> @$HOME/.claude/get-shit-done/workflows/execute-plan.md @$HOME/.claude/get-shit-done/templates/summary.md </execution_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

From io.ktor.server.application:

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:

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:

object ContentNegotiation : BaseApplicationPlugin<...>
fun ContentNegotiationConfig.json()  // installs kotlinx.serialization JSON converter

From io.ktor.server.routing + io.ktor.server.response:

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):

fun testApplication(block: suspend ApplicationTestBuilder.() -> Unit)
// ApplicationTestBuilder provides:
fun application(block: Application.() -> Unit)
val client: HttpClient

From org.flywaydb.core:

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:

@Serializable

From org.slf4j:

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):

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):

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):

// testRoot() asserts GET / returns "Ktor: ${Greeting().greet()}" — to be replaced with /health assertion
Task 1: Create application.conf + db/migration/.gitkeep + Database.kt server/src/main/resources/application.conf, server/src/main/resources/db/migration/.gitkeep, server/src/main/kotlin/dev/ulfrx/recipe/Database.kt - .planning/phases/01-project-infrastructure-module-wiring/01-RESEARCH.md lines 988-1023 (canonical Database.kt — SLF4J variant since server uses Logback not Kermit) - .planning/phases/01-project-infrastructure-module-wiring/01-RESEARCH.md lines 1029-1051 (canonical application.conf HOCON) - .planning/phases/01-project-infrastructure-module-wiring/01-RESEARCH.md lines 692-717 (PITFALL #5 — `${?X}` env-var HOCON syntax) - .planning/phases/01-project-infrastructure-module-wiring/01-RESEARCH.md lines 719-724 (PITFALL #6 — Flyway runtime API, not plugin at build time) - .planning/phases/01-project-infrastructure-module-wiring/01-PATTERNS.md lines 990-1076 (Database.kt + application.conf + .gitkeep deltas) - .planning/phases/01-project-infrastructure-module-wiring/01-CONTEXT.md D-16 (server /health + Flyway + Postgres env overrides) - server/build.gradle.kts (verify Plan 03 made `implementation(projects.shared)` present so `SERVER_PORT` is still reachable) Create three files.

File 1: server/src/main/resources/application.conf (HOCON, 01-RESEARCH.md lines 1031-1051):

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):

package dev.ulfrx.recipe

import io.ktor.server.application.Application
import org.flywaydb.core.Flyway
import org.slf4j.LoggerFactory

object Database {
    private val log = LoggerFactory.getLogger(Database::class.java)

    fun migrate(app: Application) {
        val url = app.environment.config.property("database.url").getString()
        val user = app.environment.config.property("database.user").getString()
        val password = app.environment.config.property("database.password").getString()

        log.info("Connecting to {} as {} and running Flyway migrations", url, user)

        runCatching {
            Flyway.configure()
                .dataSource(url, user, password)
                .locations("classpath:db/migration")
                .baselineOnMigrate(true)
                .validateOnMigrate(true)
                .cleanDisabled(true)
                .load()
                .migrate()
        }.onFailure { ex ->
            log.error("Flyway migration failed — cannot start server", ex)
            throw IllegalStateException("Database unreachable or migration failed", ex)
        }
    }
}

CRITICAL:

  • throw IllegalStateException(...) is the fail-loud contract (D-16). Do NOT wrap it in a generic try { } catch { return false } — the server MUST refuse to start if the DB is unreachable.
  • Use SLF4J (LoggerFactory.getLogger(...)), NOT Kermit. The server has Logback wired via logback.xml; Kermit is the CLIENT logger (composeApp only).
  • Log credentials are NOT logged — only url and user appear in the info line. password is used for dataSource(...) only.
  • cleanDisabled = true prevents accidental flywayClean wiping tables in dev/prod (matches recipe.jvm.server.gradle.kts plugin config — double-enforcement).
  • baselineOnMigrate = true tolerates an existing DB with no Flyway history (defensive — Phase 1's DB is empty, Phase 11's homelab DB may pre-exist).
  • locations("classpath:db/migration") points to the resource directory the .gitkeep keeps alive. test -f server/src/main/resources/application.conf && test -f server/src/main/resources/db/migration/.gitkeep && test -f server/src/main/kotlin/dev/ulfrx/recipe/Database.kt && grep -q 'port = 8080' server/src/main/resources/application.conf && grep -q 'port = ${?PORT}' server/src/main/resources/application.conf && grep -q 'url = "jdbc:postgresql://localhost:5432/recipe"' server/src/main/resources/application.conf && grep -q 'url = ${?DATABASE_URL}' server/src/main/resources/application.conf && grep -q 'user = "recipe"' server/src/main/resources/application.conf && grep -q 'user = ${?DATABASE_USER}' server/src/main/resources/application.conf && grep -q 'password = "recipe"' server/src/main/resources/application.conf && grep -q 'password = ${?DATABASE_PASSWORD}' server/src/main/resources/application.conf && grep -q 'object Database' server/src/main/kotlin/dev/ulfrx/recipe/Database.kt && grep -q 'org.flywaydb.core.Flyway' server/src/main/kotlin/dev/ulfrx/recipe/Database.kt && grep -q 'org.slf4j.LoggerFactory' server/src/main/kotlin/dev/ulfrx/recipe/Database.kt && grep -q 'cleanDisabled(true)' server/src/main/kotlin/dev/ulfrx/recipe/Database.kt && grep -q 'baselineOnMigrate(true)' server/src/main/kotlin/dev/ulfrx/recipe/Database.kt && grep -q 'throw IllegalStateException' server/src/main/kotlin/dev/ulfrx/recipe/Database.kt && grep -q 'classpath:db/migration' server/src/main/kotlin/dev/ulfrx/recipe/Database.kt <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> HOCON config, Flyway migration resource dir, and fail-loud Database.migrate exist.
Task 2: Rewrite Application.kt to install ContentNegotiation, call Database.migrate, expose /health server/src/main/kotlin/dev/ulfrx/recipe/Application.kt - server/src/main/kotlin/dev/ulfrx/recipe/Application.kt (current 20 lines — target of rewrite) - .planning/phases/01-project-infrastructure-module-wiring/01-RESEARCH.md lines 952-985 (canonical Application.kt with ContentNegotiation + /health) - .planning/phases/01-project-infrastructure-module-wiring/01-PATTERNS.md lines 940-986 (Application.kt deltas) - .planning/phases/01-project-infrastructure-module-wiring/01-CONTEXT.md D-16 (sentinel JSON body for /health — Claude's discretion; use trivial `{"status":"ok"}`) - shared/src/commonMain/kotlin/dev/ulfrx/recipe/Constants.kt (verify `SERVER_PORT` constant is defined) Replace the entire content of `server/src/main/kotlin/dev/ulfrx/recipe/Application.kt` with:
package dev.ulfrx.recipe

import io.ktor.serialization.kotlinx.json.json
import io.ktor.server.application.Application
import io.ktor.server.application.install
import io.ktor.server.engine.embeddedServer
import io.ktor.server.netty.Netty
import io.ktor.server.plugins.contentnegotiation.ContentNegotiation
import io.ktor.server.response.respond
import io.ktor.server.routing.get
import io.ktor.server.routing.routing
import kotlinx.serialization.Serializable

fun main() {
    embeddedServer(Netty, port = SERVER_PORT, host = "0.0.0.0", module = Application::module)
        .start(wait = true)
}

@Serializable
private data class Health(val status: String)

fun Application.module() {
    install(ContentNegotiation) {
        json()
    }
    Database.migrate(this)
    configureRouting()
}

fun Application.configureRouting() {
    routing {
        get("/health") {
            call.respond(Health(status = "ok"))
        }
    }
}

DELETIONS:

  • DROP the wildcard imports (io.ktor.server.application.*, io.ktor.server.engine.*, io.ktor.server.netty.*, io.ktor.server.response.*, io.ktor.server.routing.*) — replaced with explicit imports to satisfy D-11 allWarningsAsErrors (wildcard-unused warnings would fail the build)
  • DROP get("/") { call.respondText("Ktor: ${Greeting().greet()}") } — replaced by /health

ADDITIONS:

  • ADD install(ContentNegotiation) { json() } — required for @Serializable response serialization
  • ADD Database.migrate(this) call inside Application.module() — fails loudly if Postgres unreachable
  • ADD @Serializable private data class Health(val status: String) — the /health response shape
  • ADD Application.configureRouting() extension function — extracted from module() so the test (Task 3) can compose routing WITHOUT invoking Database.migrate()

KEEP:

  • package dev.ulfrx.recipe (unchanged)
  • fun main() { embeddedServer(Netty, port = SERVER_PORT, host = "0.0.0.0", module = Application::module).start(wait = true) } — programmatic boot, unchanged shape
  • SERVER_PORT constant is referenced from shared/ (unchanged)

CRITICAL:

  • The extraction of configureRouting() from module() is load-bearing for the test. Task 3 needs to test routing without calling Database.migrate(this) (which requires a real Postgres).
  • install(ContentNegotiation) { json() } — MUST be installed before any route returns a @Serializable type. Both module() (for production) and the test (Task 3) must install it. grep -q '^package dev.ulfrx.recipe$' server/src/main/kotlin/dev/ulfrx/recipe/Application.kt && grep -q 'import io.ktor.server.plugins.contentnegotiation.ContentNegotiation' server/src/main/kotlin/dev/ulfrx/recipe/Application.kt && grep -q 'import io.ktor.serialization.kotlinx.json.json' server/src/main/kotlin/dev/ulfrx/recipe/Application.kt && grep -q 'import kotlinx.serialization.Serializable' server/src/main/kotlin/dev/ulfrx/recipe/Application.kt && ! grep -qE 'import io.ktor.server.application.*' server/src/main/kotlin/dev/ulfrx/recipe/Application.kt && grep -q 'install(ContentNegotiation)' server/src/main/kotlin/dev/ulfrx/recipe/Application.kt && grep -q 'Database.migrate(this)' server/src/main/kotlin/dev/ulfrx/recipe/Application.kt && grep -q 'get("/health")' server/src/main/kotlin/dev/ulfrx/recipe/Application.kt && grep -q 'data class Health(val status: String)' server/src/main/kotlin/dev/ulfrx/recipe/Application.kt && grep -q 'fun Application.configureRouting()' server/src/main/kotlin/dev/ulfrx/recipe/Application.kt && ! grep -q 'call.respondText' server/src/main/kotlin/dev/ulfrx/recipe/Application.kt && grep -q 'embeddedServer(Netty, port = SERVER_PORT' server/src/main/kotlin/dev/ulfrx/recipe/Application.kt <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> Application.kt installs ContentNegotiation, runs Flyway at boot, exposes /health JSON, splits routing for testability.
Task 3: Rewrite ApplicationTest.kt to assert GET /health returns 200 with JSON body server/src/test/kotlin/dev/ulfrx/recipe/ApplicationTest.kt - server/src/test/kotlin/dev/ulfrx/recipe/ApplicationTest.kt (current 20-line content — target of rewrite) - .planning/phases/01-project-infrastructure-module-wiring/01-RESEARCH.md lines 1084-1125 (canonical ApplicationTest.kt variant) - .planning/phases/01-project-infrastructure-module-wiring/01-PATTERNS.md lines 1079-1125 (test delta explaining the no-Postgres-required refactor) - server/src/main/kotlin/dev/ulfrx/recipe/Application.kt (the freshly rewritten file — the test references `configureRouting()` from this file) - .planning/phases/01-project-infrastructure-module-wiring/01-VALIDATION.md lines 52-53 (automated command this test must satisfy: `./gradlew :server:test --tests "*Health*"`) Replace the entire content of `server/src/test/kotlin/dev/ulfrx/recipe/ApplicationTest.kt` with:
package dev.ulfrx.recipe

import io.ktor.client.request.get
import io.ktor.client.statement.bodyAsText
import io.ktor.http.HttpStatusCode
import io.ktor.serialization.kotlinx.json.json
import io.ktor.server.application.install
import io.ktor.server.plugins.contentnegotiation.ContentNegotiation
import io.ktor.server.testing.testApplication
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertTrue

class ApplicationTest {

    @Test
    fun `health endpoint returns 200 with status ok`() = testApplication {
        application {
            install(ContentNegotiation) {
                json()
            }
            configureRouting()
        }
        val response = client.get("/health")
        assertEquals(HttpStatusCode.OK, response.status)
        val body = response.bodyAsText()
        assertTrue(body.contains("\"status\""), "expected body to contain status field, was: $body")
        assertTrue(body.contains("\"ok\""), "expected body to contain ok value, was: $body")
    }
}

CRITICAL:

  • The test invokes configureRouting() directly (extracted in Task 2) and does NOT call Database.migrate(...). This is the KEY refactor: the test runs without a running Postgres, so ./gradlew :server:test can succeed in CI / fresh clones.
  • install(ContentNegotiation) { json() } is explicitly installed inside application { } — because the production Application.module() installs it, but the test composes only configureRouting() and must install the plugin itself.
  • Imports are explicit (no wildcards) to satisfy D-11 allWarningsAsErrors.
  • Assertions check for "status" and "ok" substrings in the JSON body — this is a structural check that works regardless of JSON field ordering.
  • The test function name uses backtick-quoted natural-language identifier (`health endpoint returns 200 with status ok`) — standard Kotlin test-naming convention; the test will run via ./gradlew :server:test --tests "*health*" or similar wildcards.

DELETIONS:

  • DROP the existing testRoot() test — it asserted the template's / route response with "Ktor: ${Greeting().greet()}", which no longer exists.
  • DROP wildcard imports io.ktor.client.request.*, io.ktor.client.statement.*, io.ktor.http.*, io.ktor.server.testing.*, kotlin.test.*. test -f server/src/test/kotlin/dev/ulfrx/recipe/ApplicationTest.kt && grep -q 'health endpoint returns 200' server/src/test/kotlin/dev/ulfrx/recipe/ApplicationTest.kt && grep -q 'configureRouting()' server/src/test/kotlin/dev/ulfrx/recipe/ApplicationTest.kt && ! grep -q 'Database.migrate' server/src/test/kotlin/dev/ulfrx/recipe/ApplicationTest.kt && grep -q 'install(ContentNegotiation)' server/src/test/kotlin/dev/ulfrx/recipe/ApplicationTest.kt && grep -q 'client.get("/health")' server/src/test/kotlin/dev/ulfrx/recipe/ApplicationTest.kt && grep -q 'HttpStatusCode.OK' server/src/test/kotlin/dev/ulfrx/recipe/ApplicationTest.kt && ! grep -q 'testRoot' server/src/test/kotlin/dev/ulfrx/recipe/ApplicationTest.kt && ! grep -q 'Greeting().greet()' server/src/test/kotlin/dev/ulfrx/recipe/ApplicationTest.kt && ! grep -qE 'import kotlin.test.*' server/src/test/kotlin/dev/ulfrx/recipe/ApplicationTest.kt && ./gradlew :server:test --tests "health" -q <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> /health test passes without requiring Postgres; old template test removed.

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

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