Prepare project plan

This commit is contained in:
2026-04-24 11:47:24 +02:00
parent 0c87978547
commit 4b838cfb99
13 changed files with 1815 additions and 7 deletions

View File

@@ -21,12 +21,12 @@ A mobile-first meal planning app for a small household — pick recipes for the
- [ ] Sessions persist across app launches; offline access works with cached credentials
**Household sharing**
- [ ] Two users (me + partner) share one household: one plan, one pantry, one shopping list
- [ ] Users can join a household: one plan, one pantry, one shopping list
- [ ] Changes by either user converge on all devices when online
**Recipes (browse & detail)**
- [ ] User can browse a curated recipe catalog with a grid view
- [ ] User can filter recipes by meal slot, tags, and cooking time
- [ ] User can filter recipes by meal slot, tags, cooking time, and ingredients
- [ ] User can search recipes by title/tag
- [ ] User can open a recipe detail view with ingredients, steps, and nutrition per serving
@@ -73,13 +73,13 @@ A mobile-first meal planning app for a small household — pick recipes for the
- True native iOS 26 Liquid Glass via SwiftUI interop — *Compose approximation for v1; revisit only if real-device chrome feels clearly inadequate*
- Desktop and Wasm as shipped products — *Desktop useful for hot-reload dev; Wasm is a possible future target, neither is a v1 deliverable*
- Sign in with Apple as a first-class button — *user's Authentik handles auth; Apple can be federated upstream in Authentik if needed later*
- Barcode scanning / receipt OCR for pantry updates — *manual entry is fine for a 2-person household*
- AI-generated recipes — *curated catalog is the value*
**Permanently out of scope (explicit exclusions):**
- Social features: comments, ratings, recipe feeds, public profiles — *this is a private household app, not a community product*
- Meal-plan marketplaces / paid plans — *personal-use product*
- Grocery delivery integrations (Instacart, etc.) — *Polish market + small scope; not worth the integration cost*
- Barcode scanning / receipt OCR for pantry updates — *manual entry is fine for a 2-person household*
- AI-generated recipes — *curated catalog is the value*
**Deliberately not carried forward from the mockup:**
- The mockup's seed data (~80 ingredients, ~30 recipes) — *user chose to start the catalog fresh*
@@ -113,6 +113,8 @@ A mobile-first meal planning app for a small household — pick recipes for the
## Key Decisions
### Product & scope
| Decision | Rationale | Outcome |
|----------|-----------|---------|
| KMP with Compose Multiplatform for UI | iOS-primary + Android secondary + Desktop/Wasm optional; single codebase for 90%+ of UI | — Pending |
@@ -126,6 +128,52 @@ A mobile-first meal planning app for a small household — pick recipes for the
| Nutrition is informational only in v1 | Keep scope tight; tracking/goals are a natural v2 if usage patterns justify | — Pending |
| Mockup is functional spec only, not visual spec | Visual direction is changing (Liquid Glass); logic is mature and worth mining | — Pending |
### Client tech stack (composeApp)
| Decision | Rationale | Outcome |
|----------|-----------|---------|
| Navigation: Jetpack Navigation Compose (CMP port) — `org.jetbrains.androidx.navigation:navigation-compose:2.9.x` | JetBrains-official recommendation on kotlinlang.org; type-safe routes via `@Serializable`; works across Android/iOS/Desktop/Wasm; skill transferable to Android | — Pending |
| Architecture: ViewModel + StateFlow + method-per-action | Standard modern pattern; matches JetBrains/Google samples; lowest ceremony; upgrade individual screens to sealed-event onEvent only when they grow complex | — Pending |
| DI: Koin — `koin-core`, `koin-compose`, `koin-compose-viewmodel` | De facto KMP standard; smoothest `koinViewModel()` integration with Jetpack Nav back-stack scoping; no codegen; small surface to learn | — Pending |
| Local DB: SQLDelight 2.x | Most mature KMP DB; Wasm-ready (hedge for future Compose-for-Web target); raw SQL is a transferable skill; clear migration story via .sq files | — Pending |
| HTTP client: Ktor Client | Same team as server; first-class KMP; shared serialization config | — Pending |
| Serialization: kotlinx.serialization (JSON) | Standard; works everywhere; pairs with Ktor and SQLDelight | — Pending |
| Date/time: kotlinx.datetime | Standard; SQLDelight adapters available | — Pending |
| Logging: Kermit (Touchlab) | KMP-native logger; simple API; optional Crashlytics/Sentry bridges | — Pending |
| Image loading: Coil 3 (`io.coil-kt.coil3:coil-compose`) | First-class Compose Multiplatform support; modern API | — Pending |
| Key-value settings: `com.russhwolf:multiplatform-settings` | Small prefs (last tab, theme toggles) that don't belong in SQLDelight | — Pending |
| Glass/blur effects: Haze (`dev.chrisbanes.haze:haze`) | Purpose-built for glass UI in CMP; handles content capture + efficient re-blur; multiplatform | — Pending |
| Mobile OIDC: AppAuth (Android) + ASWebAuthenticationSession wrapper (iOS), exposed via KMP interface | Platform-native OAuth flows; no cross-platform auth library mature enough yet for this in 2026 | — Pending |
### Server tech stack
| Decision | Rationale | Outcome |
|----------|-----------|---------|
| Server framework: Ktor Server 3.x | Same team as client HTTP; Kotlin-native; fits homelab deployment; coroutines throughout | — Pending |
| Database: Postgres | Homelab-friendly (Docker); JSONB for meal-entry extras; room to grow; standard skill | — Pending |
| SQL: Exposed DSL | Kotlin-backend standard; type-safe SQL builders; JSONB first-class; strong tutorial trail with Ktor. Avoid Exposed's DAO (active record) API | — Pending |
| Migrations: Flyway | Industry-standard numbered `V__.sql` files; auto-apply on startup; works with any JDBC-backed stack | — Pending |
| Token validation: `io.ktor:ktor-server-auth-jwt` | Built-in JWKS support with caching + rotation; direct integration with Authentik's OIDC endpoint | — Pending |
### Sync
| Decision | Rationale | Outcome |
|----------|-----------|---------|
| Strategy: last-write-wins with server-assigned `updated_at` per row | Household of 2 has negligible concurrent-edit risk; simplest to implement and debug; upgradeable per-table to op-log later if hurt | — Pending |
| Transport: HTTP polling (2030s when foreground) + pull-to-refresh + debounced push after local writes | Sufficient freshness for a household; SSE is a v2 enhancement if polling feels laggy | — Pending |
| API shape: REST, versioned `/api/v1`, two sync endpoints (`POST /sync/push`, `GET /sync/pull?since=...`) plus catalog + households/invites CRUD | Versioning leaves room to evolve; minimal surface area; read-mostly catalog is heavily cached on client | — Pending |
| Server-side data model: `users`, `households`, `memberships`, `invites` + household-scoped tables carrying `household_id`, `updated_at`, `deleted_at` | Supports household sharing + invites, JIT user provisioning from OIDC `sub`, soft deletes for sync | — Pending |
### Build & module structure
| Decision | Rationale | Outcome |
|----------|-----------|---------|
| Gradle version catalog (`gradle/libs.versions.toml`) | Single source of truth for versions; standard KMP practice | — Pending |
| Convention plugins (`build-logic/` module) from day 1 | Centralizes Kotlin/Compose/test config; educational payoff; small upfront cost | — Pending |
| Keep template modules: `composeApp/`, `iosApp/`, `server/`, `shared/` — no feature modules in v1 | Feature modules don't pay off until ~10 features or multiple devs; flat is clearer at this scale | — Pending |
| `shared/commonMain` holds: domain models + API DTOs only (no UI, no HTTP, no DB) | Keeps shared dep graph minimal; both client and server depend on `shared/` | — Pending |
| `composeApp/commonMain` package layout: `app/ navigation/ ui/{theme,components,screens/{recipes,planner,pantry,shopping}} data/{local,remote,repository} domain/` | Groups by UI concern + data layer; resists premature modularization | — Pending |
## Evolution
This document evolves at phase transitions and milestone boundaries.
@@ -144,4 +192,4 @@ This document evolves at phase transitions and milestone boundaries.
4. Update Context with current state
---
*Last updated: 2026-04-23 after initialization*
*Last updated: 2026-04-24 after initial tech-stack discussion*

242
.planning/REQUIREMENTS.md Normal file
View File

@@ -0,0 +1,242 @@
# Requirements: Recipe
**Defined:** 2026-04-24
**Core Value:** "My week is planned." I pick recipes, the calendar fills up, and I know what we're eating.
## v1 Requirements
### Authentication & identity
- [ ] **AUTH-01**: User can sign in via the self-hosted Authentik instance using OIDC (authorization code flow with PKCE)
- [ ] **AUTH-02**: Client stores access + refresh tokens securely (iOS Keychain / Android EncryptedSharedPreferences)
- [ ] **AUTH-03**: Ktor server validates incoming access tokens via Authentik's JWKS endpoint (issuer, audience, expiry, signature, clock skew leeway)
- [ ] **AUTH-04**: User session persists across app launches without re-authentication (token refresh handled transparently)
- [ ] **AUTH-05**: User can sign out, which revokes local tokens and returns to the login screen
- [ ] **AUTH-06**: Users are JIT-provisioned in the server database on first successful login (by OIDC `sub` claim)
### Household sharing
- [ ] **HSHD-01**: On first login, user is prompted to create a new household or join an existing one via invite code
- [ ] **HSHD-02**: User can create a new household and become its first member
- [ ] **HSHD-03**: User can generate an invite code for their household (short-lived, single-use)
- [ ] **HSHD-04**: Another user can redeem an invite code to join the household
- [ ] **HSHD-05**: All household members see the same plan, pantry, and shopping list
- [ ] **HSHD-06**: Server-side tenancy is enforced: every household-scoped query filters by `household_id` derived from the authenticated principal, never from the request body
- [ ] **HSHD-07**: Client queries for household data filter locally by active `household_id` (defense in depth)
### Recipes — browse & detail
- [ ] **RCPE-01**: User can view a grid of the household's recipe catalog
- [ ] **RCPE-02**: User can filter recipes by meal slot (śniadanie / drugie śniadanie / obiad / przekąska / kolacja)
- [ ] **RCPE-03**: User can filter recipes by tag (e.g., "szybkie", "wegetariańskie", "wysokobiałkowe")
- [ ] **RCPE-04**: User can filter recipes by cooking time range (minutes)
- [ ] **RCPE-05**: User can search recipes by title or tag text
- [ ] **RCPE-06**: User can open a recipe detail view showing ingredients (with amounts + units), steps, nutrition per serving, and cooking time
- [ ] **RCPE-07**: Recipe detail shows ingredient alternatives (substitutions) where defined in the catalog
- [ ] **RCPE-08**: Recipe catalog is seeded via server-side SQL fixtures or admin CLI (no in-app authoring in v1)
### Meal planner (hero feature)
- [ ] **PLAN-01**: User can view a calendar showing planned meals per day
- [ ] **PLAN-02**: User can navigate between days/weeks/months in the calendar
- [ ] **PLAN-03**: User can add a recipe to any of the 5 slots on any day (śniadanie, drugie śniadanie, obiad, przekąska, kolacja)
- [ ] **PLAN-04**: User can remove a meal entry from a slot
- [ ] **PLAN-05**: User can replace a meal entry by picking a different recipe
- [ ] **PLAN-06**: User can adjust servings on a meal entry (112)
- [ ] **PLAN-07**: User can substitute an ingredient in a meal entry with one of the defined alternatives
- [ ] **PLAN-08**: User can exclude an ingredient from a meal entry (won't appear in shopping/pantry calculations)
- [ ] **PLAN-09**: User can add an extra ingredient to a meal entry (amount + unit from the ingredient catalog)
- [ ] **PLAN-10**: User can override the amount of an ingredient in a meal entry
- [ ] **PLAN-11**: User can select a specific product (pack size) for a given ingredient in a meal entry
- [ ] **PLAN-12**: User can mark a meal slot as skipped for a day
- [ ] **PLAN-13**: User sees daily nutrition totals (kcal, protein, fat, carbs) aggregated from all planned meals that day, respecting customizations
- [ ] **PLAN-14**: Meal entries have stable UUID identity (never composite keys like `date + slot`) to survive concurrent edits
### Pantry
- [ ] **PNTR-01**: User can view pantry inventory grouped by ingredient category (pieczywo, nabiał, mięso i ryby, warzywa, owoce, suche, przyprawy, inne)
- [ ] **PNTR-02**: User can manually add or update the quantity of an ingredient in the pantry (using its pantry unit: g, ml, szt.)
- [ ] **PNTR-03**: User sees which ingredients fall short over a user-selected planning horizon (e.g., next 7 days) based on the plan minus current pantry
- [ ] **PNTR-04**: User can filter pantry view by category
- [ ] **PNTR-05**: User can filter pantry view by shortfall status (needed / sufficient / not in plan)
### Shopping list
- [ ] **SHOP-01**: User can select a date range from the plan to generate a shopping list
- [ ] **SHOP-02**: Shopping list aggregates all ingredient needs across selected days minus current pantry quantities
- [ ] **SHOP-03**: Shopping list groups items by ingredient category for efficient in-store navigation
- [ ] **SHOP-04**: User can mark an item as bought during a shopping session; the item is removed from active needs and added to pantry in its pantry unit
- [ ] **SHOP-05**: User can undo a recently marked-bought item within the same session
- [ ] **SHOP-06**: Session log persists across app restarts until the user explicitly clears it
### Offline + sync
- [ ] **SYNC-01**: Client reads all household data from local SQLDelight without requiring network
- [ ] **SYNC-02**: Client writes all household data locally first (optimistic UI), then queues the change for sync
- [ ] **SYNC-03**: Server assigns `updated_at` (server time, monotonic) on every accepted write; clients never trust their own device clock for sync ordering
- [ ] **SYNC-04**: Client pulls household changes via `GET /sync/pull?since=...` using a lexicographic `(updated_at, id)` cursor to survive same-millisecond writes
- [ ] **SYNC-05**: Client pushes pending writes via `POST /sync/push` (batched); accepted writes come back with server-assigned `updated_at`
- [ ] **SYNC-06**: Deletes are soft deletes (`deleted_at` column); synced as state changes, not as "delete ops"
- [ ] **SYNC-07**: Sync runs on: app foreground, pull-to-refresh, debounced 2s after every local write, and polls every 2030s while foreground
- [ ] **SYNC-08**: Sync failures (network error, 5xx) retry with exponential backoff and do not block the UI
- [ ] **SYNC-09**: Sync engine is implemented as a single Koin singleton owning the outbox queue and pull cursor — features never issue HTTP writes directly
- [ ] **SYNC-10**: When two household members edit the same row, the server-assigned later write wins; no silent data loss because writes use UUID identity, not natural keys
### UI foundation (polish + glass aesthetic)
- [ ] **UI-01**: All user-facing strings are externalized as Compose resources (i18n-ready), even though v1 ships Polish only
- [ ] **UI-02**: App ships with Polish-language copy throughout
- [ ] **UI-03**: Bottom tab navigation with 4 tabs: Przepisy / Planer / Spiżarnia / Zakupy, each preserving its own back stack independently
- [ ] **UI-04**: Tab bar and nav bar use Haze-based glass/blur effects (Liquid Glass approximation)
- [ ] **UI-05**: App supports light and dark color schemes with translucent surfaces working in both
- [ ] **UI-06**: UI is iOS-idiomatic within Compose constraints (safe areas, swipe-back gesture where applicable, keyboard avoidance)
- [ ] **UI-07**: Visual hierarchy is less cramped than the mockup — deliberate spacing, calmer typography, readable at arm's length
- [ ] **UI-08**: Locale-aware date formatting for display (days, months, weekday names in Polish); sync wire-format stays UTC ISO-8601
- [ ] **UI-09**: App starts cleanly on first launch (no blank flash) and shows appropriate empty states when catalog / plan / pantry / shopping are empty
### Infrastructure & build
- [ ] **INFRA-01**: Gradle version catalog at `gradle/libs.versions.toml` is the single source of truth for library versions
- [ ] **INFRA-02**: `build-logic/` convention plugins centralize Kotlin/Compose/test configuration across modules
- [ ] **INFRA-03**: iOS Kotlin/Native binary options set from day 1: `kotlin.native.binary.objcDisposeOnMain=false`, `gc=cms`
- [ ] **INFRA-04**: Server Docker image builds and deploys to user's homelab alongside Authentik
- [ ] **INFRA-05**: Flyway migrations run automatically on server startup in a known order
- [ ] **INFRA-06**: `shared/commonMain` contains only domain models + API DTOs — no UI, no HTTP, no DB code
- [ ] **INFRA-07**: App is distributed to partner via TestFlight (iOS) for initial dogfooding
## v2 Requirements
Explicitly acknowledged but deferred. Not in the v1 roadmap.
### In-app recipe authoring
- **AUTHR-01**: User can create a new recipe with ingredients, steps, nutrition, allowed slots, tags
- **AUTHR-02**: User can edit an existing recipe
- **AUTHR-03**: User can archive/delete a recipe they created
### Nutrition goal tracking
- **NUTR-01**: User can set daily macro targets (kcal, protein, fat, carbs)
- **NUTR-02**: Planner shows deficit/surplus vs. targets per day
- **NUTR-03**: User can view weekly nutrition trends
### Additional platforms
- **PLAT-01**: Android app distributed to household members (APK or Play Store)
- **PLAT-02**: Web (Compose for Wasm) as a PWA replacement for the current mockup
- **PLAT-03**: English localization (full copy pass)
### Sync hardening
- **SYNC2-01**: Server-sent events (SSE) for near-realtime sync instead of polling
- **SYNC2-02**: Per-table upgrade path from LWW to operation-log sync if concurrent-edit data loss becomes observable
### iOS Liquid Glass fidelity
- **LG2-01**: Native SwiftUI interop for tab bar and nav bar (real iOS 26 Liquid Glass material) if Compose approximation proves inadequate on real hardware
## Out of Scope
Explicitly excluded. Documented to prevent scope creep.
| Feature | Reason |
|---------|--------|
| Social features (comments, ratings, public profiles, feeds) | Private household app, not a community product |
| Recipe sharing between households | Households are isolated in v1; recipe marketplace is not the point |
| Meal-plan marketplace / paid plans | Personal-use product |
| Grocery-delivery integrations (Instacart, Carrefour Online, etc.) | Polish-market + small scope; integration cost not justified |
| Barcode scanning / receipt OCR for pantry updates | Manual entry is fine for a 2-person household |
| AI-generated recipes | Curated catalog is the value |
| Apple Sign-in as a first-class button | Authentik OIDC is user's IdP, not a third-party social login |
| Port of mockup's vanilla-JS visual design | Visual rebuild around Liquid-Glass language; mockup is functional spec only |
| Port of mockup's ~80 ingredients + ~30 recipes as seed data | User explicitly chose to re-curate catalog fresh |
| Device-clock-based sync timestamps | Silent data loss under drift; server-assigned timestamps mandatory |
| True iOS 26 Liquid Glass native material in v1 | Requires SwiftUI interop; Compose approximation is v1 scope |
## Traceability
Populated during roadmap creation. Each v1 requirement maps to exactly one phase.
| Requirement | Phase | Status |
|-------------|-------|--------|
| AUTH-01 | Phase 2: Authentication Foundation | Pending |
| AUTH-02 | Phase 2: Authentication Foundation | Pending |
| AUTH-03 | Phase 2: Authentication Foundation | Pending |
| AUTH-04 | Phase 2: Authentication Foundation | Pending |
| AUTH-05 | Phase 2: Authentication Foundation | Pending |
| AUTH-06 | Phase 2: Authentication Foundation | Pending |
| HSHD-01 | Phase 3: Households, Membership & Server Data Foundation | Pending |
| HSHD-02 | Phase 3: Households, Membership & Server Data Foundation | Pending |
| HSHD-03 | Phase 3: Households, Membership & Server Data Foundation | Pending |
| HSHD-04 | Phase 3: Households, Membership & Server Data Foundation | Pending |
| HSHD-05 | Phase 3: Households, Membership & Server Data Foundation | Pending |
| HSHD-06 | Phase 3: Households, Membership & Server Data Foundation | Pending |
| HSHD-07 | Phase 3: Households, Membership & Server Data Foundation | Pending |
| RCPE-01 | Phase 5: Recipe Catalog (Read Path) | Pending |
| RCPE-02 | Phase 5: Recipe Catalog (Read Path) | Pending |
| RCPE-03 | Phase 5: Recipe Catalog (Read Path) | Pending |
| RCPE-04 | Phase 5: Recipe Catalog (Read Path) | Pending |
| RCPE-05 | Phase 5: Recipe Catalog (Read Path) | Pending |
| RCPE-06 | Phase 5: Recipe Catalog (Read Path) | Pending |
| RCPE-07 | Phase 5: Recipe Catalog (Read Path) | Pending |
| RCPE-08 | Phase 5: Recipe Catalog (Read Path) | Pending |
| PLAN-01 | Phase 6: Meal Planner — Core Write Path | Pending |
| PLAN-02 | Phase 6: Meal Planner — Core Write Path | Pending |
| PLAN-03 | Phase 6: Meal Planner — Core Write Path | Pending |
| PLAN-04 | Phase 6: Meal Planner — Core Write Path | Pending |
| PLAN-05 | Phase 6: Meal Planner — Core Write Path | Pending |
| PLAN-06 | Phase 6: Meal Planner — Core Write Path | Pending |
| PLAN-07 | Phase 7: Meal Planner — Customization & Nutrition | Pending |
| PLAN-08 | Phase 7: Meal Planner — Customization & Nutrition | Pending |
| PLAN-09 | Phase 7: Meal Planner — Customization & Nutrition | Pending |
| PLAN-10 | Phase 7: Meal Planner — Customization & Nutrition | Pending |
| PLAN-11 | Phase 7: Meal Planner — Customization & Nutrition | Pending |
| PLAN-12 | Phase 6: Meal Planner — Core Write Path | Pending |
| PLAN-13 | Phase 7: Meal Planner — Customization & Nutrition | Pending |
| PLAN-14 | Phase 6: Meal Planner — Core Write Path | Pending |
| PNTR-01 | Phase 8: Pantry | Pending |
| PNTR-02 | Phase 8: Pantry | Pending |
| PNTR-03 | Phase 8: Pantry | Pending |
| PNTR-04 | Phase 8: Pantry | Pending |
| PNTR-05 | Phase 8: Pantry | Pending |
| SHOP-01 | Phase 9: Shopping List & Session Log | Pending |
| SHOP-02 | Phase 9: Shopping List & Session Log | Pending |
| SHOP-03 | Phase 9: Shopping List & Session Log | Pending |
| SHOP-04 | Phase 9: Shopping List & Session Log | Pending |
| SHOP-05 | Phase 9: Shopping List & Session Log | Pending |
| SHOP-06 | Phase 9: Shopping List & Session Log | Pending |
| SYNC-01 | Phase 4: Sync Engine Skeleton | Pending |
| SYNC-02 | Phase 4: Sync Engine Skeleton | Pending |
| SYNC-03 | Phase 4: Sync Engine Skeleton | Pending |
| SYNC-04 | Phase 4: Sync Engine Skeleton | Pending |
| SYNC-05 | Phase 4: Sync Engine Skeleton | Pending |
| SYNC-06 | Phase 4: Sync Engine Skeleton | Pending |
| SYNC-07 | Phase 4: Sync Engine Skeleton | Pending |
| SYNC-08 | Phase 4: Sync Engine Skeleton | Pending |
| SYNC-09 | Phase 4: Sync Engine Skeleton | Pending |
| SYNC-10 | Phase 4: Sync Engine Skeleton | Pending |
| UI-01 | Phase 11: Localization & iOS Deployment | Pending |
| UI-02 | Phase 11: Localization & iOS Deployment | Pending |
| UI-03 | Phase 10: UI Chrome & Haze Liquid-Glass Polish | Pending |
| UI-04 | Phase 10: UI Chrome & Haze Liquid-Glass Polish | Pending |
| UI-05 | Phase 5: Recipe Catalog (Read Path) | Pending |
| UI-06 | Phase 10: UI Chrome & Haze Liquid-Glass Polish | Pending |
| UI-07 | Phase 10: UI Chrome & Haze Liquid-Glass Polish | Pending |
| UI-08 | Phase 5: Recipe Catalog (Read Path) | Pending |
| UI-09 | Phase 10: UI Chrome & Haze Liquid-Glass Polish | Pending |
| INFRA-01 | Phase 1: Project Infrastructure & Module Wiring | Pending |
| INFRA-02 | Phase 1: Project Infrastructure & Module Wiring | Pending |
| INFRA-03 | Phase 1: Project Infrastructure & Module Wiring | Pending |
| INFRA-04 | Phase 11: Localization & iOS Deployment | Pending |
| INFRA-05 | Phase 3: Households, Membership & Server Data Foundation | Pending |
| INFRA-06 | Phase 1: Project Infrastructure & Module Wiring | Pending |
| INFRA-07 | Phase 11: Localization & iOS Deployment | Pending |
**Coverage:**
- v1 requirements: **72 total** (AUTH=6, HSHD=7, RCPE=8, PLAN=14, PNTR=5, SHOP=6, SYNC=10, UI=9, INFRA=7)
- Mapped to phases: **72**
- Unmapped: **0**
---
*Requirements defined: 2026-04-24*
*Last updated: 2026-04-23 after roadmap creation*

227
.planning/ROADMAP.md Normal file
View File

@@ -0,0 +1,227 @@
# Roadmap: Recipe
**Core Value:** "My week is planned." I pick recipes, the calendar fills up, and I know what we're eating.
**Granularity:** Fine (11 phases)
**Mode:** YOLO
**Source of truth:** Derived from `.planning/REQUIREMENTS.md` (72 v1 requirements) guided by `.planning/research/SUMMARY.md` (suggested skeleton) and `.planning/research/ARCHITECTURE.md` (build-order reasoning).
## Phases
- [ ] **Phase 1: Project Infrastructure & Module Wiring** — Running-but-empty KMP client + Ktor server with all build infra baked in
- [ ] **Phase 2: Authentication Foundation** — User signs in through Authentik (OIDC+PKCE) and the server validates tokens
- [ ] **Phase 3: Households, Membership & Server Data Foundation** — Users create/join households; server enforces household scope
- [ ] **Phase 4: Sync Engine Skeleton** — Offline-first read/write with outbox-backed LWW sync on a sentinel table
- [ ] **Phase 5: Recipe Catalog (Read Path)** — User browses, filters, and opens recipe details from a seeded catalog
- [ ] **Phase 6: Meal Planner — Core Write Path** — User picks recipes into the 5-slot calendar; first real outbox-backed aggregate
- [ ] **Phase 7: Meal Planner — Customization & Nutrition** — User tweaks servings/ingredients/products per meal entry and sees daily nutrition
- [ ] **Phase 8: Pantry** — User tracks what's on hand and sees shortfalls against the plan
- [ ] **Phase 9: Shopping List & Session Log** — User generates a grouped shopping list from the plan and shops with "bought" tracking
- [ ] **Phase 10: UI Chrome & Haze Liquid-Glass Polish** — Tab/nav glass effects, iOS-idiomatic chrome, calmer visual hierarchy
- [ ] **Phase 11: Localization & iOS Deployment** — Full Polish copy pass, i18n-ready resources, TestFlight to partner
## Phase Summary Table
| # | Name | Goal (one line) | Requirements | #SC |
|---|------|-----------------|--------------|-----|
| 1 | Project Infrastructure & Module Wiring | KMP client + Ktor server build cleanly with convention plugins, version catalog, iOS binary flags, and a shared DTO module | INFRA-01, INFRA-02, INFRA-03, INFRA-06 | 4 |
| 2 | Authentication Foundation | End-to-end OIDC+PKCE login to Authentik with JIT user provisioning and server-side JWT validation | AUTH-01, AUTH-02, AUTH-03, AUTH-04, AUTH-05, AUTH-06 | 5 |
| 3 | Households, Membership & Server Data Foundation | Create/join households via invites; every request carries a household-scoped principal derived from JWT | HSHD-01, HSHD-02, HSHD-03, HSHD-04, HSHD-05, HSHD-06, HSHD-07, INFRA-05 | 5 |
| 4 | Sync Engine Skeleton | Outbox-backed LWW sync works round-trip on a sentinel table with server-assigned timestamps and cursor pull | SYNC-01, SYNC-02, SYNC-03, SYNC-04, SYNC-05, SYNC-06, SYNC-07, SYNC-08, SYNC-09, SYNC-10 | 5 |
| 5 | Recipe Catalog (Read Path) | User browses a seeded recipe catalog, filters/searches, and opens a detail view — offline-capable | RCPE-01, RCPE-02, RCPE-03, RCPE-04, RCPE-05, RCPE-06, RCPE-07, RCPE-08, UI-05, UI-08 | 5 |
| 6 | Meal Planner — Core Write Path | User fills the 5-slot calendar with recipes; adds/removes/replaces/skips; writes survive offline and sync | PLAN-01, PLAN-02, PLAN-03, PLAN-04, PLAN-05, PLAN-06, PLAN-12, PLAN-14 | 5 |
| 7 | Meal Planner — Customization & Nutrition | User customizes ingredients per meal entry and sees daily macro totals that respect customizations | PLAN-07, PLAN-08, PLAN-09, PLAN-10, PLAN-11, PLAN-13 | 4 |
| 8 | Pantry | User manages pantry inventory by category and sees shortfalls for a chosen horizon | PNTR-01, PNTR-02, PNTR-03, PNTR-04, PNTR-05 | 4 |
| 9 | Shopping List & Session Log | User generates a category-grouped shopping list and marks items bought during a session | SHOP-01, SHOP-02, SHOP-03, SHOP-04, SHOP-05, SHOP-06 | 4 |
| 10 | UI Chrome & Haze Liquid-Glass Polish | 4-tab nav with independent back stacks, Haze glass chrome, iOS idioms, breathing-room visual hierarchy | UI-03, UI-04, UI-06, UI-07, UI-09 | 5 |
| 11 | Localization & iOS Deployment | All strings externalized, Polish copy throughout, partner installs via TestFlight | UI-01, UI-02, INFRA-04, INFRA-07 | 4 |
## Phase Details
### Phase 1: Project Infrastructure & Module Wiring
**Goal:** Stand up a KMP + Ktor repo whose build is "boring correct" from day 1 — version catalog, convention plugins, iOS binary flags, and a pure-Kotlin `shared/` module — so every later phase slots into an already-configured system.
**Depends on:** Nothing (first phase)
**Requirements:** INFRA-01, INFRA-02, INFRA-03, INFRA-06
**Success Criteria** (what must be TRUE):
1. `./gradlew build` succeeds across `composeApp`, `server`, `shared`, and produces an iOS framework and an Android APK from the bare template screens.
2. All library versions are resolved through `gradle/libs.versions.toml`; no version literals exist inside any `build.gradle.kts`.
3. iOS `gradle.properties` carry `kotlin.native.binary.objcDisposeOnMain=false` and `kotlin.native.binary.gc=cms`; a debug launch on simulator boots without warnings about legacy memory-management flags.
4. `build-logic/` convention plugins apply the Kotlin/Compose/test configuration to every module — adding a new module requires only applying a convention plugin, not copying compiler args.
5. `shared/commonMain` contains only domain models + serializable DTOs; no Ktor, Compose, or SQLDelight imports appear anywhere under `shared/`.
**Plans:** TBD
**UI hint:** no
**Research flag:** no
### Phase 2: Authentication Foundation
**Goal:** Deliver a working end-to-end login: the app opens Authentik via OIDC (authorization code + PKCE), stores the tokens securely, and the Ktor server validates them on a protected `/api/v1/me` endpoint, JIT-provisioning users on first sign-in.
**Depends on:** Phase 1
**Requirements:** AUTH-01, AUTH-02, AUTH-03, AUTH-04, AUTH-05, AUTH-06
**Success Criteria** (what must be TRUE):
1. From a fresh install on iOS, I can tap "Zaloguj się", complete Authentik's hosted login, and land back in the app as an authenticated user.
2. I close and reopen the app an hour later; I am still signed in without re-entering credentials (refresh token flow runs transparently).
3. I tap "Wyloguj się"; the app returns to the login screen and the stored tokens are gone from Keychain/EncryptedSharedPreferences.
4. Calling `GET /api/v1/me` with a valid token returns my user record; the same call with a missing, expired, or wrong-audience token returns 401.
5. My user row exists in the server DB after my first successful login, keyed by the OIDC `sub` claim (no manual user creation needed).
**Plans:** TBD
**UI hint:** yes
**Research flag:** yes
### Phase 3: Households, Membership & Server Data Foundation
**Goal:** Introduce the tenancy model before any feature tables land — `users`, `households`, `memberships`, `invites` with Flyway migrations; server's `PrincipalResolver` maps JWT `sub` to an active `householdId`; client finishes onboarding by creating or joining a household.
**Depends on:** Phase 2
**Requirements:** HSHD-01, HSHD-02, HSHD-03, HSHD-04, HSHD-05, HSHD-06, HSHD-07, INFRA-05
**Success Criteria** (what must be TRUE):
1. On my first login, I see an onboarding screen asking me to create a new household or enter an invite code.
2. I create a household, receive a short-lived single-use invite code, send it to my partner, and they redeem it to join the same household.
3. Once both users are in the same household, any household-scoped API call returns identical data regardless of which member made it.
4. A crafted API request that puts a different `household_id` in the body is ignored — the server always derives `household_id` from the authenticated principal, not the payload.
5. The server starts up and Flyway automatically applies `V1__init.sql` (or equivalent) in the correct order; restarting the server twice in a row is idempotent.
**Plans:** TBD
**UI hint:** yes
**Research flag:** no
### Phase 4: Sync Engine Skeleton
**Goal:** Build the offline-first spine — a Koin-singleton `SyncEngine` that owns the outbox and pull cursor, server endpoints `POST /sync/push` + `GET /sync/pull?since=`, and a sentinel table round-trips through it — so every later feature just adds a table, not a sync strategy.
**Depends on:** Phase 3
**Requirements:** SYNC-01, SYNC-02, SYNC-03, SYNC-04, SYNC-05, SYNC-06, SYNC-07, SYNC-08, SYNC-09, SYNC-10
**Success Criteria** (what must be TRUE):
1. I write to the sentinel table while the app is offline (airplane mode); the write appears instantly in the UI, and when I reconnect it reaches the server within seconds without manual intervention.
2. My partner edits the same sentinel row on their device; within the poll interval (2030 s while foregrounded) I see their change, and if we both edited concurrently the server's later-assigned `updated_at` wins with no silent data loss.
3. I delete a sentinel row on device A; after sync the row is gone on device B — and if I re-create "the same" row it comes back with a fresh UUID identity and does not resurrect old fields.
4. Killing the app with pending writes in the outbox and relaunching later preserves those writes; they drain on the next sync cycle.
5. Network failures and 5xx responses trigger exponential backoff retries without blocking the UI; no feature code issues HTTP sync writes directly — all go through the `SyncEngine`.
**Plans:** TBD
**UI hint:** no
**Research flag:** yes
### Phase 5: Recipe Catalog (Read Path)
**Goal:** Deliver the first real user-visible feature — a browseable recipe catalog — via a pull-only cache path that exercises Exposed + SQLDelight + Ktor + Coil end-to-end without write-path complexity, seeded server-side so the rest of the app has real data to develop against.
**Depends on:** Phase 4
**Requirements:** RCPE-01, RCPE-02, RCPE-03, RCPE-04, RCPE-05, RCPE-06, RCPE-07, RCPE-08, UI-05, UI-08
**Success Criteria** (what must be TRUE):
1. I open "Przepisy" and see a grid of recipe cards with thumbnail, title, and cooking time — fully populated from server-seeded catalog data.
2. I can filter the grid by meal slot, tag, and cooking-time range, and search by title/tag text; results update as I type.
3. I tap a recipe and see a detail view with ingredients (amounts + units), steps, nutrition per serving, cooking time, and any defined substitutions.
4. I put the device in airplane mode, relaunch the app, and the catalog still renders from the local SQLDelight cache.
5. The app respects my system light/dark appearance setting, and recipe-list dates/times render in Polish locale format (day and month names in Polish).
**Plans:** TBD
**UI hint:** yes
**Research flag:** no
### Phase 6: Meal Planner — Core Write Path
**Goal:** Ship the hero feature's skeleton — the calendar with 5 slots per day — as the first real household-scoped write aggregate. Every add/remove/replace/skip/serving-change goes through the outbox, proving the sync spine on realistic load.
**Depends on:** Phase 5
**Requirements:** PLAN-01, PLAN-02, PLAN-03, PLAN-04, PLAN-05, PLAN-06, PLAN-12, PLAN-14
**Success Criteria** (what must be TRUE):
1. I open "Planer", navigate between days/weeks/months, and see each day's 5 slots (śniadanie, drugie śniadanie, obiad, przekąska, kolacja) with whatever I've planned.
2. I can tap a slot, pick a recipe from the catalog, and see it appear instantly — even while offline — then reappear on my partner's device after a sync cycle.
3. I can remove a meal entry, replace it with a different recipe, adjust its servings (112), and mark a slot as "skipped" for a specific day.
4. Every meal entry has a stable UUID identity; deleting and re-adding the same recipe on the same (day, slot) creates a distinct new entry rather than reviving the old one.
5. Two household members concurrently editing the same slot converge deterministically on whichever edit the server stamped last, with no silent data loss.
**Plans:** TBD
**UI hint:** yes
**Research flag:** no
### Phase 7: Meal Planner — Customization & Nutrition
**Goal:** Flesh out the hero feature with per-entry customization (substitutions, excludes, extras, amount overrides, product/pack choice) and the nutrition numbers that close the "am I eating right" loop — all while respecting customizations so the math is honest.
**Depends on:** Phase 6
**Requirements:** PLAN-07, PLAN-08, PLAN-09, PLAN-10, PLAN-11, PLAN-13
**Success Criteria** (what must be TRUE):
1. On any meal entry I can substitute an ingredient with one of the catalog-defined alternatives and the change sticks after sync and restart.
2. On any meal entry I can exclude an ingredient, add an extra ingredient from the catalog (amount + unit), and override an ingredient's amount — each of these reflects in shopping/pantry calculations later.
3. On any meal entry I can select a specific product (pack size) for a given ingredient when multiple exist.
4. Each day in the planner shows daily nutrition totals (kcal, protein, fat, carbs) aggregated across all planned meals for that day, recomputed when any customization changes.
**Plans:** TBD
**UI hint:** yes
**Research flag:** no
### Phase 8: Pantry
**Goal:** Give the household a view of what's actually on hand and what's missing, so the plan connects to real life. Reuses the Phase 4 sync foundation on a second household-scoped aggregate.
**Depends on:** Phase 7
**Requirements:** PNTR-01, PNTR-02, PNTR-03, PNTR-04, PNTR-05
**Success Criteria** (what must be TRUE):
1. I open "Spiżarnia" and see my pantry inventory grouped by category (pieczywo, nabiał, mięso i ryby, warzywa, owoce, suche, przyprawy, inne).
2. I can manually add or update the quantity of any pantry ingredient using its pantry unit (g, ml, szt.), and the change syncs to my partner's device.
3. I pick a planning horizon (e.g., "next 7 days") and see which ingredients fall short based on the plan minus current pantry.
4. I can filter the pantry view by category and by shortfall status (needed / sufficient / not in plan).
**Plans:** TBD
**UI hint:** yes
**Research flag:** no
### Phase 9: Shopping List & Session Log
**Goal:** Close the loop from plan to store — generate a category-grouped shopping list from a chosen date range, mark items bought during an in-store session, and move bought items into the pantry automatically.
**Depends on:** Phase 8
**Requirements:** SHOP-01, SHOP-02, SHOP-03, SHOP-04, SHOP-05, SHOP-06
**Success Criteria** (what must be TRUE):
1. I open "Zakupy", pick a date range from the plan, and see a shopping list aggregating ingredient needs minus current pantry, grouped by category for an efficient store trip.
2. During a shopping session I can mark an item bought; it disappears from active needs and shows up in the pantry in its pantry unit.
3. I can undo a recently marked-bought item within the same session; the item reappears in active needs.
4. I close and reopen the app mid-shopping; my session's bought/unbought state is still there until I explicitly clear it.
**Plans:** TBD
**UI hint:** yes
**Research flag:** no
### Phase 10: UI Chrome & Haze Liquid-Glass Polish
**Goal:** Swap the boring default chrome used in Phases 59 for the intended Liquid-Glass-inspired feel — 4-tab bottom nav with independent back stacks, Haze-based blur on tab/nav chrome, iOS-idiomatic safe-area/keyboard/swipe-back behaviors, and a calmer spacing/typography pass across every screen. Measurable against realistic data already present.
**Depends on:** Phase 9
**Requirements:** UI-03, UI-04, UI-06, UI-07, UI-09
**Success Criteria** (what must be TRUE):
1. The app has a 4-tab bottom nav (Przepisy / Planer / Spiżarnia / Zakupy); tapping into a recipe detail, switching tabs, and coming back preserves the detail — each tab keeps its own back stack.
2. The tab bar and top nav bar use Haze-based translucent blur over content beneath them, consistent in light and dark schemes, and scrolling a full recipe grid on iPhone 11 stays above ~55 fps.
3. The app respects iOS safe areas, supports the swipe-back gesture where applicable, and keyboards never cover focused inputs.
4. Typography and spacing feel noticeably calmer than the legacy PWA mockup — more whitespace between cards, larger hit targets, readable at arm's length.
5. On a fresh install I never see a blank flash on launch, and every main screen (catalog / planner / pantry / shopping) renders a deliberate empty state when there's nothing to show yet.
**Plans:** TBD
**UI hint:** yes
**Research flag:** yes
### Phase 11: Localization & iOS Deployment
**Goal:** Externalize every string into Compose resources with complete Polish copy (correct plural forms), build and deploy the Ktor server image to the homelab alongside Authentik, and get the iOS build into my partner's hands via TestFlight.
**Depends on:** Phase 10
**Requirements:** UI-01, UI-02, INFRA-04, INFRA-07
**Success Criteria** (what must be TRUE):
1. Every user-facing string across every screen is resolved through Compose resources — a grep for raw Polish literals inside composables returns only fixture/test data.
2. The whole app reads as correctly-grammatical Polish, including plural forms (1 / 2 / 5 / 22 counts all render with the right form) and date/weekday names.
3. The Ktor server builds into a Docker image and is running in the homelab reachable over HTTPS with a real (Let's-Encrypt-issued) cert, alongside Authentik.
4. My partner installs the iOS app through TestFlight, signs in through Authentik, joins our household via invite, and can plan a meal that I see on my device.
**Plans:** TBD
**UI hint:** yes
**Research flag:** no
## Progress Table
| Phase | Plans Complete | Status | Completed |
|-------|----------------|--------|-----------|
| 1. Project Infrastructure & Module Wiring | 0/0 | Not started | - |
| 2. Authentication Foundation | 0/0 | Not started | - |
| 3. Households, Membership & Server Data Foundation | 0/0 | Not started | - |
| 4. Sync Engine Skeleton | 0/0 | Not started | - |
| 5. Recipe Catalog (Read Path) | 0/0 | Not started | - |
| 6. Meal Planner — Core Write Path | 0/0 | Not started | - |
| 7. Meal Planner — Customization & Nutrition | 0/0 | Not started | - |
| 8. Pantry | 0/0 | Not started | - |
| 9. Shopping List & Session Log | 0/0 | Not started | - |
| 10. UI Chrome & Haze Liquid-Glass Polish | 0/0 | Not started | - |
| 11. Localization & iOS Deployment | 0/0 | Not started | - |
## Coverage Summary
- **v1 requirements total:** 72 (AUTH=6, HSHD=7, RCPE=8, PLAN=14, PNTR=5, SHOP=6, SYNC=10, UI=9, INFRA=7)
- **Mapped to phases:** 72
- **Unmapped:** 0
- **Coverage:** 100%
---
*Roadmap created: 2026-04-23*
*Granularity: fine (11 phases) | Mode: yolo*

55
.planning/STATE.md Normal file
View File

@@ -0,0 +1,55 @@
# Project State: Recipe
**Project reference:** `.planning/PROJECT.md`
**Roadmap:** `.planning/ROADMAP.md`
**Requirements:** `.planning/REQUIREMENTS.md`
## Core Value
"My week is planned." I pick recipes, the calendar fills up, and I know what we're eating. Everything else — pantry tracking, shopping list, nutrition numbers — exists to reinforce that one moment.
## Current Position
**Current focus:** Phase 1: Project Infrastructure & Module Wiring
**Current plan:**
**Status:** Roadmap created; no plan started yet
**Phase progress:** 0 / 11 phases complete
**Progress bar:** `░░░░░░░░░░░░░░░░░░░░` 0%
## Performance Metrics
| Metric | Value |
|--------|-------|
| Phases planned | 11 |
| v1 requirements | 72 |
| Coverage | 100% |
| Phases complete | 0 |
| Plans complete | 0 |
## Accumulated Context
### Decisions carried in
All locked tech-stack decisions are captured in `.planning/PROJECT.md § Key Decisions`. Architectural patterns are in `.planning/research/ARCHITECTURE.md`. High-risk pitfalls by phase are in `.planning/research/PITFALLS.md`. Do not re-relitigate locked decisions during phase planning.
### Open todos
- None yet — first action is `/gsd-plan-phase 1`.
### Blockers
- None.
## Session Continuity
**Last session:** Roadmapping session, 2026-04-23. Produced `ROADMAP.md` with 11 phases and `STATE.md`; updated `REQUIREMENTS.md` traceability table.
**Next action:** `/gsd-plan-phase 1` — decompose Phase 1 (Project Infrastructure & Module Wiring) into plans.
**Research flags to revisit during phase planning:**
- Phase 2 (Auth): Authentik-specific OIDC setup; iOS OIDC wrapper library choice; token refresh behavior.
- Phase 4 (SyncEngine): concrete cursor format, outbox schema ordering guarantees, retry/backoff policy.
- Phase 10 (UI chrome): current Haze CMP-iOS perf on iPhone 11/12-era hardware; liquid-glass approximation patterns.
---
*Last updated: 2026-04-23*

41
.planning/config.json Normal file
View File

@@ -0,0 +1,41 @@
{
"model_profile": "quality",
"commit_docs": true,
"parallelization": true,
"search_gitignored": false,
"brave_search": false,
"firecrawl": false,
"exa_search": false,
"git": {
"branching_strategy": "none",
"phase_branch_template": "gsd/phase-{phase}-{slug}",
"milestone_branch_template": "gsd/{milestone}-{slug}",
"quick_branch_template": null
},
"workflow": {
"research": true,
"plan_check": true,
"verifier": true,
"nyquist_validation": true,
"auto_advance": false,
"node_repair": true,
"node_repair_budget": 2,
"ui_phase": true,
"ui_safety_gate": true,
"text_mode": false,
"research_before_questions": false,
"discuss_mode": "discuss",
"skip_discuss": false,
"code_review": true,
"code_review_depth": "standard"
},
"hooks": {
"context_warnings": true
},
"project_code": null,
"phase_naming": "sequential",
"agent_skills": {},
"features": {},
"mode": "yolo",
"granularity": "fine"
}

View File

@@ -0,0 +1,144 @@
# Phase 1: Project Infrastructure & Module Wiring - Context
**Gathered:** 2026-04-24
**Status:** Ready for planning
<domain>
## Phase Boundary
Stand up a KMP client + Ktor server whose build is "boring correct" from day 1 — Gradle version catalog, `build-logic/` convention plugins, iOS binary flags, a pure-Kotlin `shared/` module, foundational DI + logging bootstrap, and a minimally-running Ktor server — so every later phase slots into an already-configured system. Scope is infrastructure only; no feature logic, no auth, no DB tables, no UI beyond the template screens.
</domain>
<decisions>
## Implementation Decisions
### Target matrix
- **D-01:** Drop the `js` target from `composeApp` and `shared`. Keep `wasmJs` as the strategic future-web bet (per PROJECT.md "possible future target").
- **D-02:** Skip `iosX64` (Intel simulator / iPhone 5S-SE1). User is on Apple Silicon; no Intel-Mac contributors anticipated. Saves a full iOS compile per build.
- **D-03:** Keep `jvm` target in `composeApp` for Desktop — **as a dev tool only** (hot-reload iteration loop). No Compose Desktop packaging config; not a release surface; not a v1 deliverable per PROJECT.md.
- **D-04:** `shared/` ships the exact same target set as `composeApp`: `androidTarget, iosArm64, iosSimulatorArm64, jvm, wasmJs`. Plus `jvm` covers the server dependency.
- **D-05:** Final target matrix repo-wide: `androidTarget, iosArm64, iosSimulatorArm64, jvm (Desktop + Server), wasmJs`.
### Convention plugins (build-logic/)
- **D-06:** Fine-grained plugin split (5 plugins). Each module applies only what it needs:
- `recipe.kotlin.multiplatform` — KMP target matrix + JVM toolchain + common-test deps
- `recipe.compose.multiplatform` — Compose Multiplatform setup (layers on top of KMP)
- `recipe.android.application` — Android-app-only config (namespace, compileSdk, minSdk, targetSdk from catalog)
- `recipe.jvm.server` — Ktor server JVM config
- `recipe.quality` — Spotless + ktlint + compiler strictness (reusable across all modules)
- **D-07:** `recipe.kotlin.multiplatform` locks in: the D-05 target set, JVM toolchain, framework basename convention (`ComposeApp` / `Shared`), and `kotlin-test` as a common-test dep. New KMP modules apply this plugin and get everything.
- **D-08:** JVM toolchain: **JVM 21** for server, desktop, and `shared/jvm`. Android bytecode target stays **JVM 11** (Android 7 minSdk constraint per template). Document this split in the convention plugin comments.
- **D-09:** **All library versions live in `gradle/libs.versions.toml`.** Hard rule: grep for a non-test version literal inside any `build.gradle.kts` returns zero matches. This is INFRA-01 Success Criterion #2. Plugin versions also routed through the catalog (aliases).
### Code-quality toolchain (recipe.quality plugin)
- **D-10:** Minimal baseline — ship ktlint via **Spotless** only. Spotless handles Kotlin + Gradle files + markdown. Commands: `./gradlew spotlessCheck`, `./gradlew spotlessApply`. No Detekt, no Konsist in Phase 1.
- **D-11:** `allWarningsAsErrors = true` everywhere (configured in `recipe.kotlin.multiplatform`). Any Kotlin/compiler warning fails the build; forces conscious suppression rather than silent drift.
- **D-12:** `explicitApi()` **strict on `shared/` only**. `shared/` is structurally a library (consumed by both composeApp and server as a wire-format contract); `composeApp` and `server` are app code and stay on Kotlin defaults. Configured in `shared/build.gradle.kts` directly, not in the KMP plugin (app modules shouldn't inherit it).
- **D-13:** **No git hooks.** `./gradlew check` is the local gate; CI gate deferred to Phase 11 (deployment). Local hooks add commit friction and are trivially bypassed.
### Phase 1 "running-but-empty" scope — what's wired beyond the template
- **D-14:** **Koin bootstrap.** Add Koin deps (`koin-core`, `koin-compose`, `koin-compose-viewmodel`) via `recipe.kotlin.multiplatform`. Call `startKoin { modules(appModule) }` inside `App()` for composeApp and `MainViewController` for iOS. Ship an empty `appModule` placeholder in `composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/di/AppModule.kt`. Phase 2 adds `authModule`; Phase 4 adds `syncModule`; etc.
- **D-15:** **Kermit logger bootstrap.** Add Kermit dep via `recipe.kotlin.multiplatform`. Set a single top-level tag (`"recipe"`) during app init. Available from day 1 for subsequent phases.
- **D-16:** **Server: `/health` endpoint + Flyway scaffold + Postgres conn config.**
- `GET /health` returns 200 with a trivial JSON body.
- Flyway Gradle plugin + runtime dep wired into `server/build.gradle.kts` via `recipe.jvm.server`; `src/main/resources/db/migration/` directory created (empty). Phase 3 drops `V1__init.sql` into an already-working migrator.
- `application.conf` reads `DATABASE_URL`, `DATABASE_USER`, `DATABASE_PASSWORD` from env with localhost defaults matching docker-compose.
- Server starts and connects to Postgres on boot; fails loudly (not silently) if Postgres is unreachable.
- **D-17:** **`docker-compose.yml` at repo root** defines a `postgres:16` service with a named volume. `README.md` gets a "Local development" section. Phase 3 does not have to litigate local-Postgres setup. Authentik stays on user's homelab (not in docker-compose) but the compose file is the handle for future local services if they're ever needed.
### Locked infrastructure hygiene (from PROJECT.md, enforced in Phase 1)
- **D-18:** iOS binary flags added to `gradle.properties`: `kotlin.native.binary.objcDisposeOnMain=false` and `kotlin.native.binary.gc=cms` (INFRA-03, PITFALLS.md #1).
- **D-19:** `shared/commonMain` stays pure: domain models + `@Serializable` DTOs only; no Ktor, no Compose, no SQLDelight imports. Phase 1 ships an empty package scaffold under `dev.ulfrx.recipe.shared` ready for Phase 2+ DTOs (INFRA-06).
- **D-20:** Namespace `dev.ulfrx.recipe` (package root). Framework basename `ComposeApp` for iOS. No feature modules in v1.
### Claude's Discretion
- Exact ordering of plugin application inside each `build.gradle.kts`
- Specific `spotless { kotlin { ktlint(...) } }` ruleset version (pick latest stable from catalog)
- Whether `application.conf` or `ApplicationConfig.kt` code owns env-var parsing
- Flyway `cleanDisabled` and `baselineOnMigrate` flag choices (use sane defaults for dev)
- Whether Koin bootstrap in `MainViewController` uses `KoinApplication` vs `startKoin` (iOS-specific idiom)
- Whether `docker-compose.yml` uses a `.env` file or inlines localhost defaults
- The exact sentinel JSON body for `/health` (empty object is fine)
</decisions>
<canonical_refs>
## Canonical References
**Downstream agents MUST read these before planning or implementing.**
### Product + scope anchors
- `.planning/PROJECT.md` — Locked tech stack (§ Key Decisions), constraints, module structure rules
- `.planning/REQUIREMENTS.md` — INFRA-01, INFRA-02, INFRA-03, INFRA-06 are the in-scope requirements for this phase
- `.planning/ROADMAP.md` § "Phase 1: Project Infrastructure & Module Wiring" — phase goal + 5 success criteria; ordering rationale for subsequent phases
### Architecture + pitfalls
- `.planning/research/ARCHITECTURE.md` — Recommended project structure (§ Recommended Project Structure) defines the `composeApp/commonMain` package layout that Phase 1 scaffolds; § Build Order Implication explains why the foundation-first order matters
- `.planning/research/PITFALLS.md` — Phase 1 must prevent pitfalls #1 (K/N GC + objcDisposeOnMain), #2 (legacy freeze/SharedImmutable — Kotlin 2.x only), #5 (newSuspendedTransaction, not relevant in Phase 1 but plugin must not preclude it), #6 (DSL-only Exposed, infra impact only)
- `.planning/research/SUMMARY.md` § "Phase 1: Project infrastructure + module wiring" — executive summary of the research-driven rationale
### Project convention
- `CLAUDE.md` — Non-negotiable conventions (§ Non-negotiable conventions). Items #5 (Exposed DSL only), #7 (iOS binary flags day 1), #8 (shared/commonMain stays light), #9 (strings externalized from day 1 — Phase 1 scaffold only, real copy in Phase 11) all touch Phase 1.
No external ADRs or specs yet — project is greenfield; decisions flow from PROJECT.md + research/ files.
</canonical_refs>
<code_context>
## Existing Code Insights
### Reusable assets (what the template already gives us)
- `gradle/libs.versions.toml` exists and is the catalog. Needs to grow; does not need to be created.
- `gradle.properties` exists with basic Gradle memory + Android settings. **Missing iOS binary flags** (D-18 adds them).
- `settings.gradle.kts` already enables `TYPESAFE_PROJECT_ACCESSORS` — keep it.
- Compose Multiplatform hot reload already works for Desktop (commit c50d747). The `recipe.compose.multiplatform` convention plugin should preserve that wiring.
- `composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/App.kt` is the template App(). Koin `startKoin { }` call goes here.
- `server/src/main/kotlin/dev/ulfrx/recipe/Application.kt` is the template Ktor module. `/health` route + Flyway bootstrap go here.
- `iosApp/iosApp/iOSApp.swift` + `ContentView.swift` — the MainViewController hookup for iOS lives here; that's where iOS-side `startKoin` + `ComposeUIViewController` wiring lands.
### Established patterns
- JetBrains KMP template conventions (plugin application style, source-set DSL) — Phase 1 refactors into convention plugins but must not break template compatibility (future template updates are an informal escape hatch).
- `gradle/libs.versions.toml` uses `version.ref = "..."` aliases — continue that pattern; do not introduce inline versions.
### Integration points
- Each module's `build.gradle.kts` replaces its `plugins { alias(...) }` block with `plugins { id("recipe.kotlin.multiplatform"); id("recipe.quality"); ... }`. The actual alias-based plugins (`kotlinMultiplatform`, `composeMultiplatform`, etc.) are applied *inside* the convention plugins, so modules no longer touch `libs.plugins.*`.
- Root `build.gradle.kts` keeps its `apply false` declarations for now (Gradle's plugin classloader hint); convention plugins rely on those declarations being present in the root build.
- `build-logic/` is its own included build (`includeBuild("build-logic")` in `settings.gradle.kts`) — standard Gradle pattern, not a regular module.
### What must NOT change in Phase 1
- Package namespace (`dev.ulfrx.recipe`) — locked in CLAUDE.md and every existing file.
- Android minSdk 24 / compileSdk 36 / targetSdk 36 — locked in `libs.versions.toml`.
- Kotlin version (2.3.20), AGP (8.11.2), Compose Multiplatform (1.10.3), Ktor (3.4.1) — current template versions, upgraded only if catalog-wide bump becomes necessary.
</code_context>
<specifics>
## Specific Ideas
- **"Fine-grained conventions" means a module's plugins block reads like a role declaration.** `composeApp/build.gradle.kts` should literally say: "I am a Kotlin Multiplatform module, I use Compose, I am an Android application, I follow the quality rules." No hidden Compose config leaking into `shared/`.
- **`./gradlew build` succeeds green** is the verification ritual. Any deviation from Phase 1 AC#1 is a regression. Every plan in this phase should end with that check.
- **Android minSdk 24 stays.** Partner's phones are modern enough; Android is secondary anyway. Revisit only if a library requires higher.
- **docker-compose.yml is dev-ergonomics, not deploy infra.** Phase 11 handles the real homelab deploy (separate compose file on the homelab, alongside Authentik).
</specifics>
<deferred>
## Deferred Ideas
- **Detekt static analysis** — skip day 1; add only if code review starts missing the same classes of bug. Revisit criterion: "we've had 3+ PR comments that Detekt would have caught."
- **Konsist architecture fitness tests** — revisit ~Phase 4 (SyncEngine) when cross-layer rules like "repositories never import Ktor Client" or "no HTTP from composeApp/ui/" become meaningful to police. Pattern 2 in ARCHITECTURE.md is the first rule that deserves a fitness test.
- **CI pipeline (GitHub Actions or homelab runner)** — Phase 11 per ROADMAP.md. Phase 1 is single-dev, local-build-only.
- **Git hooks** — considered and explicitly rejected; revisit only if local formatting drift becomes a recurring problem.
- **explicitApi for composeApp and server** — considered; rejected because both are app code, not libraries. Only `shared/` gets the discipline.
- **iosX64 target** — rejected; revisit only if an Intel-Mac contributor joins.
- **`js` target** — rejected; `wasmJs` covers the future-web ambition alone.
- **Compose Desktop packaging (dmg/msi/exe)** — Desktop is dev-tool only in v1; full packaging is out of scope entirely.
- **Konsist, Detekt, CI** listed above are the candidates most likely to be revisited first.
</deferred>
---
*Phase: 01-project-infrastructure-module-wiring*
*Context gathered: 2026-04-24*

View File

@@ -0,0 +1,216 @@
# Phase 1: Project Infrastructure & Module Wiring - Discussion Log
> **Audit trail only.** Do not use as input to planning, research, or execution agents.
> Decisions are captured in CONTEXT.md — this log preserves the alternatives considered.
**Date:** 2026-04-24
**Phase:** 01-project-infrastructure-module-wiring
**Areas discussed:** Target matrix, Convention plugin split, Code-quality toolchain, "Running-but-empty" scope
---
## Target matrix
### Q1: JS target in composeApp + shared — drop it?
| Option | Description | Selected |
|--------|-------------|----------|
| Drop js, keep wasmJs | PROJECT.md mentions Wasm but not js; js is legacy Kotlin/JS path | ✓ |
| Keep both js and wasmJs | Preserve template exactly; zero risk of Kotlin/JS regression on future merges | |
| Drop both | Strictest minimum; re-add wasmJs only if web becomes real | |
**User's choice:** Drop js, keep wasmJs.
### Q2: iosX64 target — include?
| Option | Description | Selected |
|--------|-------------|----------|
| Skip iosX64 | User is on Apple Silicon; iosArm64 + iosSimulatorArm64 sufficient | ✓ |
| Add iosX64 for safety | Intel-Mac safety net; costs a second iOS compile per build | |
**User's choice:** Skip iosX64.
### Q3: Desktop JVM target — role?
| Option | Description | Selected |
|--------|-------------|----------|
| Dev-tool only, no shipped artifact | Hot-reload loop retained; no packaging; PROJECT.md alignment | ✓ |
| Full desktop app, packaged + shipped | dmg/msi/exe as release surface; out of PROJECT.md scope | |
| Drop desktop target entirely | Simplest; loses hot reload | |
**User's choice:** Dev-tool only, no shipped artifact.
### Q4: shared/ target set — mirror composeApp?
| Option | Description | Selected |
|--------|-------------|----------|
| Mirror composeApp exactly | Same target set; consistent dep graph | ✓ |
| Superset: ship everything KMP supports | Every target just in case; build cost | |
| Minimum: only what's used today | Strictest diet; same as Option 1 if we drop js | |
**User's choice:** Mirror composeApp exactly.
---
## Convention plugin split
### Q1: How granular should the convention plugins be?
| Option | Description | Selected |
|--------|-------------|----------|
| Fine-grained (45 plugins) | recipe.kotlin.multiplatform + .compose.multiplatform + .android.application + .jvm.server + .quality | ✓ |
| Coarse (2 plugins) | recipe.kmp + recipe.server; leaks Compose config into shared | |
| Monolith (1 plugin) | Single recipe.conventions with conditional logic | |
**User's choice:** Fine-grained (45 plugins). Preview showed the role-declaration pattern in each module's plugins block.
### Q2: What does the KMP convention plugin lock in?
| Option | Description | Selected |
|--------|-------------|----------|
| Targets + toolchain + common test deps | New KMP module = apply plugin, done | ✓ |
| Targets + toolchain only | Thinner plugin, more repetition downstream | |
| Everything incl. Koin + Kermit wiring | Upfront convenience, invasive over time | |
**User's choice:** Targets + toolchain + common test deps.
### Q3: JVM toolchain version?
| Option | Description | Selected |
|--------|-------------|----------|
| JVM 21 everywhere, androidTarget stays JVM 11 | Split kept; modern JDK on server, Android constrained | ✓ |
| JVM 17 everywhere | Unified; loses JVM-21 features (virtual threads) | |
| Keep template defaults | Zero refactor risk; loses explicit control | |
**User's choice:** JVM 21 everywhere, androidTarget stays JVM 11.
### Q4: Where do library version strings live?
| Option | Description | Selected |
|--------|-------------|----------|
| All versions in libs.versions.toml, nowhere else | Strict INFRA-01 SC#2 | ✓ |
| Catalog for libs, plugin versions inline | Technically violates SC#2 | |
**User's choice:** All versions in libs.versions.toml, nowhere else.
---
## Code-quality toolchain
User clarified: "What are Detekt alternatives? Is ktlint OK?" Discussion explained Detekt = static analysis, ktlint = formatting — not alternatives, usually paired. Presented tiers (minimal / standard / architecture-aware) and user chose minimal.
### Q1: Static analysis (Detekt)?
| Option | Description | Selected |
|--------|-------------|----------|
| Wire Detekt now | Default ruleset + baseline; catches Kotlin footguns | |
| Skip Detekt; lean on IDE + compiler | No CI gate for static analysis | ✓ |
| Placeholder task, no rules | Wire-in-place for future enablement | |
**User's choice:** Skip Detekt. Minimal baseline.
**Notes:** Konsist (architecture fitness) deferred to ~Phase 4 when SyncEngine rules exist.
### Q2: Formatting / linting?
| Option | Description | Selected |
|--------|-------------|----------|
| ktlint via Spotless plugin | One tool: Kotlin + Gradle + markdown | ✓ |
| ktlint plugin directly | Thinner; loses multi-format coverage | |
| Skip, rely on IDE + .editorconfig | No CI-level gate | |
**User's choice:** ktlint via Spotless plugin.
### Q3: Compiler warnings as errors?
| Option | Description | Selected |
|--------|-------------|----------|
| allWarningsAsErrors = true everywhere | Max discipline; deprecations force conscious suppression | ✓ |
| Warn only | Noise accumulates | |
| As-errors for module code, relaxed for generated | Small config carve-out | |
**User's choice:** allWarningsAsErrors = true everywhere.
### Q4: Explicit API mode for shared/?
User clarified: "I don't understand it. What is this explicit api?" and later "Is this some kind of a standard because I am writing kotlin server applications and didn't meet with that". Discussion explained explicitApi as a library-authoring convention (stdlib, coroutines, Ktor etc.) requiring `public` keyword + explicit return types. User weighed the tradeoff and picked strict-on-shared/ on library-contract grounds.
| Option | Description | Selected |
|--------|-------------|----------|
| Skip entirely | Kotlin defaults; no `public` ceremony | |
| Strict on shared/ only | Library discipline on the cross-runtime contract | ✓ |
**User's choice:** Strict on shared/ only.
### Q5: Git hooks?
| Option | Description | Selected |
|--------|-------------|----------|
| No git hooks | `./gradlew check` is the gate; CI later | ✓ |
| Pre-commit hook running spotlessCheck | Blocks commits with formatting drift | |
**User's choice:** No git hooks.
---
## "Running-but-empty" scope
### Q1: Koin DI bootstrap — wire it in Phase 1?
| Option | Description | Selected |
|--------|-------------|----------|
| Wire minimal bootstrap now | Empty appModule + startKoin in App() and MainViewController | ✓ |
| Defer to Phase 2 | Phase 2 does DI + auth together | |
**User's choice:** Wire minimal bootstrap now.
### Q2: Kermit logger bootstrap?
| Option | Description | Selected |
|--------|-------------|----------|
| Set up Kermit now | Logger available from day 1 | ✓ |
| Defer | Add when first feature needs logging | |
**User's choice:** Set up Kermit now.
### Q3: Server "running-but-empty" — `/health` + Flyway scaffold + Postgres config?
| Option | Description | Selected |
|--------|-------------|----------|
| Health endpoint + Flyway scaffold + Postgres conn config | Phase 3 migrations drop into an already-wired migrator | ✓ |
| Health endpoint only, no DB | Phase 3 wires Flyway + Postgres together | |
| Strictly the template skeleton | Most minimal; Phase 2 and 3 do more | |
**User's choice:** Health endpoint + Flyway scaffold + Postgres conn config.
### Q4: docker-compose.yml in Phase 1?
| Option | Description | Selected |
|--------|-------------|----------|
| Add docker-compose.yml now | Phase 3 doesn't have to litigate local-Postgres setup | ✓ |
| Defer to Phase 3 | Compose arrives with first migration | |
| No compose; use homelab Postgres directly | Fastest setup; dev pollutes shared instance | |
**User's choice:** Add docker-compose.yml now.
---
## Claude's Discretion
- Exact ordering of plugin application inside each `build.gradle.kts`
- Specific Spotless ktlint ruleset version (pick latest stable from catalog)
- Whether `application.conf` or a Kotlin config class owns env-var parsing
- Flyway `cleanDisabled` / `baselineOnMigrate` flag choices
- iOS Koin bootstrap idiom (`KoinApplication` vs `startKoin` in MainViewController)
- `docker-compose.yml` shape: `.env` file vs inline localhost defaults
- Exact sentinel JSON body for `/health`
## Deferred Ideas
- Detekt static analysis — revisit only if review misses start compounding
- Konsist architecture fitness tests — revisit ~Phase 4 (SyncEngine rules)
- CI pipeline — Phase 11 (deployment)
- Git hooks — considered; revisit only on recurring format drift
- explicitApi for composeApp / server — rejected (app code, not libraries)
- iosX64 target — rejected (no Intel-Mac contributors)
- `js` target — rejected (wasmJs covers future-web intent)
- Compose Desktop packaging (dmg/msi/exe) — Desktop is dev-only

View File

@@ -0,0 +1,259 @@
# Architecture Research
**Domain:** Offline-first meal-planning app (KMP + Ktor, household-shared)
**Researched:** 2026-04-23
**Confidence:** HIGH (locked stack; standard patterns within it)
## System Overview
```
┌──────────────────────────────────────────────────────────────────┐
│ composeApp/ (Android · iOS · Desktop · Wasm) │
│ ┌──────────────────────────────────────────────────────────┐ │
│ │ UI: Compose screens + NavHost (Jetpack Nav CMP) │ │
│ │ ViewModel (StateFlow) ──► Repository (reactive Flow) │ │
│ │ │ │ │ │
│ │ ▼ ▼ │ │
│ │ SyncEngine (singleton) ◄──► SQLDelight (local) + Outbox │ │
│ │ │ │ │
│ │ │ AuthSession (AppAuth / ASWebAuth) │ │
│ │ ▼ │ │
│ │ Ktor Client (JWT bearer) ─────────────────┐ │ │
│ └────────────────────────────────────────────┼───────────────┘ │
└──────────────────────────────────────────────┼───────────────────┘
│ HTTPS
┌────────────────────────────────┼───────────┐
│ Authentik (OIDC IdP, homelab) │ JWKS │
└────────────────────────────────┴───────────┘
┌──────────────────────────────────────────────▼───────────────────┐
│ server/ (Ktor 3.x, same homelab) │
│ Auth (ktor-server-auth-jwt) ──► Routes /api/v1/* │
│ │ │ │
│ ▼ ▼ │
│ PrincipalResolver ──► Services ──► Exposed DSL ──► Postgres │
│ (Flyway) │
└──────────────────────────────────────────────────────────────────┘
shared/commonMain: domain models + API DTOs (client + server both depend)
```
## Component Responsibilities
| Component | Responsibility | Typical Implementation |
|-----------|----------------|------------------------|
| Screen (`@Composable`) | Render state, forward intents. No I/O. | `PlannerScreen(state, onAddMeal)`; consumes `collectAsStateWithLifecycle()` |
| ViewModel | Expose `StateFlow`; coordinate repo calls; zero Compose imports | Extends `ViewModel`, scoped via `koinViewModel()`, method-per-action |
| Repository | Single source of truth for one aggregate; hide local/remote split | Exposes `Flow<Domain>` from SQLDelight; write path goes through local DB + outbox |
| SyncEngine | Own outbox drain, pull loop, backoff, auth failure handling | App-scoped Koin singleton; one `CoroutineScope(SupervisorJob)`; started after auth |
| DataSource (local) | Thin SQLDelight wrapper, mapping rows ↔ domain | Per-table `Queries` injected; suspend + `asFlow().mapToList()` |
| DataSource (remote) | Typed Ktor calls for `/sync/push`, `/sync/pull`, catalog endpoints | `HttpClient` with `Auth { bearer { ... } }` + `ContentNegotiation(Json)` |
| AuthSession | Own tokens, refresh, sign-in/out; expose `StateFlow<AuthState>` | Platform-specific actual class (AppAuth / ASWebAuth) behind `expect` |
| Koin Module | Wire graph per layer (`appModule`, `dataModule`, `syncModule`, `authModule`) | Declared in `commonMain`; `startKoin` in `App()` + `MainViewController` |
| Ktor route | HTTP surface; validate DTO; call service; never touch DB directly | `Route.planRoutes()` under `authenticate("auth-jwt") { route("/api/v1") { ... } }` |
| Exposed table | Schema definition + column types; DSL queries via `transaction {}` | `object PlanEntries : Table("plan_entries")` — no DAO |
| Outbox | Durable queue of unsynced local writes keyed by aggregate+id | `sync_outbox` table in SQLDelight; `(op, table, pk, payload_json, attempts)` |
## Recommended Project Structure
```
composeApp/src/commonMain/kotlin/app/recipe/
├── app/ # App() composable, root nav, Koin bootstrap
├── navigation/ # @Serializable route classes + NavGraphBuilder extensions
├── ui/
│ ├── theme/ # Color, typography, Haze style tokens
│ ├── components/ # Reusable (GlassCard, MealSlotChip, ...)
│ └── screens/
│ ├── recipes/ # RecipeListScreen, RecipeDetailScreen, *ViewModel
│ ├── planner/ # PlannerScreen, DayColumn, *ViewModel
│ ├── pantry/
│ └── shopping/
├── data/
│ ├── local/ # SQLDelight driver factory (expect/actual), Queries wrappers
│ ├── remote/ # HttpClient factory, DTOs mirroring shared/, auth interceptor
│ ├── sync/ # SyncEngine, Outbox, pull scheduler, conflict policy
│ └── repository/ # PlanRepository, PantryRepository, CatalogRepository, ...
├── domain/ # Value types, enums (MealSlot), pure computations (shortfall, aggregation)
├── auth/ # AuthSession interface, token store, OIDC config
└── di/ # appModule, dataModule, syncModule, authModule
server/src/main/kotlin/app/recipe/server/
├── Application.kt # embeddedServer, install plugins, call moduleMain()
├── plugins/ # Auth, ContentNegotiation, CallLogging, StatusPages, CORS
├── auth/ # JWKS config, PrincipalResolver (sub → user → household)
├── routes/
│ ├── sync/ # push.kt, pull.kt
│ ├── catalog/ # recipes, ingredients, products (read-mostly)
│ ├── households/ # memberships, invites
│ └── health/
├── services/ # PlanService, SyncService — orchestrate transactions
├── db/
│ ├── tables/ # Exposed Table objects (no DAO)
│ ├── Mappers.kt # ResultRow → shared DTO
│ └── Database.kt # HikariCP + Flyway.migrate()
└── util/ # Clock (injectable), IdGen, Json
server/src/main/resources/db/migration/ # V1__init.sql, V2__plan_entries.sql, ...
shared/src/commonMain/kotlin/app/recipe/shared/ # Domain + DTOs (@Serializable) — no I/O deps
```
Rationale: groups by UI concern then data layer, matching the locked decision in PROJECT.md. `data/sync/` is a first-class folder because sync is the spine of the app. `domain/` holds pure logic so it can be unit-tested without Android/iOS runtime. Server mirrors the client's layered split (routes → services → db) so reasoning transfers.
## Architectural Patterns
### Pattern 1: Repository → reactive Flow → StateFlow in ViewModel
Repositories expose `Flow<Domain>` built from SQLDelight's `asFlow().mapToList()`. The ViewModel lifts that into a cold-hot `StateFlow` using `stateIn` with `WhileSubscribed(5_000)`. Writes go through the repo, which writes to SQLDelight; the reactive query re-emits automatically. **Never** pre-fetch state with a suspend call in `init {}` — that races with collection.
```kotlin
class PlannerViewModel(private val repo: PlanRepository) : ViewModel() {
val state: StateFlow<PlannerState> = repo.observeWeek(currentWeek)
.map(PlannerState::fromEntries)
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), PlannerState.Loading)
fun onAddMeal(day: LocalDate, slot: MealSlot, recipeId: Uuid) =
viewModelScope.launch { repo.add(day, slot, recipeId) }
}
```
### Pattern 2: Sync engine as a Koin singleton owning outbox + poll cycles
One long-lived `SyncEngine` bound in `syncModule` with a `SupervisorJob`-backed scope. It exposes `pushNow()`, `pullNow()`, `status: StateFlow<SyncStatus>`. Two loops: a push loop that drains `sync_outbox` with exponential backoff on 5xx/network errors, and a pull loop that calls `GET /sync/pull?since={lastCursor}` every 2030s while foregrounded. Repositories never talk to HTTP directly for household data — they enqueue outbox rows and trust the engine.
```kotlin
class SyncEngine(private val api: SyncApi, private val local: LocalDb, private val clock: Clock) {
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
fun start() { scope.launch { pushLoop() }; scope.launch { pullLoop() } }
suspend fun nudge() = pushSignal.emit(Unit)
}
```
Trade-off: single point of failure if the engine deadlocks, so all its work must be cancellable and idempotent (server-side push is keyed by `client_op_id`).
### Pattern 3: Household-scope enforcement at three layers
Defence in depth: (a) **Client query filter** — every SQLDelight query for household-scoped tables includes `WHERE household_id = :hh`, sourced from `AuthSession.activeHouseholdId`; (b) **Server principal resolver** — a `PrincipalResolver` turns the JWT `sub` claim into `(userId, householdId)` via a cached lookup against `memberships`; routes receive an `AuthPrincipal` already carrying `householdId`; (c) **DB row ownership** — every household-scoped table has `household_id uuid NOT NULL` with an index, and every `UPDATE`/`DELETE` includes `AND household_id = ?`.
```kotlin
fun Route.planRoutes(svc: PlanService) = authenticate("auth-jwt") {
post("/api/v1/sync/push") {
val p = call.principal<AuthPrincipal>()!! // householdId baked in
val batch = call.receive<PushBatch>()
call.respond(svc.applyBatch(p.householdId, batch))
}
}
```
Never trust a `householdId` field inside a client payload — overwrite with the principal's.
### Pattern 4: Catalog (read-mostly) vs Household (read-write, synced) split
Two cache + sync policies in one app. **Catalog** (recipes, ingredients, products) is pre-seeded server-side, pulled via versioned ETag (`GET /api/v1/catalog?etag=...`), cached in SQLDelight with a simple "replace all or diff by updated_at" refresh on app start + manual refresh. No outbox. **Household** (plan entries, pantry, shopping items) is LWW-synced with server-assigned `updated_at`, uses the outbox, and is reactively observed. Keep these in separate repositories and separate Koin modules so their refresh semantics don't leak into each other.
## Data Flow — Hero Write Path (Add Meal to Plan)
```
User taps "add meal"
PlannerScreen invokes onAddMeal(day, slot, recipeId)
PlannerViewModel.onAddMeal → viewModelScope.launch { repo.add(...) }
PlanRepository.add():
├─ SQLDelight transaction:
│ INSERT plan_entry (id=localUuid, household_id, day, slot, recipe_id,
│ updated_at=NULL /* server will stamp */, pending=1)
│ INSERT sync_outbox (op='upsert', table='plan_entry', pk=id,
│ payload_json, client_op_id, attempts=0)
└─ Flow<PlanEntries> re-emits → PlannerViewModel.state recomputes → UI updates
│ (optimistic; pending=1 may render a subtle marker)
SyncEngine.nudge() — push loop wakes
Ktor Client POST /api/v1/sync/push (Authorization: Bearer <jwt>)
Ktor Server: install(Authentication) { jwt("auth-jwt") { verifier(jwkProvider) } }
│ JWT validated against Authentik JWKS (cached, rotating)
PrincipalResolver: sub → userId → householdId (cached)
sync/push.kt → SyncService.applyBatch(householdId, batch)
Exposed transaction {
PlanEntries.upsert { it[id]=...; it[householdId]=...; it[updatedAt]=Clock.now() }
// server clock is authoritative
}
Response { applied: [{ id, client_op_id, updated_at: <server ts> }] }
Client: local tx {
UPDATE plan_entry SET updated_at = <server ts>, pending = 0 WHERE id = ?
DELETE FROM sync_outbox WHERE client_op_id = ?
}
Flow re-emits → pending marker vanishes
~~~ (later) partner's device ~~~
Pull loop: GET /api/v1/sync/pull?since=<lastCursor>
Server returns rows with updated_at > since, scoped to householdId
Client upserts rows in a single SQLDelight tx; advances cursor
Partner's PlannerViewModel StateFlow emits new state → their UI updates
```
## Anti-Patterns
### Anti-Pattern 1: Suspend fetch in `init {}` feeding a `MutableStateFlow`
```kotlin
// WRONG
init { viewModelScope.launch { _state.value = repo.getOnce() } }
```
Races with UI collection; loses SQLDelight's reactive updates; forces manual refresh after every write. **Instead:** build the `StateFlow` declaratively from `repo.observeX().stateIn(...)`.
### Anti-Pattern 2: Using Exposed DAO (active record) for new tables
Exposed's DAO API (`IntEntity`, `EntityClass`) looks convenient but leaks lazy-loading through transactions and fights JSONB/composite types. PROJECT.md already forbids it. **Instead:** use the DSL (`Table` objects + `transaction { Table.select { ... } }` + explicit `ResultRow → DTO` mappers). Predictable SQL, no session/transaction surprises.
### Anti-Pattern 3: Sharing SQLDelight transactions across coroutine contexts on iOS
SQLDelight's iOS driver (native-sqlite) uses thread-confined connections. Launching nested `withContext(Dispatchers.IO)` inside a `transaction { }` can throw `IllegalStateException` or silently serialize incorrectly. **Instead:** keep the entire transaction inside one coroutine, use SQLDelight's `transactionWithResult { }`, and do network/CPU work *outside* the tx. On iOS, the driver's own dispatcher handles threading.
### Anti-Pattern 4: Using device clock for `updated_at`
Phones have drifting clocks and timezone shenanigans; a device whose clock is 10 minutes fast will always "win" LWW. **Instead:** server stamps `updated_at` inside the push transaction (`Clock.System.now()` on the server, or `now()` in SQL). The client only stores what the server returns. Local-only edits carry `pending=1` until acknowledged.
### Anti-Pattern 5: Putting UI, HTTP, or DB types in `shared/commonMain`
PROJECT.md scopes `shared/` to domain models + DTOs. Dragging Ktor or SQLDelight into `shared/` pulls platform-specific deps into the server build graph and vice versa. **Instead:** client-only concerns live in `composeApp/`, server-only in `server/`, and `shared/` stays a pure-Kotlin library with `kotlinx.serialization` + `kotlinx.datetime` as its only non-stdlib deps.
## Build Order Implication
The layer that must exist first is **auth + a working Ktor skeleton that echoes an authenticated principal**, because every subsequent layer depends on having a real `householdId` to scope against. After that the unblock order is: (1) **sync engine foundation** — outbox table, empty push/pull endpoints, cursor persistence — so every feature slots into an already-synced path instead of being retrofitted; (2) **catalog read path** — lets the UI render recipes without any write-path complexity, proving HTTP + SQLDelight + Coil end-to-end on a trivial aggregate; (3) **household write path** — the planner as the first real outbox-backed aggregate, which flushes out LWW edge cases; (4) **UI chrome** — Haze-backed glass, navigation polish, theming — last, because decorating a working app is cheap while architecting around decoration is expensive. Skipping step 1 or 2 and jumping to the planner looks faster for a week and costs a month.
## Sources
- `/Users/rwilk/dev/repo/recipe/.planning/PROJECT.md` (authoritative stack + constraints)
- Training knowledge: Compose Multiplatform 1.7+, Jetpack Nav CMP port 2.9.x, SQLDelight 2.x coroutine extensions, Ktor 3.x auth-jwt + JWKS, Exposed DSL transaction semantics, Authentik OIDC discovery
- No web searches needed — patterns are standard within the locked stack
---
*Architecture research for: KMP + Ktor household meal planner*
*Researched: 2026-04-23*

View File

@@ -0,0 +1,292 @@
# Pitfalls Research
**Domain:** Kotlin Multiplatform + Compose Multiplatform (iOS-primary), Ktor/Exposed/Postgres, OIDC, LWW delta sync
**Researched:** 2026-04-23
**Confidence:** HIGH for KMP/Ktor/Exposed gotchas; MEDIUM for Haze + Navigation-CMP specifics (behavior shifts across minor versions)
## Critical Pitfalls
### Pitfall 1: Kotlin/Native iOS GC thrashing and `objcDisposeOnMain` hangs
**What goes wrong:** On-device (especially iPhone XR/11) the app consumes 300700 MB steadily and freezes for 12 s under ViewModel churn. Flamegraphs show GC threads at >100% CPU.
**Why:** The K/N memory manager dispatches Obj-C release to the main thread by default, serializing teardown behind UI frames. Compose/Koin graphs produce many bridged Obj-C references per navigation.
**Warning signs:** Frame hitches on tab switches; main-thread time in `objc_release` / `Kotlin_ObjCExport_releaseReservedObjectTail`; Instruments shows growing K/N heap.
**How to avoid:** Set `kotlin.native.binary.objcDisposeOnMain=false` and `kotlin.native.binary.gc=cms` in `gradle.properties` from day 1. Release Kotlin refs in `onDispose`; don't hold them in long-lived Swift closures.
**Phase:** UI chrome.
---
### Pitfall 2: Legacy `freeze()` / strict-mm ceremony in copy-pasted snippets
**What goes wrong:** Code from 20212022 tutorials adds `freeze()`, `@SharedImmutable`, `AtomicReference` from `kotlin.native.concurrent`, or `ensureNeverFrozen()`. Compiles on Kotlin 2.x but adds dead code and masks real bugs.
**Why:** The new memory manager removed the freeze paradigm entirely; `freeze()` is a no-op and deprecated.
**Warning signs:** Any of the above symbols appearing in snippets you're about to paste.
**How to avoid:** Reject pre-1.7.20 KMP code. Use `kotlinx.atomicfu` if you truly need atomics; StateFlow is already thread-safe.
**Phase:** Data.
---
### Pitfall 3: `ComposeUIViewController` state loss on iOS re-entry
**What goes wrong:** Backgrounding then returning resets scroll positions, selected tabs, half-filled forms. Koin-scoped ViewModels re-create.
**Why:** If the `UIViewController` is instantiated inside a SwiftUI `body`, each re-render builds a fresh composition. Compose state is owned by the controller's composition root.
**Warning signs:** State survives Android rotation but dies on iOS foreground-return; ViewModel `init` fires on backgrounded return.
**How to avoid:** Build the `UIViewController` **once** — store in `@StateObject` or a top-level property, not in a SwiftUI `body`. Use `rememberSaveable` for any UI state that must survive process death. Never nest multiple `ComposeUIViewController` wrappers.
**Phase:** UI chrome.
---
### Pitfall 4: SQLDelight iOS — missing migration files, in-memory vs file driver divergence
**What goes wrong:** JVM tests pass with in-memory driver; the iOS app crashes on launch with `no such column` after a schema change.
**Why:** `NativeSqliteDriver` persists a real file. Editing `.sq` without a numbered `.sqm` migration and a bumped schema `version` means SQLDelight only *verifies* the schema on open — on a device with an existing install, that check fails.
**Warning signs:** Works on fresh simulator install; breaks on physical device with prior install; Android OK, iOS fails.
**How to avoid:** Every schema change gets a numbered `Nm.sqm`. Enable `verifyMigrations = true` and `verifyDefinitions = true`. Add a dev-only "wipe DB" debug button during early development. Reinstall on device before any QA.
**Phase:** Data.
---
### Pitfall 5: Exposed `transaction {}` inside suspend functions → pool exhaustion
**What goes wrong:** Plain `transaction { ... }` in Ktor handlers. Under modest concurrency (~20 requests) the pool exhausts, p99 cliffs, and `IllegalStateException: Transaction is not currently active` appears.
**Why:** `transaction {}` is blocking and binds the transaction to the calling thread. In a coroutine it blocks event-loop threads; if the code suspends mid-transaction, resume lands on a different thread and loses the JDBC connection binding.
**Warning signs:** Connection pool always fully leased at low RPS; latency cliffs; "transaction not active" in logs.
**How to avoid:** Use `newSuspendedTransaction(Dispatchers.IO) { ... }` in suspend contexts. Pass the `Database` instance explicitly. No HTTP calls inside transactions. HikariCP pool size 810 is plenty for 510 users.
**Phase:** Data.
---
### Pitfall 6: Exposed DAO + JSONB footguns
**What goes wrong:** `IntEntity` + `jsonb<T>()` produces double-serialized JSON in Postgres (`"{\"key\":\"v\"}"`) or `SerializationException` on read.
**Why:** DAO integration with JSONB is thin; it's easy to store a pre-stringified value. DAO lazy-loads hide *when* the column is read, so failures manifest far from the cause.
**Warning signs:** Escaped JSON in `psql` output; serialization errors deep in read paths.
**How to avoid:** Use DSL only (already locked in PROJECT.md). For JSONB, define `jsonb("extras", Json.Default, MealExtras.serializer())` once; never stringify upstream. Round-trip integration test per JSONB column.
**Phase:** Data.
---
### Pitfall 7: Ktor JWT — audience, issuer, clock skew, JWKS cache
**What goes wrong:** 401s in production only, after a while, or after Authentik restart. Messages: "Token can't be used before...", "Claim 'aud' doesn't contain required audience", or silent 401s post key-rotation.
**Why:** Four defaults converge:
1. `ktor-server-auth-jwt` requires explicit `.withAudience()` / `.withIssuer()`.
2. Default clock leeway is **zero** — 2 s device drift rejects fresh tokens.
3. JWKS cache defaults to `(10, 24h)` — key rotation invisible for hours.
4. Authentik's `aud` can be array or string depending on provider config.
**Warning signs:** 401 only in prod; 401 only on some devices; works briefly then fails; 401 after Authentik restart.
**How to avoid:** Configure `.withIssuer(issuer).withAudience(clientId).acceptLeeway(30)`. JWKS provider with `.cached(10, 15, MINUTES).rateLimited(10, 1, MINUTES)`. In Authentik, emit `aud` as a single client_id string. Integration test: wrong `aud` → 401.
**Phase:** Auth.
---
### Pitfall 8: OIDC redirect URI mismatch + missing PKCE
**What goes wrong:** "redirect_uri does not match" or consent loop on one platform; or login succeeds without PKCE and is interceptable.
**Why:** Native apps are *public* clients — no shippable secret, so Authentik requires PKCE. Redirect URIs must match byte-for-byte (trailing slash, case). iOS uses a custom URL scheme or Universal Link; Android uses an intent-filter. Debug and release builds can differ.
**Warning signs:** Works on Android, fails on iOS (or vice versa); Authentik logs show `invalid_grant`; no `code_challenge` in auth request; fails on release build only.
**How to avoid:** Authentik provider = "Public" + PKCE S256. Register both `recipe://callback` and `recipe://callback/`. AppAuth (Android) + ASWebAuthenticationSession (iOS) with `usePKCE = true`. Keep the redirect URI in one constant in `shared/commonMain`.
**Phase:** Auth.
---
### Pitfall 9: LWW trusting client clocks
**What goes wrong:** User A's phone clock is 90 s fast; A's edit beats B's real-time-later edit in LWW. B's change silently disappears.
**Why:** Client-assigned timestamps trust unverifiable clocks. Even NTP-synced devices drift; simulators can be minutes off.
**Warning signs:** "My edit vanished"; stable prior state reappears; most common with both household members editing the same meal.
**How to avoid:** Server assigns `updated_at` on every write (already in PROJECT.md — enforce it). Client sends only content + prior `updated_at` for optimistic concurrency. Server sets `updated_at = now()` in the transaction and returns it. Make timestamps strictly monotonic per row (e.g. `GREATEST(now(), old.updated_at + interval '1 microsecond')`) to avoid tie collisions.
**Phase:** Sync.
---
### Pitfall 10: Soft-delete + recreate race
**What goes wrong:** Delete a meal entry, immediately re-add "the same" one. Depending on pull ordering, the new row is hidden by the tombstone, or the old row is resurrected with old fields.
**Why:** If `(plan_date, slot)` is treated as identity, tombstone/recreate races are inevitable on concurrent 2-user editing.
**Warning signs:** Undeleted items; deleted meals reappear on partner's device; duplicates in pantry.
**How to avoid:** Identity is always a fresh UUID per row, never `(date, slot)`. Tombstones carry their own `updated_at`. Pull returns tombstones and live rows; client applies in `updated_at` order. Per-client push outbox replays in local sequence order — never parallel. Integration test: two clients alternating delete/recreate, assert convergence.
**Phase:** Sync.
---
### Pitfall 11: Pull-cursor edge cases — missed updates, same-timestamp ties
**What goes wrong:** Partner edits at 14:00:05; client's last pull cursor is `14:00:04.999`. If cursor semantics or timestamp precision are wrong, the change is skipped forever.
**Why:** Cursor semantics are subtle. Second-precision timestamps, `>=` instead of `>`, and ties among rows sharing a `updated_at` all cause skipped or replayed rows. Debounced push interleaved with pull can reorder writes.
**Warning signs:** Sporadic stale data that vanishes after pull-to-refresh; only reproduces near DB restarts or bulk imports; duplicates after manual refresh.
**How to avoid:** `updated_at` is `timestamptz` with microsecond precision and strictly monotonic. Cursor is `(updated_at, id)` lexicographic: `WHERE (updated_at, id) > (:since_ts, :since_id) ORDER BY updated_at, id LIMIT N`. Pause pull while a push is in flight. Never split the write and its timestamp notification across transactions.
**Phase:** Sync.
---
### Pitfall 12: Haze on scroll + nested children tank older iPhones
**What goes wrong:** LazyColumn scrolling under a blurred top bar stutters badly on iPhone XR/11, dropping to ~30 fps. Nesting `hazeChild` inside a list item sitting in a `hazeSource` Scaffold makes it worse.
**Why:** iOS Haze uses Skiko `GraphicsLayer` for offscreen capture + re-blur each frame. Progressive blur adds ~25% cost. Older A-series chips without hardware-accelerated RenderEffect equivalents jank under this load.
**Warning signs:** Smooth on simulator/M-series, choppy on iPhone 11; FPS 4050; Skiko render thread pegged in Instruments.
**How to avoid:** One `hazeSource` per screen, never nested. Limit blur to chrome (tab bar, nav bar, sheet headers), not scrolling content. Avoid progressive blur on iOS pre-iPhone 13. Test on the oldest target device in real hardware. Feature-flag the effect with a solid-translucent fallback.
**Phase:** UI chrome.
---
### Pitfall 13: Navigation-CMP tabs — `when`-switch kills per-tab back stack
**What goes wrong:** Tabs implemented as `when (tab) { 0 -> RecipesScreen()... }`. Tapping into a detail, switching tabs, and returning loses the detail. System back exits the app instead of unwinding the tab.
**Why:** A `when` switch destroys the non-current tab's Compose tree. Jetpack Navigation's multi-back-stack requires either each tab as a destination in a parent NavHost, or per-tab nested `NavHost` instances, with `popUpTo(saveState) + restoreState + launchSingleTop`.
**Warning signs:** Deep-links don't restore; back from a nested screen jumps tabs; ViewModels re-created on tab switches.
**How to avoid:** One top-level `NavHost`; `navigation(route = "recipesGraph", ...)` block per tab. Bottom bar navigates: `popUpTo(graph.findStartDestination().id) { saveState = true }; launchSingleTop = true; restoreState = true`. Scope `koinViewModel()` to the destination's `NavBackStackEntry`, not the parent graph. Wasm deep-links are deferred per PROJECT.md.
**Phase:** UI chrome.
---
### Pitfall 14: Polish locale — plurals and timestamp zones
**What goes wrong:** "added 2 godzina temu" (wrong plural form). Shopping items near midnight show on the wrong day across devices.
**Why:** Polish has four CLDR plural forms (one / few / many / other). Naive `if (n == 1)` handles at most two. Serializing `LocalDateTime` over the wire (instead of UTC `Instant`) produces zone/DST bugs.
**Warning signs:** Grammatically wrong Polish copy; yesterday's items shown as today's.
**How to avoid:** Use Compose Resources `<plurals>` with all four forms; call `pluralStringResource(count)`. Wire format: `Instant` UTC ISO-8601 only; display: `.toLocalDateTime(TimeZone.currentSystemDefault())`. Unit test plurals with count 0/1/2/5/22.
**Phase:** UI chrome (i18n foundation).
---
## Technical Debt Patterns
| Shortcut | Immediate Benefit | Long-term Cost | When Acceptable |
|---|---|---|---|
| Ad-hoc `psql` DDL, skipping Flyway | Fast schema iteration | Dev/prod drift; can't rebuild from scratch | Pre-first-deploy only; squash into `V1__init.sql` before real data |
| Hardcoded OIDC issuer/client_id in `shared/commonMain` | Avoids build-config plumbing | Can't run against staging Authentik; Authentik change forces rebuild | v1 single-environment only |
| Plain `transaction {}` in admin endpoints | Simpler mental model | Mixing blocking + suspend patterns leaks; eventually every endpoint wants suspend | Admin-only, single-user endpoints |
| Free-form `meal_entry.extras` JSONB without schema | Evolve without migrations | No DB validation; orphan fields accumulate; hard to query | Until extras shape stabilizes; then promote hot fields to columns |
| No indices until queries are slow | Faster early dev | p99 cliffs during sync; adding indices under load is risky | Until first data import; then index every `(household_id, updated_at)` |
## Integration Gotchas
| Integration | Common Mistake | Correct Approach |
|---|---|---|
| Authentik OIDC | Confidential client type with secret shipped in binary | Public client + PKCE S256; never ship `client_secret` |
| Authentik OIDC | Leaving default signing alg; Ktor JWT expects RS256 | Configure RS256 explicitly; verify `kid` resolves via JWKS |
| Haze + Scaffold | `hazeSource` on Scaffold root + `hazeChild` on a sheet both capturing | `hazeSource` on scrollable content only; chrome uses `hazeChild` |
| App Store / TestFlight | ATS exception to reach homelab self-signed cert | Real cert via Let's Encrypt + Caddy/Traefik; never ship ATS exceptions |
| Postgres JSONB | `WHERE extras->>'k' = 'v'` with no GIN index | `CREATE INDEX ... USING GIN (extras jsonb_path_ops)` once access patterns emerge |
## Performance Traps
| Trap | Symptoms | Prevention | When It Breaks |
|---|---|---|---|
| Pull sync without pagination | First-sync-after-seed hangs seconds | Cursor-paginate `LIMIT 200 ORDER BY updated_at, id` | >500 rows in any scoped table |
| Coil full-res images in recipe grid | Memory spikes, laggy scroll | Explicit thumbnail `Size`; memory+disk cache | >30 images on screen |
| Compose recomposition of entire calendar per edit | Calendar flashes on slot change; scroll resets | Stable IDs per slot; hoist per-slot state; `derivedStateOf` for totals | Any calendar with >7 days visible |
| Haze over full scrolling region | Jank on iPhone XR/11 | Blur chrome only, not content; fallback for old devices | Pre-A13 silicon on 60 Hz panels |
## Security Mistakes
| Mistake | Risk | Prevention |
|---|---|---|
| Missing `WHERE household_id = :caller_household` on reads | Cross-household data leak | All scoped reads go through a `HouseholdScope` helper; review rule: no raw `selectAll()` on scoped tables |
| Trusting client-supplied `household_id` in request body | Tenancy bypass via crafted POST | Derive `household_id` from JWT `sub``memberships`; ignore body's value |
| Logging the `Authorization` header in Ktor `CallLogging` | Tokens leak to log files → account compromise | Custom log filter redacting `Authorization`; never `log.info(token)` |
| Storing OIDC refresh token in plain prefs | Local/backup exposure | `multiplatform-settings` with Keychain (iOS) / EncryptedSharedPreferences (Android) backends |
## "Looks Done But Isn't" Checklist
- [ ] **Auth:** Login works — verify token refresh runs before expiry (set Authentik access-token lifetime to 5 min in dev; watch for silent 401s)
- [ ] **Sync:** Pull works — verify tombstones propagate (delete on A, confirm gone on B after pull, not just after push)
- [ ] **Sync:** Offline writes survive app kill + relaunch + reconnect — not just a warm resume
- [ ] **Household isolation:** Log in as household B; hit every endpoint; assert zero household A rows returned
- [ ] **SQLDelight migrations:** Install prior release, launch once, upgrade in place; confirm no crash, no data loss
- [ ] **Polish plurals:** Open every screen with counts 0, 1, 2, 5, 22; verify grammar
- [ ] **Haze performance:** Test on oldest supported device (iPhone XS/11) scrolling a full screen; not just simulator
## Pitfall-to-Phase Mapping
| Pitfall | Prevention Phase | Verification |
|---|---|---|
| K/N GC thrash; `objcDisposeOnMain` | UI chrome (infra) | Gradle property set; Instruments shows no GC-main domination |
| Legacy `freeze()` ceremony | Data | Code search for `freeze(`, `@SharedImmutable` returns empty |
| UIViewController re-creation | UI chrome | State survives background/foreground cycle |
| SQLDelight missing migration | Data | Prior-build → new-build upgrade test on real device |
| Blocking Exposed transaction in suspend | Data | No `transaction {` in suspend paths; 50-concurrent-request load test with pool size 10 |
| DAO + JSONB | Data | No `exposed.dao.*` imports; per-JSONB-column round-trip test |
| JWT aud/iss/leeway/JWKS | Auth | Wrong-aud → 401; 30 s skew → 200; JWKS refreshes within 15 min |
| OIDC redirect URI / PKCE | Auth | Flow passes on iOS *and* Android; Authentik logs show `code_challenge` per request |
| LWW client-clock trust | Sync | All writes set `updated_at` server-side; clients never send it |
| Soft-delete recreate race | Sync | Two-client alternating delete/recreate converges |
| Pull-cursor edge cases | Sync | Cursor is `(updated_at, id)` lexicographic; same-timestamp test |
| Haze scroll jank | UI chrome | iPhone 11 real-device FPS >55 on recipe grid scroll |
| Nested NavHost / multi-back-stack | UI chrome | Tab switch preserves deep state; system back unwinds within tab |
| Polish plurals / timestamps | UI chrome | Plural unit tests pass; wire format is UTC-only |
| Household tenancy bypass | Auth + Sync | Cross-household read test asserts empty result sets |
## Sources
- [Kotlin/Native memory management](https://kotlinlang.org/docs/native-memory-manager.html) (HIGH)
- [Compose Multiplatform for iOS Stable, 2025](https://www.kmpship.app/blog/compose-multiplatform-ios-stable-2025) (MEDIUM)
- [Haze 1.0 release notes — Chris Banes](https://chrisbanes.me/posts/haze-1.0/) (HIGH)
- [Haze Platforms documentation](https://chrisbanes.github.io/haze/latest/platforms/) (HIGH)
- [Navigation in Compose Multiplatform — JetBrains](https://kotlinlang.org/docs/multiplatform/compose-navigation.html) (HIGH)
- [Bottom Nav + Nested Navigation guide](https://saurabhjadhavblogs.com/jetpack-compose-bottom-navigation-nested-navigation-solved) (MEDIUM)
- [Exposed — Working with Transactions](https://www.jetbrains.com/help/exposed/transactions.html) (HIGH)
- [Exposed — JSON/JSONB types](https://www.jetbrains.com/help/exposed/json-and-jsonb-types.html) (HIGH)
- [Exposed — Breaking Changes](https://www.jetbrains.com/help/exposed/breaking-changes.html) (HIGH)
- Community-known K/N + KMP gotchas synthesized from training + surrounding sources (MEDIUM)
---
*Pitfalls research for: Kotlin Multiplatform recipe/meal-planning app with self-hosted Ktor + Postgres + Authentik backend*
*Researched: 2026-04-23*

View File

@@ -0,0 +1,159 @@
# Project Research Summary
**Project:** Recipe (working title) — household meal planner + pantry + shopping list
**Domain:** Mobile (iOS-primary) + self-hosted backend, offline-first collaborative app for a 2-person household
**Researched:** 2026-04-24
**Confidence:** HIGH
## Executive Summary
This is a household-scoped meal-planning app built as a Kotlin Multiplatform client (iOS-primary) against a self-hosted Ktor server, with offline-first operation and last-write-wins sync over HTTP polling. The core value is "my week is planned" — the planner is the hero feature; pantry and shopping exist to reinforce it. User-base is ~5-10 authenticated users across a handful of households. Tech stack was locked in a direct discussion rather than derived from research, so the research scope was narrowed to two areas where novel value was expected: **architecture patterns within the locked stack** and **pitfalls specific to this library combination**.
The recommended approach centers on a **sync-engine-first** architecture: a single Koin-singleton `SyncEngine` owns the outbox, the pull cursor, and the push/pull cycles; repositories only write to SQLDelight + outbox, never to HTTP directly. Every feature in the app sits on top of this spine, which decouples UI/domain from transport concerns and makes offline-first a property of the system rather than a per-feature discipline. Household scope is enforced at **three layers** (client query filter, server `PrincipalResolver` deriving household from JWT `sub`, and a `household_id` column on every tenant-scoped table) — single-layer enforcement is consistently the source of cross-tenant data leaks in apps like this.
The **highest-risk area** is sync correctness under concurrent household edits. LWW with device-clock timestamps silently loses data when clocks drift; the mitigation is server-assigned `updated_at` for every write, UUIDs (never composite natural keys like `(date, slot)`) as row identity, and a `(updated_at, id)` lexicographic pull cursor with microsecond precision to survive same-millisecond edits. Secondary risks: Kotlin/Native memory/GC on iPhone 12-era devices, Ktor JWT validation leeway and JWKS caching interactions with Authentik, and Haze blur over scrolling content on older iPhones.
## Key Findings
### Recommended Stack
Locked via direct discussion (see PROJECT.md § Key Decisions). No research-driven changes required.
**Core technologies:**
- Compose Multiplatform + Jetpack Navigation CMP port — iOS-primary UI, official JetBrains-recommended router
- Koin + SQLDelight + Ktor Client + kotlinx.serialization/datetime + Kermit + Coil 3 + Haze — canonical KMP client stack in 2026
- Ktor Server + Exposed (DSL, not DAO) + Postgres + Flyway + ktor-server-auth-jwt — canonical Kotlin backend
- Authentik OIDC — user's existing homelab identity provider
### Expected Features
Not researched (user explicitly opted to start catalog fresh rather than survey the market). Active v1 requirements captured directly in PROJECT.md § Requirements — four feature pillars: recipe catalog browse, meal planner, pantry, shopping list; plus auth, household sharing, and offline sync foundations.
### Architecture Approach
See `.planning/research/ARCHITECTURE.md` for the full treatment.
**Major components (top to bottom):**
1. **Compose UI + Navigation** — screens observe ViewModel state; navigation via Jetpack Nav CMP with nested NavHosts per tab for independent back stacks
2. **ViewModel layer** — StateFlow of immutable `UiState`, method-per-action pattern, scoped to `NavBackStackEntry` via `koinViewModel()`
3. **Repository layer** — domain-shaped API; reads return SQLDelight Flows `.asFlow().mapToList(dispatcher)`; writes go to SQLDelight + outbox atomically
4. **SyncEngine (Koin singleton)** — drives outbox drain (push) and pull cursor (poll on foreground + pull-to-refresh + debounced-after-write); owns all HTTP sync traffic
5. **Local DataSources** — thin wrappers over SQLDelight generated queries; one driver per process, threaded correctly for iOS NativeSqliteDriver
6. **Remote DataSources** — Ktor Client with JSON negotiation; catalog fetches use HTTP caching; sync endpoints are separate from catalog
7. **Server Ktor routes** — auth-gated via `Authentication.jwt("authentik")`; every household-scoped handler routes through a `PrincipalResolver` that looks up membership once
8. **Server DB (Exposed + Postgres + Flyway)** — DSL-only, JSONB for meal-entry extras, `newSuspendedTransaction` for every coroutine-touching handler
### Critical Pitfalls
See `.planning/research/PITFALLS.md` for 14 critical pitfalls + anti-pattern tables. The five most load-bearing:
1. **Sync correctness under concurrent edits** — server-assigned `updated_at`, UUID row identity, lexicographic `(updated_at, id)` pull cursor. Any shortcut here causes silent data loss.
2. **Ktor JWT + Authentik integration** — audience, issuer, clock-skew leeway, JWKS cache TTL all configurable; default values fail silently when clocks drift or keys rotate. Explicit configuration mandatory.
3. **OIDC redirect URI + PKCE** — byte-exact match required; mobile clients are public so PKCE is mandatory. Common cause of 400-series auth loops that are opaque without server logs.
4. **Household tenancy derivation**`household_id` always derived from authenticated `sub`, never accepted from request body. Single source of cross-tenant leaks.
5. **iOS infra hygiene**`kotlin.native.binary.objcDisposeOnMain=false`, `gc=cms`, single `ComposeUIViewController` instance, Haze on chrome only (never over scrolling content). Cheap to bake in day 1; painful to retrofit when iPhone XR/11 users complain about jank.
## Implications for Roadmap
The architecture research suggests an explicit **foundation-first** build order. The pitfalls research reinforces this by surfacing that most high-cost mistakes live in the foundation (sync engine, auth validation, household scope), not the feature layer. Suggested phase skeleton:
### Phase 1: Project infrastructure + module wiring
**Rationale:** One-time setup that blocks everything. Convention plugins, version catalog, Koin bootstrap, shared DTOs module, iOS target config with binary flags (`objcDisposeOnMain`, `gc=cms`), server Gradle setup, Flyway plumbing.
**Delivers:** A running but empty composeApp (iOS + Android) + running but unrouted Ktor server.
**Avoids:** Retrofit cost on iOS memory flags and Gradle conventions mid-project.
### Phase 2: Authentication foundation
**Rationale:** Nothing else can be built without authenticated principals. Blocks sync, household data, all CRUD.
**Delivers:** Client OIDC flow (AppAuth on Android, ASWebAuthenticationSession on iOS) to Authentik → access token stored. Server ktor-server-auth-jwt validates token against Authentik JWKS. Protected `/api/v1/me` endpoint returns user.
**Avoids:** Pitfalls 7 + 8 (JWT validation misconfig, redirect URI/PKCE errors).
### Phase 3: Households + membership + server data foundation
**Rationale:** Every feature table needs `household_id`. Introducing this after feature tables exist is a migration nightmare.
**Delivers:** `users`, `households`, `memberships`, `invites` tables + Flyway migrations. `PrincipalResolver` that maps JWT `sub``household_id`. Endpoints for creating a household, generating invite codes, accepting invites. Client auth session now includes `household_id`.
**Avoids:** Tenant-scope leaks, cross-household data bugs.
### Phase 4: Sync engine skeleton
**Rationale:** Second-hardest piece after auth. Must exist before any feature-specific data can be synced. Built on a trivial first table (e.g., a `notes` sentinel table or the `households` metadata).
**Delivers:** SyncEngine interface + implementation. Outbox schema in SQLDelight with `id`, `table`, `row_id`, `op`, `payload`, `created_at`. `/api/v1/sync/push` and `/api/v1/sync/pull` endpoints. Polling scheduler + pull-to-refresh + debounced after-write trigger. Cursor persistence. Mock table round-trips.
**Avoids:** Pitfalls 9, 10, 11 (LWW timestamp sources, delete-recreate races, cursor edge cases).
### Phase 5: Recipe catalog (read path)
**Rationale:** Read-mostly, simpler sync (no writes from client), teaches Exposed + SQLDelight + Ktor end-to-end. Seeds the rest of the app with real data to develop against.
**Delivers:** `recipes`, `ingredients`, `products` tables on server (Flyway migrations). Seed data mechanism (SQL fixtures or admin CLI). Catalog Ktor routes. Client-side catalog cache in SQLDelight with pull-only sync. RecipeListScreen + RecipeDetailScreen reading from local cache.
**Avoids:** Sync-strategy-by-accident (catalog uses different cache rules than household data).
### Phase 6: Meal planner (hero write path)
**Rationale:** Core value. Exercises the full write path: optimistic local write + outbox + sync.
**Delivers:** `plan_entry` table (server + client) with `household_id` + `updated_at` + `deleted_at`. Planner calendar UI. Add/remove/edit meal flows. Nutrition totals computed client-side from catalog + plan.
**Avoids:** Pitfall 1 (sync correctness), by this point the foundation is already correct.
### Phase 7: Pantry
**Rationale:** Second household-scoped feature. Reuses all of the plumbing from Phase 6. Validates that sync foundation generalizes.
### Phase 8: Shopping list + session log
**Rationale:** Computed view over pantry + plan + a small session table. Ties the three data sources together.
### Phase 9: UI chrome with Haze liquid-glass approximation
**Rationale:** Intentionally late. Earlier phases use boring default chrome to avoid blocking on design. Now swap in Haze-based nav and tab bars, glassy cards, dark mode polish. Measurable real-device perf can be validated against real data from Phase 6-8.
**Avoids:** Pitfall 12 (Haze perf regressions — easier to measure once data volume is realistic).
### Phase 10: Polish, polish infra, iOS deployment
**Rationale:** Externalized strings with Polish copy, locale-aware date formatting (local display only — wire stays UTC), Bundle ID + privacy manifests, TestFlight distribution to partner.
### Phase 11 (optional, post-v1): Recipe authoring in-app
**Rationale:** Explicitly deferred in PROJECT.md. First v1 seeds catalog via server migrations.
### Phase Ordering Rationale
- **Auth → Households → SyncEngine → features**: each layer enables the next; skipping any accelerates for a week and costs a month (from ARCHITECTURE.md § Build Order).
- **Catalog (read) before planner (write)**: reads are simpler and catch sync-pull bugs in isolation before writes introduce push/outbox/conflict bugs.
- **UI chrome last**: Haze perf is measurable only with realistic data; design iteration shouldn't block data-layer correctness.
### Research Flags
Phases likely needing deeper phase-level research during planning:
- **Phase 2 (Auth):** Authentik-specific OIDC provider setup steps; AppAuth vs custom iOS wrapper tradeoffs; token refresh behavior
- **Phase 4 (SyncEngine):** Concrete cursor format, outbox schema ordering guarantees, retry/backoff policy
- **Phase 9 (UI chrome):** Current Haze perf benchmarks on CMP iOS; liquid-glass approximation design patterns
Phases with well-trodden paths (minimal research-phase needed):
- **Phase 1 (Infra):** Convention plugins + version catalog is well-documented
- **Phase 5 (Catalog read):** Basic CRUD + cache pattern
- **Phases 6-8 (features):** Once foundation is in place, these follow the architecture patterns directly
## Confidence Assessment
| Area | Confidence | Notes |
|------|------------|-------|
| Stack | HIGH | Locked in direct discussion with tradeoff analysis per library |
| Features | HIGH (scope-bounded) | User defined v1 directly in PROJECT.md; no market research done, intentionally |
| Architecture | HIGH | Research agent produced concrete patterns specific to the locked stack |
| Pitfalls | HIGH | 14 specific pitfalls with library-level detail; covers all major risk areas |
**Overall confidence:** HIGH for entering the roadmap phase.
### Gaps to Address
- **Authentik-specific OIDC flow details** — the research documented WHAT to get right (JWT validation, PKCE, redirect URIs) but not Authentik's specific UI/config steps. Resolve during Phase 2 planning.
- **Mobile OIDC library choice for iOS** — PROJECT.md notes "ASWebAuthenticationSession wrapper" with no specific KMP wrapper library recommended. Resolve during Phase 2 planning.
- **Haze current CMP-iOS perf characteristics on iPhone 12-era hardware** — needs real-device measurement, not research. Covered by Phase 9.
- **Seed-data mechanism** — "SQL migrations, JSON fixtures, or CLI tool" listed as options in PROJECT.md § Constraints. Resolve during Phase 5 planning.
## Sources
### Primary (HIGH confidence)
- `.planning/PROJECT.md` — authoritative product scope + locked stack
- `.planning/research/ARCHITECTURE.md` — agent-researched, ~1900 words, 7 sections
- `.planning/research/PITFALLS.md` — agent-researched, ~2800 words, 14 critical pitfalls + tables
### Secondary
- Direct discussion transcript (April 2026) — tech-stack tradeoff conversation that led to PROJECT.md decisions
- [Navigation in Compose Multiplatform](https://kotlinlang.org/docs/multiplatform/compose-navigation.html)
- [Kotlin/Native memory management](https://kotlinlang.org/docs/native-memory-manager.html)
- [Haze 1.0 — Chris Banes](https://chrisbanes.me/posts/haze-1.0/)
- [Exposed — Transactions](https://www.jetbrains.com/help/exposed/transactions.html)
- [Exposed — JSON/JSONB types](https://www.jetbrains.com/help/exposed/json-and-jsonb-types.html)
---
*Research completed: 2026-04-24*
*Ready for roadmap: yes*