docs(02): resolve planning verification artifacts
This commit is contained in:
815
.planning/phases/02-authentication-foundation/02-PATTERNS.md
Normal file
815
.planning/phases/02-authentication-foundation/02-PATTERNS.md
Normal file
@@ -0,0 +1,815 @@
|
||||
# Phase 2: Authentication Foundation - Pattern Map
|
||||
|
||||
**Mapped:** 2026-04-27
|
||||
**Files analyzed:** 56 new/modified files or file groups
|
||||
**Analogs found:** 48 / 56
|
||||
|
||||
## File Classification
|
||||
|
||||
| New/Modified File | Role | Data Flow | Closest Analog | Match Quality |
|
||||
|-------------------|------|-----------|----------------|---------------|
|
||||
| `gradle/libs.versions.toml` | config | build-config | `gradle/libs.versions.toml` | exact |
|
||||
| `shared/build.gradle.kts` | config | build-config | `server/build.gradle.kts` + `shared/build.gradle.kts` | role-match |
|
||||
| `shared/src/commonMain/kotlin/dev/ulfrx/recipe/shared/Constants.kt` | config | transform | `shared/src/commonMain/kotlin/dev/ulfrx/recipe/Constants.kt` | role-match |
|
||||
| `shared/src/commonMain/kotlin/dev/ulfrx/recipe/shared/dto/User.kt` | model | request-response | `server/src/main/kotlin/dev/ulfrx/recipe/Application.kt` `Health` DTO | partial |
|
||||
| `shared/src/commonMain/kotlin/dev/ulfrx/recipe/shared/dto/MeResponse.kt` | model | request-response | `server/src/main/kotlin/dev/ulfrx/recipe/Application.kt` `Health` DTO | partial |
|
||||
| `shared/src/commonTest/kotlin/dev/ulfrx/recipe/shared/dto/MeResponseSerializationTest.kt` | test | transform | `shared/src/commonTest/kotlin/dev/ulfrx/recipe/SharedCommonTest.kt` | role-match |
|
||||
| `server/build.gradle.kts` | config | build-config | `server/build.gradle.kts` | exact |
|
||||
| `server/src/main/resources/application.conf` | config | request-response | `server/src/main/resources/application.conf` | exact |
|
||||
| `server/src/main/resources/db/migration/V1__users.sql` | migration | CRUD | `server/src/main/resources/db/migration/.gitkeep` + `Database.kt` Flyway path | partial |
|
||||
| `server/src/main/kotlin/dev/ulfrx/recipe/auth/AuthConfig.kt` | config | request-response | `server/src/main/resources/application.conf` + `Database.kt` config reads | role-match |
|
||||
| `server/src/main/kotlin/dev/ulfrx/recipe/auth/AuthPlugin.kt` | middleware | request-response | `server/src/main/kotlin/dev/ulfrx/recipe/Application.kt` plugin install pattern | role-match |
|
||||
| `server/src/main/kotlin/dev/ulfrx/recipe/auth/PrincipalResolver.kt` | service | CRUD | `server/src/main/kotlin/dev/ulfrx/recipe/Database.kt` fail-loud DB boundary | partial |
|
||||
| `server/src/main/kotlin/dev/ulfrx/recipe/auth/UsersTable.kt` | model | CRUD | `V1__users.sql` planned migration + Exposed DSL decision | no existing analog |
|
||||
| `server/src/main/kotlin/dev/ulfrx/recipe/auth/MeRoute.kt` | route | request-response | `server/src/main/kotlin/dev/ulfrx/recipe/Application.kt` `configureRouting()` | exact |
|
||||
| `server/src/main/kotlin/dev/ulfrx/recipe/Application.kt` | controller | request-response | `server/src/main/kotlin/dev/ulfrx/recipe/Application.kt` | exact |
|
||||
| `server/src/main/kotlin/dev/ulfrx/recipe/Database.kt` | service | file-I/O | `server/src/main/kotlin/dev/ulfrx/recipe/Database.kt` | exact |
|
||||
| `server/src/test/kotlin/dev/ulfrx/recipe/auth/JwtAuthTest.kt` | test | request-response | `server/src/test/kotlin/dev/ulfrx/recipe/ApplicationTest.kt` | exact |
|
||||
| `server/src/test/kotlin/dev/ulfrx/recipe/auth/JwtTestSupport.kt` | test utility | transform | `server/src/test/kotlin/dev/ulfrx/recipe/ApplicationTest.kt` | partial |
|
||||
| `composeApp/build.gradle.kts` | config | build-config | `composeApp/build.gradle.kts` | exact |
|
||||
| `composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/auth/OidcClient.kt` | service | event-driven | `composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/di/Koin.kt` expect-free common seam style | partial |
|
||||
| `composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/auth/OidcResult.kt` | model | event-driven | `shared` DTO style + `kotlin.test` examples | partial |
|
||||
| `composeApp/src/androidMain/kotlin/dev/ulfrx/recipe/auth/OidcClient.android.kt` | service | event-driven | `composeApp/src/androidMain/kotlin/dev/ulfrx/recipe/MainActivity.kt` | role-match |
|
||||
| `composeApp/src/iosMain/kotlin/dev/ulfrx/recipe/auth/OidcClient.ios.kt` | service | event-driven | `composeApp/src/iosMain/kotlin/dev/ulfrx/recipe/di/KoinIos.kt` | role-match |
|
||||
| `composeApp/src/jvmMain/kotlin/dev/ulfrx/recipe/auth/OidcClient.jvm.kt` | service | transform | `composeApp/src/jvmMain/kotlin/dev/ulfrx/recipe/main.kt` | role-match |
|
||||
| `composeApp/src/webMain/kotlin/dev/ulfrx/recipe/auth/OidcClient.wasmJs.kt` | service | transform | `composeApp/src/webMain/kotlin/dev/ulfrx/recipe/main.kt` | role-match |
|
||||
| `composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/auth/AuthState.kt` | model | event-driven | `shared` model/test shape | partial |
|
||||
| `composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/auth/AuthSession.kt` | service | event-driven | `composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/di/AppModule.kt` Koin singleton hook | role-match |
|
||||
| `composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/auth/TokenStore.kt` | service | file-I/O | `tools/verify-shared-pure.sh` persistence-boundary guard | partial |
|
||||
| `composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/auth/AuthHttpClient.kt` | service | request-response | no Ktor client analog; use research Ktor bearer pattern | no existing analog |
|
||||
| `composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/auth/MeClient.kt` | service | request-response | `server/src/test/kotlin/dev/ulfrx/recipe/ApplicationTest.kt` client GET pattern | role-match |
|
||||
| `composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/auth/AuthModule.kt` | provider | dependency-injection | `composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/di/AppModule.kt` | exact |
|
||||
| `composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/di/AppModule.kt` | provider | dependency-injection | `composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/di/AppModule.kt` | exact |
|
||||
| `composeApp/src/androidMain/kotlin/dev/ulfrx/recipe/auth/SettingsFactory.android.kt` | service | file-I/O | `MainApplication.kt` Android context injection | role-match |
|
||||
| `composeApp/src/iosMain/kotlin/dev/ulfrx/recipe/auth/SettingsFactory.ios.kt` | service | file-I/O | `KoinIos.kt` iOS actual bridge | partial |
|
||||
| `composeApp/src/*Main/kotlin/dev/ulfrx/recipe/auth/HttpClientEngine.*.kt` | utility | request-response | platform `main.kt` target-specific files | role-match |
|
||||
| `composeApp/src/androidMain/kotlin/dev/ulfrx/recipe/MainActivity.kt` | controller | event-driven | `MainActivity.kt` | exact |
|
||||
| `composeApp/src/androidMain/kotlin/dev/ulfrx/recipe/MainApplication.kt` | provider | dependency-injection | `MainApplication.kt` | exact |
|
||||
| `composeApp/src/androidMain/AndroidManifest.xml` | config | event-driven | `AndroidManifest.xml` | exact |
|
||||
| `iosApp/iosApp/Info.plist` | config | event-driven | `Info.plist` | exact |
|
||||
| `iosApp/iosApp/iOSApp.swift` | controller | event-driven | `iOSApp.swift` | exact |
|
||||
| `iosApp/Podfile` | config | build-config | no existing Podfile; use `composeApp/build.gradle.kts` iOS framework block | no existing analog |
|
||||
| `composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/theme/RecipeTheme.kt` | component | transform | `composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/App.kt` `MaterialTheme` wrapper | exact |
|
||||
| `composeApp/src/commonMain/composeResources/values/strings.xml` | config | transform | `composeApp/src/commonMain/composeResources/drawable/compose-multiplatform.xml` | partial |
|
||||
| `composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/screens/auth/SplashScreen.kt` | component | event-driven | `composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/App.kt` | role-match |
|
||||
| `composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/screens/auth/LoginScreen.kt` | component | event-driven | `composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/App.kt` | role-match |
|
||||
| `composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/screens/auth/LoginViewModel.kt` | controller | event-driven | no ViewModel analog; use Koin/viewmodel deps and UI-SPEC method-per-action contract | no existing analog |
|
||||
| `composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/screens/auth/PostLoginPlaceholderScreen.kt` | component | event-driven | `composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/App.kt` | role-match |
|
||||
| `composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/screens/auth/PostLoginViewModel.kt` | controller | event-driven | no ViewModel analog; use same shape as `LoginViewModel` | no existing analog |
|
||||
| `composeApp/src/commonTest/kotlin/dev/ulfrx/recipe/auth/AuthSessionTest.kt` | test | event-driven | `composeApp/src/commonTest/kotlin/dev/ulfrx/recipe/ComposeAppCommonTest.kt` | role-match |
|
||||
| `docs/authentik-setup.md` | docs | manual-UAT | `README.md` local development pattern if present; otherwise phase docs style | partial |
|
||||
|
||||
## Pattern Assignments
|
||||
|
||||
### Shared DTO + Constants Files
|
||||
|
||||
Applies to:
|
||||
- `shared/src/commonMain/kotlin/dev/ulfrx/recipe/shared/Constants.kt`
|
||||
- `shared/src/commonMain/kotlin/dev/ulfrx/recipe/shared/dto/User.kt`
|
||||
- `shared/src/commonMain/kotlin/dev/ulfrx/recipe/shared/dto/MeResponse.kt`
|
||||
- `shared/src/commonTest/kotlin/dev/ulfrx/recipe/shared/dto/MeResponseSerializationTest.kt`
|
||||
|
||||
**Analog:** `shared/build.gradle.kts`, `shared/src/commonMain/kotlin/dev/ulfrx/recipe/Constants.kt`, `server/src/main/kotlin/dev/ulfrx/recipe/Application.kt`, `shared/src/commonTest/kotlin/dev/ulfrx/recipe/SharedCommonTest.kt`
|
||||
|
||||
**Shared purity pattern** (`shared/build.gradle.kts` lines 16-20):
|
||||
```kotlin
|
||||
sourceSets {
|
||||
commonMain.dependencies {
|
||||
// Phase 1: intentionally empty. Domain models + DTOs land Phase 2+.
|
||||
// D-19 / INFRA-06: No Ktor, Compose, SQLDelight, Koin, or Kermit here - EVER.
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Public constant pattern** (`shared/src/commonMain/kotlin/dev/ulfrx/recipe/Constants.kt` lines 1-3):
|
||||
```kotlin
|
||||
package dev.ulfrx.recipe
|
||||
|
||||
public const val SERVER_PORT: Int = 8080
|
||||
```
|
||||
|
||||
**Serializable DTO pattern** (`server/src/main/kotlin/dev/ulfrx/recipe/Application.kt` lines 12, 19-22):
|
||||
```kotlin
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
@Serializable
|
||||
private data class Health(
|
||||
val status: String,
|
||||
)
|
||||
```
|
||||
|
||||
**Test skeleton pattern** (`shared/src/commonTest/kotlin/dev/ulfrx/recipe/SharedCommonTest.kt` lines 1-10):
|
||||
```kotlin
|
||||
package dev.ulfrx.recipe
|
||||
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
|
||||
class SharedCommonTest {
|
||||
@Test
|
||||
fun example() {
|
||||
assertEquals(3, 1 + 2)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Planner note:** New shared symbols must be `public` because `shared` has `explicitApi()` enabled at `shared/build.gradle.kts` lines 9-10. Keep only Kotlin stdlib and `kotlinx.serialization` imports in shared DTOs.
|
||||
|
||||
### Gradle Catalog + Module Build Files
|
||||
|
||||
Applies to:
|
||||
- `gradle/libs.versions.toml`
|
||||
- `shared/build.gradle.kts`
|
||||
- `server/build.gradle.kts`
|
||||
- `composeApp/build.gradle.kts`
|
||||
|
||||
**Analog:** existing build files
|
||||
|
||||
**Version catalog organization** (`gradle/libs.versions.toml` lines 1-24, 27-43, 68-79):
|
||||
```toml
|
||||
[versions]
|
||||
kotlin = "2.3.20"
|
||||
kotlinx-serialization = "1.7.3"
|
||||
ktor = "3.4.1"
|
||||
|
||||
[libraries]
|
||||
compose-runtime = { module = "org.jetbrains.compose.runtime:runtime", version.ref = "composeMultiplatform" }
|
||||
ktor-serverCore = { module = "io.ktor:ktor-server-core-jvm", version.ref = "ktor" }
|
||||
|
||||
[plugins]
|
||||
kotlinSerialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlin" }
|
||||
```
|
||||
|
||||
**Server dependency pattern** (`server/build.gradle.kts` lines 1-8, 27-39):
|
||||
```kotlin
|
||||
plugins {
|
||||
alias(libs.plugins.kotlinJvm)
|
||||
alias(libs.plugins.kotlinSerialization)
|
||||
alias(libs.plugins.ktor)
|
||||
alias(libs.plugins.flywayPlugin)
|
||||
application
|
||||
id("recipe.quality")
|
||||
}
|
||||
|
||||
dependencies {
|
||||
implementation(libs.ktor.serverCore)
|
||||
implementation(libs.ktor.serverNetty)
|
||||
implementation(libs.ktor.serverContentNegotiation)
|
||||
implementation(libs.ktor.serializationKotlinxJson)
|
||||
implementation(projects.shared)
|
||||
testImplementation(libs.ktor.serverTestHost)
|
||||
testImplementation(libs.kotlin.testJunit)
|
||||
}
|
||||
```
|
||||
|
||||
**Compose dependency pattern** (`composeApp/build.gradle.kts` lines 48-69):
|
||||
```kotlin
|
||||
sourceSets {
|
||||
commonMain.dependencies {
|
||||
implementation(project.dependencies.platform(libs.koin.bom))
|
||||
implementation(libs.koin.core)
|
||||
implementation(libs.koin.compose)
|
||||
implementation(libs.koin.composeViewmodel)
|
||||
implementation(libs.kermit)
|
||||
implementation(libs.compose.runtime)
|
||||
implementation(libs.compose.foundation)
|
||||
implementation(libs.compose.material3)
|
||||
implementation(libs.compose.components.resources)
|
||||
implementation(projects.shared)
|
||||
}
|
||||
androidMain.dependencies {
|
||||
implementation(libs.compose.uiToolingPreview)
|
||||
implementation(libs.androidx.activity.compose)
|
||||
implementation(libs.koin.android)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**No version literal guard** (`tools/verify-no-version-literals.sh` lines 1-20):
|
||||
```bash
|
||||
VIOLATIONS=$(grep -rn -E 'version[[:space:]]*=[[:space:]]*"[0-9]' --include='*.gradle.kts' . 2>/dev/null \
|
||||
| grep -v 'build-logic/build.gradle.kts' \
|
||||
| grep -vE ':[0-9]+:version[[:space:]]*=[[:space:]]*"[0-9]' \
|
||||
|| true)
|
||||
```
|
||||
|
||||
**Planner note:** Put new dependency versions in `gradle/libs.versions.toml`; do not add library versions directly in `*.gradle.kts` except for explicitly justified test-only literals already called out by the plan.
|
||||
|
||||
### Client Koin + Logging + Auth Module
|
||||
|
||||
Applies to:
|
||||
- `composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/auth/AuthModule.kt`
|
||||
- `composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/di/AppModule.kt`
|
||||
- Auth singleton wiring in `AuthSession.kt`
|
||||
|
||||
**Analog:** `composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/di/AppModule.kt`, `Koin.kt`, `Logging.kt`, platform bootstraps
|
||||
|
||||
**Koin module placeholder pattern** (`AppModule.kt` lines 1-9):
|
||||
```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
|
||||
}
|
||||
```
|
||||
|
||||
**Koin startup pattern** (`Koin.kt` lines 1-10):
|
||||
```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)
|
||||
}
|
||||
```
|
||||
|
||||
**Client logging bootstrap** (`Logging.kt` lines 1-8):
|
||||
```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.
|
||||
}
|
||||
```
|
||||
|
||||
**Platform init order** (`MainApplication.kt` lines 8-15, `KoinIos.kt` lines 5-8):
|
||||
```kotlin
|
||||
class MainApplication : Application() {
|
||||
override fun onCreate() {
|
||||
super.onCreate()
|
||||
configureLogging()
|
||||
initKoin {
|
||||
androidContext(this@MainApplication)
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
```kotlin
|
||||
fun doInitKoin() {
|
||||
configureLogging()
|
||||
initKoin()
|
||||
}
|
||||
```
|
||||
|
||||
**Planner note:** Add `authModule` without starting Koin from composables. Wire modules from the existing `initKoin` path, preserving the one-start-per-platform rule.
|
||||
|
||||
### Compose App Shell + Auth Screens
|
||||
|
||||
Applies to:
|
||||
- `composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/App.kt`
|
||||
- `composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/theme/RecipeTheme.kt`
|
||||
- `SplashScreen.kt`
|
||||
- `LoginScreen.kt`
|
||||
- `LoginViewModel.kt`
|
||||
- `PostLoginPlaceholderScreen.kt`
|
||||
- `PostLoginViewModel.kt`
|
||||
- `composeApp/src/commonMain/composeResources/values/strings.xml`
|
||||
|
||||
**Analog:** `composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/App.kt`, `composeResources/drawable/compose-multiplatform.xml`
|
||||
|
||||
**Current app wrapper to preserve/replace** (`App.kt` lines 25-52):
|
||||
```kotlin
|
||||
@Composable
|
||||
@Preview
|
||||
fun App() {
|
||||
MaterialTheme {
|
||||
var showContent by remember { mutableStateOf(false) }
|
||||
Column(
|
||||
modifier =
|
||||
Modifier
|
||||
.background(MaterialTheme.colorScheme.primaryContainer)
|
||||
.safeContentPadding()
|
||||
.fillMaxSize(),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
) {
|
||||
Button(onClick = { showContent = !showContent }) {
|
||||
Text("Click me!")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Resource import pattern** (`App.kt` lines 21-23):
|
||||
```kotlin
|
||||
import org.jetbrains.compose.resources.painterResource
|
||||
import recipe.composeapp.generated.resources.Res
|
||||
import recipe.composeapp.generated.resources.compose_multiplatform
|
||||
```
|
||||
|
||||
**Compose resource file placement analog** (`composeResources/drawable/compose-multiplatform.xml` lines 1-7):
|
||||
```xml
|
||||
<vector
|
||||
xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:aapt="http://schemas.android.com/aapt"
|
||||
android:width="450dp"
|
||||
android:height="450dp"
|
||||
android:viewportWidth="64"
|
||||
android:viewportHeight="64">
|
||||
```
|
||||
|
||||
**UI contract to copy from `02-UI-SPEC.md` lines 149-161:**
|
||||
```text
|
||||
AuthState.Loading -> SplashScreen()
|
||||
AuthState.Unauthenticated -> LoginScreen(viewModel = koinViewModel())
|
||||
AuthState.Authenticated(user, householdId) -> PostLoginPlaceholderScreen(user, viewModel = koinViewModel())
|
||||
```
|
||||
|
||||
**Layout contract to copy from `02-UI-SPEC.md` lines 185-197:**
|
||||
```kotlin
|
||||
Modifier.fillMaxSize()
|
||||
.background(MaterialTheme.colorScheme.surface)
|
||||
.safeContentPadding()
|
||||
.padding(horizontal = 16.dp)
|
||||
Column(
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.Center,
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
)
|
||||
```
|
||||
|
||||
**Planner note:** Existing `App.kt` is template code. Keep the `@Composable`, `@Preview`, `MaterialTheme` shape, but replace the button/greeting body with the auth gate. All strings come from Compose Resources, not raw Polish literals.
|
||||
|
||||
### Server Application, Config, Flyway, and Routes
|
||||
|
||||
Applies to:
|
||||
- `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/V1__users.sql`
|
||||
- `server/src/main/kotlin/dev/ulfrx/recipe/auth/MeRoute.kt`
|
||||
- `server/src/main/kotlin/dev/ulfrx/recipe/auth/AuthConfig.kt`
|
||||
|
||||
**Analog:** existing server files
|
||||
|
||||
**Application install and routing pattern** (`Application.kt` lines 24-38):
|
||||
```kotlin
|
||||
fun Application.module() {
|
||||
install(ContentNegotiation) {
|
||||
json()
|
||||
}
|
||||
Database.migrate(this)
|
||||
configureRouting()
|
||||
}
|
||||
|
||||
fun Application.configureRouting() {
|
||||
routing {
|
||||
get("/health") {
|
||||
call.respond(Health(status = "ok"))
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**HOCON env override pattern** (`application.conf` lines 1-18):
|
||||
```hocon
|
||||
ktor {
|
||||
deployment {
|
||||
port = 8080
|
||||
port = ${?PORT}
|
||||
}
|
||||
}
|
||||
|
||||
database {
|
||||
url = "jdbc:postgresql://localhost:5432/recipe"
|
||||
url = ${?DATABASE_URL}
|
||||
user = "recipe"
|
||||
user = ${?DATABASE_USER}
|
||||
password = "recipe"
|
||||
password = ${?DATABASE_PASSWORD}
|
||||
}
|
||||
```
|
||||
|
||||
**Flyway runtime pattern** (`Database.kt` lines 10-39):
|
||||
```kotlin
|
||||
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", ex)
|
||||
throw IllegalStateException("Database unreachable or migration failed", ex)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Planner note:** Insert auth install in `Application.module()` after `ContentNegotiation`/`CallLogging` and before protected routing. Keep `configureRouting()` testable without invoking real Postgres where possible.
|
||||
|
||||
### Server JWT Auth + Principal Resolver
|
||||
|
||||
Applies to:
|
||||
- `server/src/main/kotlin/dev/ulfrx/recipe/auth/AuthPlugin.kt`
|
||||
- `server/src/main/kotlin/dev/ulfrx/recipe/auth/PrincipalResolver.kt`
|
||||
- `server/src/main/kotlin/dev/ulfrx/recipe/auth/UsersTable.kt`
|
||||
|
||||
**Analog:** `Application.kt` plugin install, `Database.kt` DB boundary. No existing JWT or Exposed analog exists yet.
|
||||
|
||||
**Plugin install style to copy** (`Application.kt` lines 24-27):
|
||||
```kotlin
|
||||
install(ContentNegotiation) {
|
||||
json()
|
||||
}
|
||||
```
|
||||
|
||||
**DB config/logging boundary to copy** (`Database.kt` lines 7-9, 24-39):
|
||||
```kotlin
|
||||
object Database {
|
||||
private val log = LoggerFactory.getLogger(Database::class.java)
|
||||
|
||||
fun migrate(app: Application) {
|
||||
log.info("Connecting to {} as {} and running Flyway migrations", url, user)
|
||||
runCatching {
|
||||
Flyway.configure().dataSource(url, user, password).load().migrate()
|
||||
}.onFailure { ex ->
|
||||
log.error("Flyway migration failed", ex)
|
||||
throw IllegalStateException("Database unreachable or migration failed", ex)
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Required auth shape from `02-CONTEXT.md` lines 62-64:**
|
||||
```kotlin
|
||||
install(Authentication) {
|
||||
jwt("authentik") {
|
||||
// verifier(jwkProvider, issuer), withIssuer, withAudience, acceptLeeway(30)
|
||||
// validate block must reject null or blank sub
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Required JIT upsert from `02-CONTEXT.md` lines 81-91:**
|
||||
```sql
|
||||
INSERT INTO users (sub, email, display_name)
|
||||
VALUES (:sub, :email, :name)
|
||||
ON CONFLICT (sub) DO UPDATE
|
||||
SET email = EXCLUDED.email,
|
||||
display_name = EXCLUDED.display_name,
|
||||
updated_at = now()
|
||||
RETURNING *;
|
||||
```
|
||||
|
||||
**Planner note:** Use Exposed DSL only and suspend transaction APIs for request-handling DB work. There is no local Exposed analog yet; the plan must treat `PrincipalResolver` as the first canonical server CRUD service.
|
||||
|
||||
### Server Tests
|
||||
|
||||
Applies to:
|
||||
- `server/src/test/kotlin/dev/ulfrx/recipe/auth/JwtAuthTest.kt`
|
||||
- `server/src/test/kotlin/dev/ulfrx/recipe/auth/JwtTestSupport.kt`
|
||||
- `server/src/test/kotlin/dev/ulfrx/recipe/auth/MeRouteTest.kt` if added
|
||||
|
||||
**Analog:** `server/src/test/kotlin/dev/ulfrx/recipe/ApplicationTest.kt`
|
||||
|
||||
**Ktor test pattern** (`ApplicationTest.kt` lines 14-29):
|
||||
```kotlin
|
||||
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")
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Planner note:** Compose test modules directly with `testApplication { application { ... } }`. Avoid calling `Application.module()` in tests that should not require a real Postgres unless the test sets up an in-memory DB first.
|
||||
|
||||
### OIDC Platform Bootstrap
|
||||
|
||||
Applies to:
|
||||
- `OidcClient.kt`
|
||||
- `OidcResult.kt`
|
||||
- platform `OidcClient.*.kt`
|
||||
- `composeApp/src/androidMain/AndroidManifest.xml`
|
||||
- `iosApp/iosApp/Info.plist`
|
||||
- `iosApp/iosApp/iOSApp.swift`
|
||||
- `iosApp/Podfile`
|
||||
|
||||
**Analog:** platform entry points and manifests
|
||||
|
||||
**Android activity pattern** (`MainActivity.kt` lines 10-19):
|
||||
```kotlin
|
||||
class MainActivity : ComponentActivity() {
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
enableEdgeToEdge()
|
||||
super.onCreate(savedInstanceState)
|
||||
|
||||
setContent {
|
||||
App()
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Android manifest application/activity pattern** (`AndroidManifest.xml` lines 1-23):
|
||||
```xml
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<application
|
||||
android:name=".MainApplication"
|
||||
android:label="@string/app_name"
|
||||
android:theme="@android:style/Theme.Material.Light.NoActionBar">
|
||||
<activity
|
||||
android:exported="true"
|
||||
android:name=".MainActivity">
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.MAIN"/>
|
||||
<category android:name="android.intent.category.LAUNCHER"/>
|
||||
</intent-filter>
|
||||
</activity>
|
||||
</application>
|
||||
</manifest>
|
||||
```
|
||||
|
||||
**iOS SwiftUI bootstrap pattern** (`iOSApp.swift` lines 1-15):
|
||||
```swift
|
||||
import SwiftUI
|
||||
import ComposeApp
|
||||
|
||||
@main
|
||||
struct iOSApp: App {
|
||||
init() {
|
||||
KoinIosKt.doInitKoin()
|
||||
}
|
||||
|
||||
var body: some Scene {
|
||||
WindowGroup {
|
||||
ContentView()
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**iOS plist pattern** (`Info.plist` lines 1-8):
|
||||
```xml
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>CADisableMinimumFrameDurationOnPhone</key>
|
||||
<true/>
|
||||
</dict>
|
||||
</plist>
|
||||
```
|
||||
|
||||
**Target-specific main patterns** (`jvmMain/main.kt` lines 8-18, `webMain/main.kt` lines 8-14):
|
||||
```kotlin
|
||||
fun main() {
|
||||
configureLogging()
|
||||
initKoin()
|
||||
application {
|
||||
Window(
|
||||
onCloseRequest = ::exitApplication,
|
||||
title = "recipe",
|
||||
) {
|
||||
App()
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
```kotlin
|
||||
@OptIn(ExperimentalComposeUiApi::class)
|
||||
fun main() {
|
||||
configureLogging()
|
||||
initKoin()
|
||||
ComposeViewport {
|
||||
App()
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Planner note:** Preserve existing Koin bootstrap while adding AppAuth callback wiring. For `recipe://callback`, register Android AppAuth receiver and iOS `CFBundleURLTypes`; do not replace unrelated app metadata.
|
||||
|
||||
### Client AuthSession, Token Store, HTTP Client, and Common Tests
|
||||
|
||||
Applies to:
|
||||
- `AuthState.kt`
|
||||
- `AuthSession.kt`
|
||||
- `TokenStore.kt`
|
||||
- `AuthHttpClient.kt`
|
||||
- `MeClient.kt`
|
||||
- `SettingsFactory.*.kt`
|
||||
- `HttpClientEngine.*.kt`
|
||||
- `AuthSessionTest.kt`
|
||||
|
||||
**Analog:** Koin module pattern, platform main files, `ComposeAppCommonTest.kt`. There is no existing Ktor client or settings storage analog.
|
||||
|
||||
**Common test skeleton** (`ComposeAppCommonTest.kt` lines 1-10):
|
||||
```kotlin
|
||||
package dev.ulfrx.recipe
|
||||
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
|
||||
class ComposeAppCommonTest {
|
||||
@Test
|
||||
fun example() {
|
||||
assertEquals(3, 1 + 2)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Koin module registration analog** (`AppModule.kt` lines 5-9):
|
||||
```kotlin
|
||||
// Phase 2 adds authModule; Phase 4 adds syncModule; Phase 5 adds catalogModule; etc.
|
||||
val appModule =
|
||||
module {
|
||||
// intentionally empty in Phase 1
|
||||
}
|
||||
```
|
||||
|
||||
**Required Ktor client bearer shape from `02-RESEARCH.md` lines 313-329:**
|
||||
```kotlin
|
||||
install(Auth) {
|
||||
bearer {
|
||||
loadTokens {
|
||||
authSession.currentBearerTokens()
|
||||
}
|
||||
refreshTokens {
|
||||
authSession.refreshBearerTokens()
|
||||
}
|
||||
sendWithoutRequest { request ->
|
||||
request.url.host == apiHost
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Required auth state shape from `02-CONTEXT.md` lines 97-107:**
|
||||
```kotlin
|
||||
sealed class AuthState {
|
||||
data object Loading : AuthState()
|
||||
data object Unauthenticated : AuthState()
|
||||
data class Authenticated(
|
||||
val user: User,
|
||||
val householdId: HouseholdId? = null,
|
||||
) : AuthState()
|
||||
}
|
||||
```
|
||||
|
||||
**Planner note:** This group becomes the client auth canonical pattern for later phases. Keep collaborators injectable behind small interfaces so common tests can fake OIDC and `/me` without platform AppAuth.
|
||||
|
||||
## Shared Patterns
|
||||
|
||||
### Shared Module Purity
|
||||
|
||||
**Source:** `tools/verify-shared-pure.sh` lines 1-15
|
||||
**Apply to:** all files under `shared/src/commonMain`
|
||||
|
||||
```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.
|
||||
VIOLATIONS=$(grep -rn -E '^import[[:space:]]+(io\.ktor|androidx\.compose|org\.jetbrains\.compose|app\.cash\.sqldelight)' shared/src/commonMain/ 2>/dev/null || true)
|
||||
```
|
||||
|
||||
### Server Startup Order
|
||||
|
||||
**Source:** `server/src/main/kotlin/dev/ulfrx/recipe/Application.kt` lines 24-30
|
||||
**Apply to:** `Application.kt`, auth plugin install, route wiring
|
||||
|
||||
```kotlin
|
||||
fun Application.module() {
|
||||
install(ContentNegotiation) {
|
||||
json()
|
||||
}
|
||||
Database.migrate(this)
|
||||
configureRouting()
|
||||
}
|
||||
```
|
||||
|
||||
Phase 2 should extend this to:
|
||||
```text
|
||||
ContentNegotiation -> CallLogging redaction -> Database.migrate -> Database.connect -> configureAuth -> configureRouting
|
||||
```
|
||||
|
||||
### Server Logging
|
||||
|
||||
**Source:** `server/src/main/kotlin/dev/ulfrx/recipe/Database.kt` lines 7-8, 24, 36-39
|
||||
**Apply to:** server DB/auth services
|
||||
|
||||
```kotlin
|
||||
object Database {
|
||||
private val log = LoggerFactory.getLogger(Database::class.java)
|
||||
|
||||
log.info("Connecting to {} as {} and running Flyway migrations", url, user)
|
||||
log.error("Flyway migration failed", ex)
|
||||
throw IllegalStateException("Database unreachable or migration failed", ex)
|
||||
}
|
||||
```
|
||||
|
||||
### Client Logging
|
||||
|
||||
**Source:** `composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/logging/Logging.kt` lines 1-8
|
||||
**Apply to:** `AuthSession`, OIDC client wrappers
|
||||
|
||||
```kotlin
|
||||
import co.touchlab.kermit.Logger
|
||||
|
||||
fun configureLogging() {
|
||||
Logger.setTag("recipe")
|
||||
// Platform log writers (OSLog iOS, LogCat Android, System.out JVM/Wasm) install by default.
|
||||
}
|
||||
```
|
||||
|
||||
Use `Logger.withTag("auth")` for auth flow diagnostics, but never log token bodies or `Authorization` headers.
|
||||
|
||||
### Koin Startup
|
||||
|
||||
**Source:** `composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/di/Koin.kt` lines 7-10 and platform callers
|
||||
**Apply to:** `authModule`, ViewModels, OIDC clients, settings factories
|
||||
|
||||
```kotlin
|
||||
fun initKoin(config: KoinAppDeclaration? = null): KoinApplication =
|
||||
startKoin {
|
||||
config?.invoke(this)
|
||||
modules(appModule)
|
||||
}
|
||||
```
|
||||
|
||||
### HOCON Env Vars
|
||||
|
||||
**Source:** `server/src/main/resources/application.conf` lines 11-18
|
||||
**Apply to:** `oidc.issuer`, `oidc.audience`, `oidc.jwksUrl`, `oidc.leewaySeconds`
|
||||
|
||||
```hocon
|
||||
database {
|
||||
url = "jdbc:postgresql://localhost:5432/recipe"
|
||||
url = ${?DATABASE_URL}
|
||||
user = "recipe"
|
||||
user = ${?DATABASE_USER}
|
||||
password = "recipe"
|
||||
password = ${?DATABASE_PASSWORD}
|
||||
}
|
||||
```
|
||||
|
||||
### Ktor Route Tests
|
||||
|
||||
**Source:** `server/src/test/kotlin/dev/ulfrx/recipe/ApplicationTest.kt` lines 17-29
|
||||
**Apply to:** `JwtAuthTest`, `MeRouteTest`
|
||||
|
||||
```kotlin
|
||||
testApplication {
|
||||
application {
|
||||
install(ContentNegotiation) {
|
||||
json()
|
||||
}
|
||||
configureRouting()
|
||||
}
|
||||
val response = client.get("/health")
|
||||
assertEquals(HttpStatusCode.OK, response.status)
|
||||
}
|
||||
```
|
||||
|
||||
## No Analog Found
|
||||
|
||||
| File | Role | Data Flow | Reason |
|
||||
|------|------|-----------|--------|
|
||||
| `server/src/main/kotlin/dev/ulfrx/recipe/auth/UsersTable.kt` | model | CRUD | No Exposed table exists yet. Phase 2 establishes first server table DSL pattern. |
|
||||
| `server/src/main/kotlin/dev/ulfrx/recipe/auth/AuthPlugin.kt` JWT details | middleware | request-response | Ktor server exists, but no Authentication/JWT plugin exists yet. Use `02-CONTEXT.md` D-21/D-22 and Ktor docs from research. |
|
||||
| `composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/auth/AuthHttpClient.kt` | service | request-response | No Ktor client code exists yet. Use `02-RESEARCH.md` Ktor bearer shape. |
|
||||
| `composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/auth/TokenStore.kt` | service | file-I/O | No multiplatform-settings usage exists yet. Keep explicit platform store seam. |
|
||||
| `composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/screens/auth/LoginViewModel.kt` | controller | event-driven | Koin ViewModel deps exist, but no ViewModel classes exist yet. Use method-per-action `StateFlow` convention from project docs/UI spec. |
|
||||
| `composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/screens/auth/PostLoginViewModel.kt` | controller | event-driven | Same as LoginViewModel. |
|
||||
| `iosApp/Podfile` | config | build-config | No existing Podfile. Follow Plan 03 CocoaPods DSL and iOS deployment target from `composeApp/build.gradle.kts`. |
|
||||
|
||||
## Metadata
|
||||
|
||||
**Analog search scope:** `composeApp/`, `server/`, `shared/`, `iosApp/`, `build-logic/`, `gradle/`, `tools/`, Phase 1 summaries and Phase 2 plan drafts.
|
||||
**Files scanned:** 75+ code/config/planning files via `rg --files`, `find`, and targeted `nl -ba` reads.
|
||||
**Pattern extraction date:** 2026-04-27
|
||||
|
||||
@@ -367,22 +367,22 @@ Source: Phase 2 context. [VERIFIED: `.planning/phases/02-authentication-foundati
|
||||
|---|-------|---------|---------------|
|
||||
| A1 | `com.auth0:jwks-rsa` may need an explicit alias if Ktor's auth JWT dependency does not expose the builder cleanly. | Standard Stack | Minor Gradle dependency task may be missing. |
|
||||
|
||||
## Open Questions
|
||||
## Open Questions (RESOLVED)
|
||||
|
||||
1. **Android secure token storage final choice**
|
||||
1. **RESOLVED — Android secure token storage final choice**
|
||||
- What we know: no-arg/default multiplatform-settings is not encrypted on Android; AndroidX Security Crypto encrypts preferences but is deprecated. [CITED: https://github.com/russhwolf/multiplatform-settings] [CITED: https://developer.android.com/jetpack/androidx/releases/security]
|
||||
- What's unclear: whether the user accepts deprecated `EncryptedSharedPreferences` for v1 or wants direct Android Keystore-backed storage. [VERIFIED: docs comparison]
|
||||
- Recommendation: planner should add Wave 0 decision task; default to direct platform-specific `SecureAuthStateStore` with an implementation that can be swapped without touching `AuthSession`. [VERIFIED: docs comparison]
|
||||
- Decision from PLAN.md: Phase 2 uses AndroidX Security Crypto `EncryptedSharedPreferences` behind an explicit `SecureAuthStateStore.android.kt` implementation because the project requirement/context explicitly calls out EncryptedSharedPreferences for Android token storage. The deprecation risk is contained behind the `SecureAuthStateStore` seam so a future Android Keystore-backed implementation can replace it without touching `AuthSession`.
|
||||
- Guardrail: auth code must not use no-arg `Settings()` or ordinary `SharedPreferences` for tokens; Plan 03 includes grep-verifiable acceptance criteria for this.
|
||||
|
||||
2. **Exposed version and suspend transaction import**
|
||||
2. **RESOLVED — Exposed version and suspend transaction import**
|
||||
- What we know: current Exposed docs use `suspendTransaction`; project context says `newSuspendedTransaction`. [CITED: https://www.jetbrains.com/help/exposed/transactions.html] [VERIFIED: `.planning/phases/02-authentication-foundation/02-CONTEXT.md`]
|
||||
- What's unclear: which Exposed version will be pinned in `libs.versions.toml`.
|
||||
- Recommendation: pin Exposed first, then write imports from that version's docs/source. [VERIFIED: docs comparison]
|
||||
- Decision from PLAN.md: Plan 02 verifies the exact suspend transaction API/import against the pinned Exposed dependency before implementing DB code. Expected for the current catalog path is `org.jetbrains.exposed.sql.transactions.experimental.newSuspendedTransaction`; if the pinned version requires `suspendTransaction`, execution must use that exact import and record the choice in `02-02-SUMMARY.md`.
|
||||
- Guardrail: no blocking `transaction {}` inside suspend route code.
|
||||
|
||||
3. **Ktor patch bump**
|
||||
3. **RESOLVED — Ktor patch bump**
|
||||
- What we know: repo pins 3.4.1 and current docs/release search show 3.4.3. [VERIFIED: `gradle/libs.versions.toml`] [VERIFIED: Maven search]
|
||||
- What's unclear: whether Phase 2 should include a dependency patch bump.
|
||||
- Recommendation: if bumping, bump the single `ktor` version ref once and run full `./gradlew build`; otherwise add auth artifacts at 3.4.1 for catalog consistency. [VERIFIED: project version catalog]
|
||||
- Decision from PLAN.md: Phase 2 keeps Ktor pinned to the existing catalog version (`3.4.1`) and adds auth artifacts against that same version ref. A patch bump is deferred unless implementation reveals a concrete incompatibility.
|
||||
- Guardrail: do not opportunistically bump Ktor during auth implementation without an explicit failing command or compatibility reason.
|
||||
|
||||
## Environment Availability
|
||||
|
||||
|
||||
@@ -84,11 +84,11 @@ created: 2026-04-27
|
||||
|
||||
## Validation Sign-Off
|
||||
|
||||
- [ ] All tasks have `<automated>` verify or Wave 0 dependencies
|
||||
- [ ] Sampling continuity: no 3 consecutive tasks without automated verify
|
||||
- [ ] Wave 0 covers all MISSING references
|
||||
- [ ] No watch-mode flags
|
||||
- [ ] Feedback latency < 120s for quick checks
|
||||
- [x] All tasks have `<automated>` verify or Wave 0 dependencies represented in Phase 2 plans
|
||||
- [x] Sampling continuity: no 3 consecutive tasks without automated verify
|
||||
- [x] Wave 0 missing references are mapped into Phase 2 plan tasks
|
||||
- [x] No watch-mode flags
|
||||
- [x] Feedback latency target < 120s for quick checks is documented
|
||||
- [ ] `nyquist_compliant: true` set in frontmatter
|
||||
|
||||
**Approval:** pending
|
||||
**Approval:** plan-ready 2026-04-27; execution must keep `nyquist_compliant: false` and `wave_0_complete: false` until Wave 0 tests/manual-UAT artifacts actually exist.
|
||||
|
||||
Reference in New Issue
Block a user