13 KiB
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):
- Compose UI + Navigation — screens observe ViewModel state; navigation via Jetpack Nav CMP with nested NavHosts per tab for independent back stacks
- ViewModel layer — StateFlow of immutable
UiState, method-per-action pattern, scoped toNavBackStackEntryviakoinViewModel() - Repository layer — domain-shaped API; reads return SQLDelight Flows
.asFlow().mapToList(dispatcher); writes go to SQLDelight + outbox atomically - SyncEngine (Koin singleton) — drives outbox drain (push) and pull cursor (poll on foreground + pull-to-refresh + debounced-after-write); owns all HTTP sync traffic
- Local DataSources — thin wrappers over SQLDelight generated queries; one driver per process, threaded correctly for iOS NativeSqliteDriver
- Remote DataSources — Ktor Client with JSON negotiation; catalog fetches use HTTP caching; sync endpoints are separate from catalog
- Server Ktor routes — auth-gated via
Authentication.jwt("authentik"); every household-scoped handler routes through aPrincipalResolverthat looks up membership once - Server DB (Exposed + Postgres + Flyway) — DSL-only, JSONB for meal-entry extras,
newSuspendedTransactionfor every coroutine-touching handler
Critical Pitfalls
See .planning/research/PITFALLS.md for 14 critical pitfalls + anti-pattern tables. The five most load-bearing:
- 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. - 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.
- 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.
- Household tenancy derivation —
household_idalways derived from authenticatedsub, never accepted from request body. Single source of cross-tenant leaks. - iOS infra hygiene —
kotlin.native.binary.objcDisposeOnMain=false,gc=cms, singleComposeUIViewControllerinstance, 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
- Kotlin/Native memory management
- Haze 1.0 — Chris Banes
- Exposed — Transactions
- Exposed — JSON/JSONB types
Research completed: 2026-04-24 Ready for roadmap: yes