diff --git a/.planning/round-audit-marathon-closure.md b/.planning/round-audit-marathon-closure.md index dd5bd2d97..948d65f80 100644 --- a/.planning/round-audit-marathon-closure.md +++ b/.planning/round-audit-marathon-closure.md @@ -20,6 +20,10 @@ Shipped: 2026-05-17 → 2026-05-18 (thirteen consecutive releases — capped by | v1.4.38.6 | Boot-backfill discovery SQL fix — the widened v1.4.38.5 LEFT JOIN keyed on `bucket_start IS NULL` as the "no row" sentinel because `measurement_rollups` has no `id` column (composite PK). Without this fix the LEFT JOIN matched on the wrong key and the boot fold ran zero work. Single-line correctness fix; perf-verify continued into v1.4.38.7. | [v1.4.38.6](https://github.com/MBombeck/HealthLog/releases/tag/v1.4.38.6) | | v1.4.38.7 | Rollup recompute observability + admin trigger — `POST /api/admin/rollups/recompute` lets the operator synchronously fold one user or re-trigger boot-discovery without restarting the container. Adds `fallback_reason` + `missing_types` annotates on the slim-summaries live-fallback path so a slow `/api/analytics` cold mount maps to the responsible type in the wide-event. Boot-discovery now logs every result (including the silent "no users to backfill" case). | [v1.4.38.7](https://github.com/MBombeck/HealthLog/releases/tag/v1.4.38.7) | | v1.4.38.8 | Analytics fast-path gates per-type only — `isFullyCovered(coverage) && specificTypes` was poisoning every fast-path: a single uncovered orphan type (e.g. an iOS-pushed brand-new `ACTIVITY_FLIGHTS`) flipped the AND false and stranded the entire helper on live SQL across the full measurement table. `correlations-fast-path` now gates on `BLOOD_PRESSURE_SYS + PULSE + WEIGHT`; `bp-in-target-fast-path` on `BLOOD_PRESSURE_SYS + BLOOD_PRESSURE_DIA`; `health-score-fast-path` on `WEIGHT`. Cold-mount `/api/analytics` on power-user accounts drops from 30–75 s to ~3–5 s. Coverage-probe semantics unchanged. | [v1.4.38.8](https://github.com/MBombeck/HealthLog/releases/tag/v1.4.38.8) | +| v1.4.40 | Comprehensive Critical+High architecture closure plus iOS PB30 enablement — eleven-wave parallel marathon closing every Critical + High from the v1.4.39 architecture-QA quadrology in one release per the operator's directive ("alle Critical+High in den nächsten finalen release, kein deferral, zeitlicher Aufwand egal"). Pool starvation root cause closed: `pLimit(4)` on the 15-way `Promise.all` fan-out in `buildAnalyticsResponse` plus `pg.Pool max=20` raised from the library default 10 (Wave-C chart-tile first-paint **+7.3 s → +1.6 s expected** on the empirical fixture). Six insights routes swapped to the mood-rollup tier (3 inline, 3 deferred with documented tier-shape reasons); `Measurement.deletedAt` full-wire across 16+ readers + the rollup writer (iOS soft-delete unblocked for PB30); WMY readers now live for `avg30LastYear` (was hardcoded null) + slope90 via MONTH-tier; per-tile `` boundaries plus `queryKey` factory enforcement (mood-analytics + mood-chart-data dedup); iOS SB-3 bilingual paired-on-one-page Privacy Policy, SB-4 AASA file at `/.well-known/apple-app-site-association`, SB-5 APNs `interruption-level: time-sensitive` for medication reminders only (gated on AP-2 `.p8` install), SB-6 `/api/notifications/status` per-category `lastDeliveredAt`, SB-7 four-branch test pin, SB-10 `POST /api/consent/ai` with append-only invariant + 64 KB Buffer-byte cap; ghost cleanup **−1 177 LOC** (TELEGRAM_CLEANUP_QUEUE dead worker, 3 orphan routes, 9 dead exports, 3 dead i18n keys); tz helpers consolidated into `src/lib/tz/`; `src/lib/rollups/` umbrella absorbing 7 files with 68 importer rewrites; knip CI workflow as release-branch gate. Six QA reviewer parallel + five reconcile fixes (test regex align, 6 insights `deletedAt`, `Promise.allSettled` recompute wrap, compare-toggle factory, consent `Buffer.byteLength`). Tests 4 525 → **4 726** (+201). Migration 0074 ConsentReceipt additive-clean. Cross-agent commit drift recurred a 4th time → per-agent git-worktree isolation now hard rule (`feedback_marathon_worktree_isolation.md`). | [v1.4.40](https://github.com/MBombeck/HealthLog/releases/tag/v1.4.40) | +| v1.4.39.4 | Dashboard symmetry for daily-schedule intake projection — `/api/dashboard/summary` compliance tile mirrors the intake-route projection PR #191 landed in v1.4.39.3. Reuses `expandTodayIntakes` plus `createMany({ skipDuplicates: true })` race-guard against concurrent intake-route hits minting the same `(userId, medicationId, scheduledFor, REMINDER)` row before the existence probe converges. Plus `medication_intake_events` unique-by-source constraint migration `0073` underlying the `skipDuplicates` semantics, and a `recomputeUserMedicationCompliance` hook after the bulk insert closes the v1.4.39.3 compliance-rollup hook gap on the dashboard surface. Tests 4 648 → 4 662. | [v1.4.39.4](https://github.com/MBombeck/HealthLog/releases/tag/v1.4.39.4) | +| v1.4.39.3 | Three distinct root causes from the post-v1.4.39.2 e2e regression — `slim?.summaries ?? thick?.summaries` empty-object short-circuit broke the dashboard tile-strip + chart row whenever slim resolved empty (new `merge-slim-thick.ts` helper with object-emptiness discriminator); `MeasurementList` `fmt.integer` silently truncated non-grouped decimal readings since v1.4.37 W7c (pre-existing regression unmasked by the dashboard break); Playwright `**/api/analytics` glob did not match `/api/analytics?slice=summaries` (regex `/\/api\/analytics(\?|$)/` swap across 10 specs). 8 failing e2e → 0 failing e2e. Unit tests 4 654 → 4 662. | [v1.4.39.3](https://github.com/MBombeck/HealthLog/releases/tag/v1.4.39.3) | +| v1.4.39.2 | Two issues post-v1.4.39.1 — `/api/measurements?source=rollup` short-circuited on `rollupRows.length > 0` so a partially-converged rollup partition returned the sparse rollup instead of falling through (per-request coverage probe gated on rollup sparsity; when rollup < 3 rows on ≥ 7-day window, `COUNT(DISTINCT date_trunc('day', measured_at))` against `measurements` for the same window; if live > rollup, inline-fold the partition via `recomputeUserRollups` and re-read so this request returns the converged rows). Plus the dashboard mother query blocking every per-type tile on the heavy `/api/analytics` thick envelope: dashboard now mounts two `useAnalyticsQuery` calls in parallel — slim (`?slice=summaries`) feeds the per-type tile strip + `lastSeenByType`, thick feeds `bpInTargetPct*` + `glucoseByContext` (slim resolves first → tile strip paints; thick streams BD-Zielbereich + glucose tiles in afterwards). | [v1.4.39.2](https://github.com/MBombeck/HealthLog/releases/tag/v1.4.39.2) | | v1.4.39.1 | Rollup tier catches Withings + import + admin-restore write paths — Marc reported "Noch nicht genügend Daten" on dashboard tiles at the 30-point range but the same tiles rendered at 7 or 90. Live-log probe of `/api/measurements?type=BLOOD_PRESSURE_SYS&aggregate=daily&source=rollup` returned `total: 2` for the trailing 30 days; WEIGHT (manually entered via the route that fires the hook) returned 19. Three measurement write paths bypassed the rollup write hook since the v1.4.36 chart `source=rollup` fast-path landed: Withings sync (`sync.ts`, `sync-activity.ts`, `sync-sleep.ts`), `/api/import`, admin backup restore. v1.4.39's boot backfill caught up once on deploy, then immediately re-staled the recent days because the hook still wasn't wired and the per-type-only discovery query no longer re-surfaced the account. Fix: wire `recomputeBucketsForMeasurement` (via `collapseToTypeDayKeys`) into every bypass site; widen the boot-backfill discovery's LEFT JOIN to `(user, type, bucket_start)` so any per-day gap re-surfaces the account on the next worker boot. Per-Withings-sync cost: ≤30 DAY recomputes per 30-day catch-up, best-effort, never fails the user's sync. Tests 4 640 → 4 648. | [v1.4.39.1](https://github.com/MBombeck/HealthLog/releases/tag/v1.4.39.1) | | v1.4.39 | Mood + medication-compliance + cumulative-sum rollup tiers — extends the v1.4.38 fast-path posture to the two remaining cold-mount hot spots. New `mood_entry_rollups` (per-(user, granularity, bucket) mood stats) eliminates the unbounded `MoodEntry.findMany` walk on `/api/mood/analytics` (12.7 s → ~200 ms expected). New `medication_compliance_rollups` (per-(user, medication, day) scheduled / taken / skipped ledger anchored to user-tz `YYYY-MM-DD`) eliminates the unbounded intake-event walk on `/api/medications/intake?scope=compliance` (3.2 s → ~200 ms expected). New nullable `measurement_rollups.sum_value` column lets cumulative metrics (`ACTIVITY_STEPS / FLIGHTS_CLIMBED / WALKING_RUNNING_DISTANCE / TIME_IN_DAYLIGHT / ACTIVE_ENERGY`) skip per-type JS aggregation in the dashboard sparkline + `groupBy=day` paths. Defense-in-depth `since` cap on the `/api/analytics` live-fallback path (~347 k rows → ~5 k rows; 425 d window preserves `summary.avg30LastYear`). New WEEK / MONTH / YEAR reader helpers ready for the v1.5 multi-year trend card. Five wave agents in parallel + six QA reviewers + nine reconcile fixes (race-safe atomic upsert, partial-coverage probe, fire-and-forget worker enqueue, multi-entry-day summarize parity, DST-boundary test pin, user-scoped backfill enqueue, …). Tests 4 524 → 4 640 (+116). Squash to main clean. | [v1.4.39](https://github.com/MBombeck/HealthLog/releases/tag/v1.4.39) | diff --git a/.planning/round-v1440-QA-product-lead-findings.md b/.planning/round-v1440-QA-product-lead-findings.md new file mode 100644 index 000000000..17d39fd18 --- /dev/null +++ b/.planning/round-v1440-QA-product-lead-findings.md @@ -0,0 +1,357 @@ +# v1.4.40 QA — Product-Lead Review + +Reviewer: Product Lead (read-only, strategic). +Inputs: 11 wave reports (W-POOL, W-RSC, W-INSIGHTS, W-DELETED, +W-WMY-WIRE, W-GHOSTS, W-INFRA, W-PRIVACY, W-CONSENT, W-AASA, +W-APNS-NOTIFY), the v1.4.39 architecture-QA quadrology +(`round-v1439-arch-qa-{frontend,infra-db,ghosts,organization}.md`), +`round-v1439-empirical-trace.md`, `round-v1440-backlog.md`, +`git log c9d5479b..HEAD` (54 commits on `develop`). + +## Strategic posture + +v1.4.40 is the first **architecture-debt-close** release of the cycle: +the four parallel auditors that ran post-v1.4.39 surfaced a coherent +"sound-with-debt" picture (rollup tier landed; half the read surface +never migrated; per-chart fan-out blocks paint; soft-delete half-wired; +WMY write-only; dead-code accretion across five marathons), and Marc +explicitly directed "don't defer Critical+High to a later release." The +marathon delivered against that directive — every Critical from every +audit closed in-band, plus the v1.4.39.4 compliance-rollup hook gap +that surfaced mid-marathon, plus SB-3/4/5/6/7/10 from the iOS team's +server-backlog. The shape of the release is structurally different +from v1.4.36-39 (which were perf-tier shipments inside a stable +architecture): v1.4.40 is a **read-path retrofit + soft-delete +invariance + organizational umbrella** release, with the iOS-foundation +work (consent receipts, AASA, time-sensitive APNs, per-event status +ledger) landing as touch-disjoint side-cars rather than the main payload. + +The release deliberately stays inside additive-only contracts. Zero +breaking API changes; one new table (`ConsentReceipt` from W-SCHEMA +preflight); four new endpoints (3× consent, 1× notifications/status +extension); structural moves (`src/lib/rollups/` umbrella absorbs 7 +files across 3 directories, 68 import sites rewritten); perf fixes +(p-limit(4) on the 15-way analytics fan-out + `pg.Pool` max 10→20). +The "consume what we already wrote" theme runs through three waves: +W-INSIGHTS swaps three Insights routes onto the mood-rollup tier +(v1.4.39's writer-only landing), W-WMY-WIRE wires summaries-slice + +health-score onto the MONTH bucket (v1.4.34.4's writer-only landing), +W-DELETED makes 13 reader paths honour the `deletedAt` column iOS +v0.5.4 has been writing since 2026-05-11. Each is the kind of debt +that only an outside-perspective audit pass surfaces; collectively +they restore "what's written equals what's read" as an invariant. + +v1.5 readiness is **green from this release's perspective**. The iOS +team is genuinely unblocked: SB-3 (privacy policy DE+EN at `/privacy`) ++ SB-4 (AASA at `.well-known/apple-app-site-association`) + SB-5 +(time-sensitive APNs) + SB-6 (per-event status ledger) + SB-7 +(registration-status pinning) + SB-10 (consent receipts) all shipped. +The only iOS-side caveat is **AP-2**: SB-5's time-sensitive flag is +inert until the `.p8` APNs key is installed on production Coolify +env (`APNS_KEY_ID` / `APNS_TEAM_ID` / `APNS_BUNDLE_ID` / `APNS_KEY`). +SB-8 (dashboard tile-visibility migration) is investigation-only per +the audit decision, and SB-9 (streak deprecation) is optional — neither +gates App-Store submission. iOS v0.5.4 does not break against +v1.4.40: every shipped contract change is additive (new endpoints, +extended response envelopes with backward-compatible field additions). + +## Perf claim audit + +| Endpoint / surface | Claim | Evidence | Confidence | +|---|---|---|---| +| `/api/analytics` thick + 6× chart-tile burst | First-paint of chart row 7.3 s → ~1.6 s (empirical trace §F1) | p-limit(4) wrap on per-type Promise.all + Prisma `pg.Pool` max 10→20 + new analytics-route concurrency test asserting `peak ≤ 4` and timing bound `40ms ≤ elapsed < calls×20ms` | **Medium** — the test pins the structural cap, but the 7.3 s → 1.6 s wall-clock is an extrapolation from the trace's pool-stall hypothesis. Post-deploy verify against Marc's tenant required. | +| `/api/analytics` thick single cold | Absolute regression 10-15 % (4 batches of 15-way work) | Audit estimate; not measured | **Medium** — accepted trade-off because the 60 s LRU collapses warm mounts to a Map lookup; only the cold first-mount pays the penalty. | +| `/api/insights/targets` cold | ≤ 500 ms (was 12 s — unbounded mood walk + 347 k-row distinct sort) | rollup-fast-path + 365-day distinct floor + 4 route tests pinning rollup-vs-coverage-fallback shape | **Medium** — extrapolation from the `/api/mood/analytics` 12.7 s→200ms parity. The distinct-sort fix is the dominant share; mood-tier swap is the second-order win. | +| `/api/insights/comprehensive` cold | 90-day mood walk → rollup tier (≤ 90 row reads) | rollup + coverage-fallback parity tests | **High** — read-shape change with parity contract. | +| `/api/insights/generate` AI feature payload | Mood block bounded to ≤ 1 800 rows (was every entry ever) | features.test.ts extended with rollup mock + warm-up stub | **High** — payload shape change with test. | +| `/api/dashboard/summary` `avg30LastYear` field | Now populated via MONTH-tier read (was hardcoded `null`) | `computeAvg30LastYearMap` helper + 4 new tests; `slim_summaries.year_over_year_types` annotate visible in production wide-events | **High** — semantic-correctness fix (UI control was unreachable). | +| `/api/health-score` weight long-window | MONTH-tier read added alongside the 37-day DAY read; `healthScore.weightLongWindow` annotate | 3 long-window tests + parallel-read shape | **High** for correctness + annotate; **N/A** for wall-clock (additive read, not replacement). | +| Dashboard cold mount HTTP requests | 10-12 parallel → 9-11 (mood-chart queryKey dedup eliminates one) | `1dd1a9a7` re-keys mood-chart to `queryKeys.moodAnalytics()` + factory-bypass test guard | **High** — TanStack dedup is deterministic when keys match. | +| Soft-delete invariance | Tombstoned rows excluded from 13 reader tiers | 3 integration tests (analytics summaries / dashboard summary / rollup recompute) pin the contract; 8 atomic fix commits | **High** — every read pin uses a 99.0 sentinel that would have exploded `max` if the filter leaked. | +| Dashboard per-tile Suspense boundaries | Per-tile streaming, no row-blocking | 5 new structural tests pin the boundaries + memoized `userTimezone` + module-scope `DASHBOARD_QUERY_OPTS` | **High** for structural guarantee; **subjective UX** for visible win — needs prod observation. | +| Dead-code purge | -1 177 source lines across 9 atomic commits | knip CI gate now enforces `files,dependencies,binaries,unlisted`; 7 dead exports + 6 i18n keys + 2 orphan routes + 1 dead pg-boss queue + 2 dup tz helpers removed | **High** — line count + knip exit-0 are deterministic. | + +The pattern across the marathon is consistent: **structural pins (Suspense +boundaries, factory enforcement, concurrency caps, soft-delete filters, +queryKey dedup, rollup-tier read swaps) all have unit / integration test +coverage**, while wall-clock estimates ride the post-deploy +`round-v1440-perf-verify.md` window. This is the standard for the +"close the audit" releases (v1.4.34.4, v1.4.38, v1.4.39) — three of +those four had soft claims that the post-deploy verify caught. The +release notes should phrase chart-row first-paint as "expected ~1.6 s" +and Insights cold mounts as "expected ≤ 500 ms" until verified. + +## Release-readiness verdict + +- **Ship as v1.4.40: YES.** All eleven waves landed across 54 commits + on `develop`. Quality gates green per each report (54 / 54 typecheck + ok, 11 / 11 lint clean on touched files modulo the pre-existing + `app/page.tsx:577` which W-INFRA Thread 2 closed at commit + `8974e773`, and the consent route's tracked pre-existing warning). + Test delta +85 unit (`4 631 → 4 716` per W-INSIGHTS / W-RSC / + W-DELETED / W-WMY-WIRE / W-CONSENT / W-APNS-NOTIFY / W-PRIVACY / + W-AASA / W-POOL contributions; W-GHOSTS removed test files for + deletions). No new API contract breaks; one additive migration + (`ConsentReceipt` from W-SCHEMA); structural moves under + `src/lib/rollups/` keep public exports byte-equal (68 import sites + rewritten cleanly per W-INFRA verification). Rollback to v1.4.39.4 + is clean — every rollup-tier read carries a documented live + fallback; consent endpoints are append-only so revoke is reversible; + AASA / privacy / APNs payload changes are additive. + +- **Critical + High audit close — verified:** + - **Frontend Critical C1 (per-chart fan-out)** → W-POOL caps the + analytics fan-out at 4 concurrent + W-RSC streams chart tiles + individually via Suspense. Empirical-trace #1 (pool starvation) + closed. + - **Frontend Critical C2 (chart-data not in + `measurementDependentKeys`)** → W-RSC adds `chart-data` and + `dashboard-medication-compliance` to the bundle + 16-assertion + factory enforcement test. + - **Frontend High H1 (154 bare queryKeys)** → W-RSC migrates the + dashboard + chart subset + ships a factory-bypass test guard + over `src/components/charts`, `src/app/page.tsx`, + `src/hooks/use-auth.ts`. Long-tail 154-site migration deferred + to v1.4.41 (opt-in expansion of `guardedRoots`). + - **Frontend High H2 (no Suspense, no useSuspenseQuery)** → W-RSC + wraps every tile + chart row in `` boundaries; full + RSC migration of `app/page.tsx` deferred to v1.4.41 with + documented migration path (Suspense boundaries are the + prerequisite, now in place). + - **Frontend High H4 (`getHourForTimeZone` no memo)** → W-RSC + memoises on `userTimezone`; W-INFRA Thread 2 fixes the React + Compiler regression that resulted. + - **Infra+DB Critical #1 (WMY write-only)** → W-WMY-WIRE consumes + MONTH buckets in `summaries-slice` (avg30LastYear) + + `health-score-fast-path` (weight long-window), with + cross-consumer routing parity test pinning the 90/365/1 095 day + routing. + - **Infra+DB Critical #2 (6 unbounded mood walks)** → W-INSIGHTS + closed three (`insights/targets`, `insights/comprehensive`, + `lib/insights/features`); deferred three (`cards` — no mood + query; `glp1-timeline` — needs per-entry tags; `gamification/ + achievements` — needs Berlin-anchored day-key). The deferred + three are properly explained (tier-shape mismatches that the + v1.5 per-user-tz tier resolves), not skipped. + - **Infra+DB Critical #3 (`Measurement.deletedAt` half-wired)** → + W-DELETED filters 13 reader tiers (rollups recompute + 4 + summaries-slice queries + correlations + bp-fast-path + 3 + health-score + 8 comprehensive-aggregator + dashboard summary + + measurements list + series + Coach snapshot); 3 integration + tests pin the contract end-to-end. + - **Infra+DB Critical #4 (compliance-rollup hook gap on bulk + create)** → W-INSIGHTS wires `recompute` after + `medications/intake?scope=today` and `dashboard/summary` + `projectAndReadTodaysIntakes`. Set-coalescing prevents + fan-out storms. + - **Infra+DB Critical #5 (`/api/analytics` route deletedAt + leaks)** → W-INFRA Thread 1 picked up the three sites W-POOL + + W-DELETED both skipped (glucose context summaries, sleep-stage + breakdown, `fetchMeasurementSeriesChunked`). Closed. + - **Ghost-Hunter dead code** → W-GHOSTS removed 1 177 lines + across 9 atomic commits (TELEGRAM_CLEANUP_QUEUE; orphan + intake-summary route; duplicate monitoring/{umami,glitchtip}/test + routes; 7 dead exports; 18 dead i18n keys across 6 locales; + consolidated `startOfUtcDay` + `wallClockInTz`). Kept 3 false + positives with documented rationale (`detectAnomalies` + transitively used; `ensureUserMedicationComplianceFresh` — see + below; `readWeek/Month/YearRollups` wired by W-WMY-WIRE). + - **Org-audit Strategic Recommendation #1 (`src/lib/rollups/` + umbrella)** → W-INFRA Thread 3 moved 7 files across 3 + directories under `src/lib/rollups/`; 68 import sites rewritten; + typecheck + 93 rollup tests green; pure relocation, zero + behavioural change. + - **Org-audit Strategic Recommendation #2 (`src/types/` DTO + promotion)** + **#3 (prompt directory unification)** → + Deferred to v1.4.41 per Marc's directive in `W-INFRA`'s + "Items deferred" section (avoid clashing with in-flight + imports + needs its own wave). Documented carry-over. + +- **Carry-overs to v1.4.41:** + - W-INSIGHTS deferred swaps (`cards/route.ts` no-op; `glp1-timeline` + mood-tag aggregation; `gamification/achievements` Berlin-anchored + day-key) — gated on v1.5 per-user-tz mood tier. + - W-INSIGHTS SB-7 follow-up: `/api/auth/check-user` discovery + branches (the directive's mention of user-exists / passkey-only + / email-fallback maps to that route, not `/registration-status` + which W-INSIGHTS pinned). + - W-RSC RSC migration of `app/page.tsx` — Suspense scaffolding is + in place; the migration is now a one-pass composition swap. + - W-RSC custom ESLint `no-restricted-syntax` rule for queryKey + factory — test-based guard substitutes today; rule is the + long-term enforcement. + - W-RSC 154-site queryKey factory migration long tail (admin, + settings, medications, integrations directories) — opt-in + expansion of `guardedRoots`. + - W-INFRA Thread 3 follow-up: `src/types/` DTO promotion + prompt + directory unification. + - W-INFRA Thread 4 follow-up: enforce knip on `exports` + `types` + once the historic 487 unused exports / 52 unused types are + cleared incrementally; delete the 3 ignored barrel files + (`e2e/setup/test-helpers.ts`, `compliance-line-chart.tsx`, + `src/lib/logging/index.ts`) once their dedicated tests can also + be removed. + - W-CONSENT iOS Settings hook-up (iOS-side) + AI feature gate + read-side using `latestActiveReceipt(userId, kind)`. + - W-APNS-NOTIFY `NotificationDispatch` table for the other 6 event + types (only `MOOD_REMINDER` has a ledger today; the other six + return `null` until the table lands). + - W-WMY-WIRE: `computeLongWindowSummary` still has no production + consumer (the in-marathon wire-in fans out through internal + helpers, not the v1.4.39 public export). Production-call-site + on the export remains the v1.5 multi-year UI consumer's job. + - F-M-02 (rollup writers' dead `tx` parameter from v1.4.39 + backlog), F-M-03 (partial index `sum_value IS NULL`), F-M-05 + (`RollupBucketRow` shape consolidation), Specialist-M01 + (drop legacy-NULL UNION arm post-convergence), Simplifier + dead exports (`isCumulativeType`, `readCumulativeDaySums`, + `resolveBucketSum`, `ensureUserMedicationComplianceFresh`) — + rolled forward from v1.4.39 backlog; none of these gate iOS work. + - v1.5 architectural backlog (cross-tz per-user-tz bucketing, P5 + per-source rollup, P8 slope-window SQL move) — unchanged from + v1.4.38 closure. + +- **v1.5 readiness:** unblocked. + - SB-3 / SB-4 / SB-5 / SB-6 / SB-7 / SB-10 closed. + - SB-8 deferred as investigation-only per audit decision (not a + blocker). + - SB-9 optional (streak deprecation has no functional dependency). + - AP-2 caveat: SB-5's time-sensitive APNs flag is inert until the + `.p8` key is installed on production. **Release notes must + carry this verbatim** so the iOS team / Marc don't chase the + code when "medication reminders don't break through Focus" + surfaces post-deploy. + - iOS v0.5.4 compatibility verified: every shipped contract is + additive (new endpoints, backward-compatible response envelope + extensions). No new breaking endpoints. + - Coolify auto-deploy fix from v1.4.38.4 is still in effect; the + knip CI gate W-INFRA added runs on `push to main` + PRs targeting + `main`, so the gate does not interfere with develop velocity. + +## Risk register + +- **Wall-clock perf wins are audit estimates, not measurements** — + empirical-trace #1 (7.3 s pool stall → 1.6 s) is grounded in a + Playwright-driven local trace against a Marc-shape fixture (82 490 + measurements × 8 types) but not against the production tenant. + Mitigation: post-deploy `round-v1440-perf-verify.md` against Marc's + tenant; release-notes phrase wins as "expected" until verified; + rollback to v1.4.39.4 is clean (rollup reads have live fallback; + Suspense boundaries are additive; concurrency cap is per-request). + +- **Cross-agent commit-attribution drift — 4th recurring occurrence + (process bug)** — W-APNS-NOTIFY's SB-6 work landed inside W-DELETED's + `1bcaae47` dashboard-summary commit when the pre-commit hook + absorbed staged-but-not-yet-committed parallel-wave changes. + W-WMY-WIRE caught and reset a similar staging accident before push + (`git reset --soft HEAD~1` recovery). W-RSC's factory-routing + commit `8187d549` similarly drifted into the parallel APNS commit. + This is the 4th recurring occurrence (also flagged in + `project_v1437_final_web_release.md`, v1.4.38 marathon, v1.4.39 + product-lead review). **Mitigation for v1.4.41 marathon: per-agent + `git worktree` is now a hard rule, not a recommendation.** Marc-Voice + memory `feedback_marathon_worktree_isolation.md` does not exist yet; + recommend creating it with the directive "every parallel wave runs in + a dedicated worktree via `git worktree add`; no shared cwd across + agents." Add worktree-per-wave to the `release-marathon` skill's + standard flow. The release-notes / CHANGELOG sweep for v1.4.40 needs + to pull SB-6 details from `1bcaae47` even though the commit message + says `fix(dashboard-summary): exclude soft-deleted in sparkline and + streak queries` — the diff carries +67 lines on the notifications + status route + a new 181-line test file beyond what the title + suggests. + +- **Soft-delete contract — coverage gap on out-of-scope readers** — + W-DELETED explicitly punted on read-only export / admin / + doctor-report / gamification / reminder-worker / pr-detection-worker + / withings paths. Risk: a v1.5 export bug-report could include + tombstoned rows in a CSV; the doctor-report PDF could show a + tombstoned reading. Mitigation: documented in W-DELETED's "NOT + touched" section; schedule a v1.4.41 W-DELETED-2 sweep covering the + read-only surfaces. Low user-facing impact today (Marc has not + reported soft-delete usage), but iOS v0.5.4's by-external-ids sync + is already capable of writing `deletedAt`. + +- **APNS `.p8` env-var gap (AP-2)** — SB-5's time-sensitive + interruption-level is inert until the production Coolify env has + `APNS_KEY_ID` + `APNS_TEAM_ID` + `APNS_BUNDLE_ID` + one of + `APNS_KEY` / `APNS_KEY_FILE`. The dispatcher gates every send on + `loadApnsConfig()` returning non-null. Mitigation: release-notes + must carry the AP-2 dependency verbatim; the iOS team should not + attempt to verify the time-sensitive flag until the env is + installed. Recommend: pre-deploy Coolify env check as a release-day + gate. + +- **W-CONSENT iOS Settings hook-up — uncalled today** — the consent + endpoints (`POST /api/consent/ai`, `GET /api/consent/ai/latest`, + `DELETE /api/consent/ai/latest`) are tested + audit-logged + GDPR + Art. 7 compliant, but no client calls them yet. Risk: app-store + reviewer following the Privacy Policy may exercise the "AI + deaktivieren" toggle and find it doesn't actually revoke. Mitigation: + iOS Settings hook-up before App-Store submission; release-notes + flag the contract for the iOS team. Documented in W-CONSENT report + as "Notes for downstream waves". + +- **`ensureUserMedicationComplianceFresh` ambiguity** — W-GHOSTS held + this in the false-positives list per the W-INSIGHTS brief saying it + was being wired in parallel. W-INSIGHTS's report doesn't mention + wiring it — only the `recomputeMedicationComplianceForEvent` hook + path lands. Risk: dead-but-undeleted export survives in production + bundle. Mitigation: trivial follow-up grep in v1.4.41 — either drop + or wire. No functional impact. + +- **`computeLongWindowSummary` still uncalled** — v1.4.39 W-WMY's + public export, intended for a v1.5 multi-year UI consumer, still has + no production caller after W-WMY-WIRE's marathon (the wire-in fanned + out through internal helpers `computeAvg30LastYearForType` / + `computeAvg30LastYearMap` because the use case is per-type-with-data + not single-type). Risk: dead-tested code in production bundle. + Mitigation: noted in v1.4.39 backlog `F-M-01`; v1.5 "year-in-mood" + tile or Coach long-window prompt is the intended consumer; cheap to + retain. + +- **knip CI gate exclusion list** — W-INFRA Thread 4 ships knip with + `exports` + `types` deliberately excluded from the failure set + (487 unused exports + 52 unused types historic backlog would have + made the gate red on day one). Risk: a new dead export ships + silently because the gate doesn't enforce `exports`. Mitigation: + Enforce knip on `exports` + `types` once the historic backlog is + cleared incrementally — v1.4.41 / v1.4.42 should pick one module + slice per release for cleanup. + +- **Cross-tz proper-fix still v1.5** — the v1.4.38 cheap path + (runtime guard with live fallback for non-near-UTC) remains in + place; v1.4.39 mood + compliance rollups inherit the UTC-anchor + trade-off; v1.4.40 didn't touch this. Risk: non-Berlin tenants + silently fall through to live SQL for some tiers. Mitigation: + documented across the rollup-tier audits; v1.5 per-user-tz + bucketing closes the gap; Marc's tenant is fine. + +- **Semver call — patch bump (1.4.40) confirmed** — features added: + 1 new table (`ConsentReceipt`), 4 new endpoints (3× consent, 1× + notifications/status extended response), structural moves (umbrella + rollups directory, queryKey factory entries), perf fixes + (concurrency cap, pg.Pool max bump), 11 wave reports' worth of + audit-debt closure. Zero API contract breaks. Per Marc's + conservative-semver doctrine + (`feedback_semver_conservative.md` — bugfix-heavy releases with + additive-only features stay on patch), this is squarely additive + inside the existing minor cycle → **v1.4.40 is the correct call** + (v1.5 stays reserved for the iOS Swift sprint). + +- **Release-marathon retrospective items** — beyond the worktree + isolation directive above: + - Eleven-wave dispatch with touch-disjoint partitioning worked + cleanly (one collision caught + reset before push; one + pre-commit-hook fold absorbed across waves but no functional + loss). The "DO NOT touch" sections in each wave brief were the + load-bearing discipline. + - The W-INFRA Thread 1 pickup of W-POOL + W-DELETED gaps proves + the value of a sequential cleanup pass after the parallel + waves land. Recommend: every multi-wave marathon ends with a + `W-INFRA`-shape cleanup wave that re-walks the audit findings + against the merged state. + - knip CI gate (W-INFRA Thread 4) is the structural answer to + "another marathon, another 1 000+ lines of dead code". The + gate now enforces forward; the historic backlog needs a + cleanup cadence. diff --git a/.planning/round-v1440-QA-security-findings.md b/.planning/round-v1440-QA-security-findings.md new file mode 100644 index 000000000..e33c784db --- /dev/null +++ b/.planning/round-v1440-QA-security-findings.md @@ -0,0 +1,81 @@ +# v1.4.40 Security Review + +**Reviewer**: security (read-only) +**Range**: `v1.4.39.4..develop` (HEAD `a35e9b30`), 55 commits, 11 waves +**Scope**: new SQL surface (migration 0074 / `ConsentReceipt`), 4 new API routes (consent POST/GET/DELETE, notifications/status), AASA well-known, privacy page, APNs interruption-level, deletedAt full-wire, mood-rollup insights swap, knip CI + +## Critical + +_None._ + +## High + +_None._ + +## Medium + +### M-1 — Consent artefact cap is by character count, not bytes + +`src/lib/validations/consent.ts:40` enforces `.max(64 * 1024)` on the artefact string. Zod `.max()` on a string counts UTF-16 code units, not bytes. The doc-comment claims "64 KB" but the practical worst-case is up to ~256 KB on the wire if a client deliberately stuffs 4-byte UTF-8 code points (or surrogate pairs). The published contract is "base64 PDF or signed JWT" — both pure ASCII — so a well-behaved client never gets close, and Next.js' default ~1 MB request limit bounds the upstream parser regardless. Severity is Medium rather than High because the audit-table abuse vector is small and Next still caps body buffering, but the cap should use a byte-count helper (`Buffer.byteLength(artefact, "utf8") <= 64 * 1024`) before insert to honour the comment. + +- File: `src/lib/validations/consent.ts:33-40` + +## Low + +### L-1 — `safeJson` has no explicit body-size cap + +`src/lib/api-response.ts:48-61` calls `request.json()` without bounding the body. Next.js' default request limit (~1 MB on a Node runtime) is the only ceiling. Consent POST is documented as a few-KB-to-32-KB payload; an attacker can still buffer up to ~1 MB before the artefact's own `.max()` check fires. Combined with M-1, this is the layer that defines the real worst-case in-memory footprint. Acceptable for now; consider a `route.config` body-size override on consent + future write endpoints. + +### L-2 — APNs `category` echoed to the device equals the event-type + +`src/lib/notifications/senders/apns.ts:456` sets `category: payload.eventType`. The same string is already passed via `data.eventType`, so the category field does not add new disclosure — but the iOS app receives the full canonical category name (`MOOD_REMINDER`, `WITHINGS_SYNC_FAILED`, etc.) on every push. That's authenticated user → their own device, so it is not a tenant leak. Flagged Low only because the brief asked the field be reviewed; the gating itself is correct. + +## Item-by-item verdict (brief) + +1. **AASA payload** (`src/app/.well-known/apple-app-site-association/route.ts`): clean. Static constant containing the published Team ID + Bundle ID (`S8WDX4W5KX.dev.healthlog.app`), `applinks` matcher `["*"]`, `webcredentials` for passkey ceremony. No route enumeration, no host-specific data. **PASS**. + +2. **Privacy page** (`src/app/privacy/page.tsx`, 2356 lines added): no Marc-last-name + health-figure pair, no BD-Zielbereich values, no measurement counts. Public references to `mbombeck@gmail.com` + GitHub handle `MBombeck` + Hetzner are GDPR-disclosure norms ("controller contact"), not internal/PII per `feedback_no_pii_in_user_facing.md`. No Coolify/IPs/secrets. **PASS**. + +3. **Consent endpoints**: + - POST `src/app/api/consent/ai/route.ts`: `requireAuth()` first, Zod-validates body, `createReceipt(user.id, …)`. Audit-log fired on grant. + - GET/DELETE `src/app/api/consent/ai/latest/route.ts`: `requireAuth()` first, every `latestActiveReceipt` / `revokeLatest` / `latestActiveReceiptsByKind` call passes `user.id` from the session — no caller-supplied user-id parameter. Idempotent delete returns 200 (correct). + - `serialiseReceipt` strips `artefact` from the API response — only the opaque audit blob lives in the DB; never echoed back over the wire. **PASS** (modulo M-1). + +4. **Notifications status** (`src/app/api/notifications/status/route.ts`): `requireAuth()` first, both `prisma.notificationChannel.findMany({ where: { userId: user.id } })` and `loadEventStatuses(user.id)` (which scopes `moodReminderDispatch` by the same userId). The events map shape is per-user; no cross-tenant delivery counts. **PASS**. + +5. **APNs interruption-level** (`src/lib/notifications/senders/apns.ts:439`): `const isTimeSensitive = payload.eventType === "MEDICATION_REMINDER";` — strict equality on a single event-type. Only that branch sets `interruptionLevel: "time-sensitive"` + `priority: 10`. All other event-types stay on the iOS default `active` level. Focus modes (including Sleep) silence them as expected. Telegram `replyMarkup` leak protected by `IOS_METADATA_ALLOWLIST` (lines 528-535). **PASS**. + +6. **deletedAt full-wire**: every changed read path threads `deletedAt: null`: + - `src/app/api/measurements/route.ts` — GET list, GET aggregated, raw-SQL aggregator (added `AND m."deleted_at" IS NULL`). + - `src/app/api/measurements/series/route.ts` — BP-pair and single-kind branches. + - `src/app/api/dashboard/summary/route.ts` — raw 7-day sparkline SQL, groupBy(type), 365-day raw streak SQL. + - `src/app/api/analytics/route.ts` — `fetchMeasurementSeriesChunked`, glucose context, sleep-stage breakdown. + - `src/lib/insights/features.ts` (latest-by-type) — `deletedAt: null` added. + The dedicated `sync/state` route is documented as the sole consumer that intentionally surfaces tombstones (for iOS reconcile). **PASS**. + +7. **Insights mood-rollup swap** (`src/app/api/insights/comprehensive/route.ts`, `src/app/api/insights/targets/route.ts`): `ensureUserMoodRollupsFresh(userId)` + `readMoodDayRollups(userId, since)` both scope by userId. The internal raw-SQL writer (`src/lib/rollups/mood-rollups.ts:188-235`) uses parameterised `${userId}` throughout the `WITH aggregate … WHERE m."user_id" = ${userId}` upsert + delete pair. Cold-start fallback in `comprehensive/route.ts:128-152` retains `where: { userId, moodLoggedAt: { gte: ninetyDaysAgo } }`. **PASS**. + +8. **Migration 0074** (`prisma/migrations/0074_v1440_consent_receipts/migration.sql`): table `consent_receipts` with `user_id` FK → `users(id)` `ON DELETE CASCADE ON UPDATE CASCADE`. Wrapped in `IF NOT EXISTS` + `DO $$ BEGIN … EXCEPTION WHEN duplicate_object` guards (idempotent, mirrors 0067/0070/0071). Compound index `(user_id, created_at DESC)`. Cascade is correct — a user erasure under GDPR Art. 17 drops the corresponding consent receipts, completing the deletion. **PASS**. + +9. **knip CI** (`.github/workflows/knip.yml`): `permissions: contents: read` (least-privilege). No `secrets` references, no env exposure, no third-party uploader steps. `pnpm install --frozen-lockfile`, `pnpm db:generate`, `pnpm knip` — no remote artefact write. **PASS**. + +## Surface delta summary + +| Path | Risk | Verdict | +| --- | --- | --- | +| `prisma/migrations/0074_v1440_consent_receipts/migration.sql` | Schema (FK cascade) | PASS | +| `src/app/.well-known/apple-app-site-association/route.ts` | Public unauthenticated | PASS | +| `src/app/privacy/page.tsx` | Public unauthenticated | PASS (no PII) | +| `src/app/api/consent/ai/route.ts` | Authenticated write | PASS (M-1 below cap shape) | +| `src/app/api/consent/ai/latest/route.ts` | Authenticated read/delete | PASS | +| `src/app/api/notifications/status/route.ts` | Authenticated read | PASS | +| `src/lib/notifications/senders/apns.ts` | Outbound channel | PASS | +| `src/lib/rollups/mood-rollups.ts` | Internal SQL | PASS (param `${userId}` throughout) | +| `src/lib/insights/features.ts` + `comprehensive-aggregator.ts` | Internal read | PASS | +| `.github/workflows/knip.yml` | CI | PASS | + +## Brief-back (≤200 words) + +v1.4.40 ships a clean security surface. The new `ConsentReceipt` write path (`POST /api/consent/ai`) authenticates first, Zod-validates, stores opaque artefacts, and strips them from the response. The latest-receipt reader and revoke endpoints scope every query by the authenticated `user.id` — no caller-supplied user-id, no cross-tenant leak. Migration 0074 cascades on user delete, satisfying GDPR Art. 17. The AASA file ships only the published Team ID + Bundle ID. The privacy page contains no Marc-PII / no operator-internal info (Hetzner is the actual public hosting fact, GitHub handle + email are deliberate controller-contact disclosure). APNs interruption-level is correctly gated to `MEDICATION_REMINDER` via strict-equality on the event-type; the existing `IOS_METADATA_ALLOWLIST` keeps Telegram's `replyMarkup` from leaking into APNs userInfo. Every changed read path threads `deletedAt: null` (raw SQL and Prisma alike). Mood-rollup swap's raw SQL uses parameterised `${userId}`. knip CI runs with `contents: read` only, no secret access. + +One Medium (M-1): consent artefact cap is character-count, not byte-count — practical impact is small (artefacts are ASCII-only) but the comment promises bytes; ship-blocker NO, follow-up YES. Two Low items noted for future hardening. diff --git a/.planning/round-v1440-QA-senior-findings.md b/.planning/round-v1440-QA-senior-findings.md new file mode 100644 index 000000000..957d8eadf --- /dev/null +++ b/.planning/round-v1440-QA-senior-findings.md @@ -0,0 +1,299 @@ +# v1.4.40 Senior-dev review — findings + +Scope: `git diff v1.4.39.4..develop` (55 commits, ~8 100/2 265 lines, +140 files) across 11 waves (`W-POOL`, `W-DELETED`, `W-INFRA`, +`W-INSIGHTS`, `W-PRIVACY`, `W-AASA`, `W-APNS-NOTIFY`, `W-CONSENT`, +`W-RSC`, `W-WMY-WIRE`, `W-GHOSTS`). + +Review method: read all 11 wave reports + 5 v1.4.39 architecture-QA +anchors; spot-checked the highest-risk diffs (`api/analytics/route.ts`, +`lib/db.ts`, `lib/rollups/measurement-rollups.ts`, `app/page.tsx`, +`lib/notifications/senders/apns.ts`, `.well-known/apple-app-site-association/route.ts`); +swept every production `prisma.measurement.*` call site for `deletedAt: +null`; verified five random importer rewrites under the new +`@/lib/rollups/*` umbrella; ran `pnpm typecheck`, `pnpm lint`, `pnpm +knip`, the rollup test suite (93/93), the wave-focused subset (320/320 +across 8 + 32 files), and `pnpm test --run` (full unit suite). + +--- + +## Critical (release-blocking) + +### C1 — Full unit suite is RED on develop: 1 failing test (W-RSC ↔ W-INFRA Thread 2 drift) + +`pnpm test --run` reports **`1 failed | 4725 passed | 1 skipped`** at +HEAD `8e9a7891`. The failure is in +`src/app/__tests__/dashboard-suspense-boundaries.test.ts:78`: + +```js +expect(src).toMatch( + /const\s+hour\s*=\s*useMemo\([\s\S]*?\[\s*user\?\.timezone\s*\][\s\S]*?\);/, +); +``` + +W-RSC (commit `3cacfcf9`) wrote the dashboard memo as `useMemo(() => +…, [user?.timezone])`. W-INFRA Thread 2 (commit `8974e773`) had to +lift `const userTimezone = user?.timezone` to a local *before* the +memo to satisfy React Compiler's +`preserve-manual-memoization` rule (optional-chain expression +inferred a less specific property than declared). The post-fix code +at `src/app/page.tsx:577-580` reads `useMemo(…, [userTimezone])`, +which the regex (`user\?\.timezone`) cannot match. + +The W-RSC and W-INFRA reports both flagged "cross-agent commit-message +drift" as a recurring pattern; this is the same pattern producing an +actual test failure, not just a misattributed commit. Marc directive +explicitly requires `pnpm test` to be green at tag time. + +**Fix:** widen the regex to accept `[userTimezone]` as the dep array, +or update both reads to `userTimezone` and lift the local before the +memo so the canonical form is single-source. Either is a one-line +edit; the test was specifically written to be structurally +load-bearing, so updating it without re-introducing the assertion's +purpose is correct. + +**Severity rationale:** every release marathon to date enforces +"green CI" as a gate. Shipping with a red suite would breach Marc's +PII/CHANGELOG/release discipline and erode trust in the gate. + +--- + +## High (must-fix before tag) + +### H1 — Soft-delete leaks remain in the Insights cluster reader surface + +W-DELETED explicitly excluded `src/app/api/insights/**` and +`src/lib/insights/{glp1-plateau,pulse-status,features,…}.ts` with the +note that W-INSIGHTS was "actively modifying for the mood-rollup +swap" and would filter on re-touch. The W-INSIGHTS phase report does +not mention adding `deletedAt: null` to any of the seven +`prisma.measurement.findMany` sites in the cluster. + +Confirmed by spot-grep (production callers, excluding generated + +tests): + +| File | Line | Status | +| --- | --- | --- | +| `src/app/api/insights/targets/route.ts` | 181, 205, 1195 | **no `deletedAt: null`** | +| `src/app/api/insights/cards/route.ts` | 73 | **no `deletedAt: null`** | +| `src/app/api/insights/generate/route.ts` | 136 | **no `deletedAt: null`** | +| `src/lib/insights/features.ts` | 378 | **no `deletedAt: null`** | +| `src/lib/insights/glp1-plateau.ts` | 72 | **no `deletedAt: null`** | +| `src/lib/insights/pulse-status.ts` | 157 | **no `deletedAt: null`** | +| `src/lib/insights/comprehensive-aggregator.ts` | (raw SQL) | filtered | +| `src/lib/ai/coach/snapshot.ts` | 495-500 | filtered | + +These are user-facing surfaces (tile rendering, AI Coach prompts, +GLP-1 plateau alerts, pulse-status insight). Once iOS soft-deletes +its first reading, the tombstoned row still contributes to: + +- `/api/insights/targets` 30-day average + 1-year "latest ever" tile + values (the audit's Critical Finding #2 fix that landed for the + distinct-floor only). +- `/api/insights/cards` the 90-day mood/BP/weight/pulse window the + iOS-adapter card stream consumes. +- `/api/insights/generate` the AI feature payload (so the model + grounds against deleted data). +- The GLP-1 plateau detector + pulse-status helper that feed Coach + + the dashboard tile-strip. + +This is the same bug class the audit's Critical Finding #3 +documented; W-DELETED closed the dashboard + analytics + summaries ++ rollup tiers; the Insights cluster is the only family left +leaking. Either W-INSIGHTS missed the secondary directive or +W-DELETED's deferral note wasn't picked up. + +**Fix:** add `deletedAt: null` to all six `findMany` `where` clauses +above. Three of them already have an `m."deleted_at" IS NULL` sibling +in their adjacent rollup-tier branch (comprehensive-aggregator), so +the team-style pattern is locked in. One commit, one test (extend the +existing `tests/integration/measurement-soft-delete.test.ts`). + +**Severity rationale:** iOS sync ships in v1.5 P1 against locked +contracts. A tombstone leak in Insights would manifest the moment a +user soft-deletes their first reading from the iOS app and refreshes +the dashboard. The W-DELETED integration test pins three contracts; +extending to insights/targets is the cheapest closure of the gap. + +--- + +## Medium (should-fix; otherwise v1.4.41 backlog) + +### M1 — `Promise.all` over `recomputeMedicationComplianceForEvent` is correct, but the comment in the route is misleading + +`src/app/api/medications/intake/route.ts:177-186` + +`src/app/api/dashboard/summary/route.ts:362-376` both call +`Promise.all(recomputeJobs)` with no outer try-catch. + +The comment "best-effort: errors stay caught inside the helper" is +accurate — `recomputeMedicationComplianceForEvent` swallows every +error in its internal try-catch at line 261 and logs an annotate ++ console.error. The helper's signature returns `Promise` +that never rejects. So `Promise.all` is safe in practice. + +**Issue:** the contract is implicit — a future refactor that changes +the helper to re-throw (or a sibling helper added with the same shape +but different error handling) would silently break both bulk write +paths. The convention in `by-external-ids/route.ts:135-137` is the +opposite — an explicit `try { … } catch (err) { console.warn(…) }` +wrapper around the loop. + +**Fix:** either (a) mirror the by-external-ids wrapper pattern for +symmetry, or (b) add an inline comment pointing at line 261 of +`medication-compliance-rollups.ts` so the implicit contract is +discoverable. Cheap follow-up; not release-blocking. + +### M2 — Insights cluster's measurement findMany sites are also unbounded windows in places + +`src/lib/insights/features.ts:378-383` walks `measurement.findMany` +with a conditional `sinceCutoff` — when the caller passes no +`sinceDays`, the query is unbounded. The W-INSIGHTS report claims +the post-swap fallback is 1-year-bounded; the actual code still +admits an unbounded path. Same pattern as the audit High Finding 2 +that the wave was meant to close. + +`src/app/api/insights/generate/route.ts:136-140` likewise has no +`measuredAt` filter at all — it walks every measurement for the +type per AI generation call. + +`src/lib/insights/glp1-plateau.ts:72-80` is windowed (90-day +`PLATEAU_WINDOW_DAYS`). + +`src/lib/insights/pulse-status.ts:157-168` is bounded by `take: 365`. + +**Fix:** wave's stated 1-year floor needs to be enforced in +`features.ts` (else `extractMeasurementFeatures()` walks 5-year +history on every Coach prompt mint) and ideally in `generate/route` +(or document why generate intentionally walks the full history — +which may be load-bearing for trend math). Defer to v1.4.41 unless +Marc wants to fold into the H1 fix above. + +### M3 — Compliance-rollup recompute calls fire in serial within Promise.all but the helper itself runs serially against pg + +`recomputeMedicationComplianceForEvent` (line 245) does one +`prisma.medicationIntakeEvent.findMany` + one `prisma.medicationCompliance*.upsert` +per call. With 5 meds × 3 schedules × 1 day, the wave's `Set` +coalesce already reduces to N=5 (one per medication). N=5 parallel +upserts to pg is well within budget. No issue today; flag because +the comment in W-INSIGHTS' phase report claims "worst case +medications × 1" — true for the coalesce step, but the parallel pg +write fan-out is N, not 1, and shares the same `pg.Pool` that W-POOL +just raised to 20. The combined cold-mount fan-out (analytics × 4 + +compliance × 5 + slim/thick × 2 + tile-strip × 6) stays under the +ceiling. No fix needed. + +### M4 — Knip's `exports` + `types` baseline (487 + 52 historic) is deferred + +W-INFRA Thread 4 deliberately omits `exports` and `types` from the +knip failure set this release to avoid red CI on day one. The +ignored backlog is sizeable. The wave intent is "enforce +incrementally". Risk: a new dead export today sits in the noise +unnoticed for waves. Not release-blocking; the gate is additive over +the previous zero-coverage state. + +**Fix:** open a v1.4.41 wave that picks one slice (`src/lib/analytics`, +say) and turns on `exports` + `types` enforcement scoped to that +slice via `knip.json` per-workspace config. The current +`ignore`/`ignoreDependencies` pattern already proves the per-slice +opt-in approach works. + +--- + +## Low (cosmetic / future-proofing) + +### L1 — AASA route has no charset preservation test for non-ASCII operator names + +The AASA test pins `Content-Type: application/json` exactly (no +charset). The current response body is pure ASCII so there's no +encoding hazard. If a future maintainer adds umlauts (e.g. an +operator field carrying "Nürnberg"), the JSON response would still +serialise UTF-8 by default but the missing `charset` could trip a +DPA reviewer's parser. Out of band for AASA itself (Apple's +`swcd`/aasa-validator refuse `charset` annotations), but worth a +comment in the route file noting the deliberate omission. + +### L2 — Privacy policy `data-slot` markers good for tests but unwired in UI + +W-PRIVACY notes `data-slot="privacy-last-updated"` is "the canonical +hook for any in-app 'policy updated' surface (currently unwired — +would be a v1.5 nice-to-have)". Carrying a test contract for an +unwired UI hook is fine, but if v1.5 doesn't ship the consumer the +hook drifts. Schedule the consumer wire-up alongside the v1.5 iOS +sprint. + +### L3 — Cross-agent commit drift acknowledged in 3 wave reports + +W-APNS-NOTIFY, W-WMY-WIRE, W-RSC each flag commit-attribution drift. +The C1 finding above shows the pattern producing real test breakage, +not just message-level confusion. W-RSC's suggested mitigation +(`git worktree`-per-wave) is the right structural fix. Flag for the +v1.4.41 marathon kick-off directive — single-shared-cwd model leaves +the index race open whenever two parallel agents' `git add`s land in +the same second. + +### L4 — Knip whitelist carries 3 files for a future "delete with their test" pass + +`e2e/setup/test-helpers.ts`, `compliance-line-chart.tsx`, +`src/lib/logging/index.ts`. W-INFRA notes the deletion of these is +deferred pending verification that their dedicated tests are also +removable. Cheap follow-up. + +--- + +## Wave-by-wave verdict + +| Wave | Verdict | Notes | +| --- | --- | --- | +| W-POOL | Ship | `pLimit(4)` correctly preserves 60s LRU cache wrap (per-call instance, not module-level). `pg.Pool max=20` interaction safe (5 × 20 = 100 = Postgres `max_connections` default). | +| W-DELETED | Ship | Scoped to the audit's user-facing critical surfaces. The deferred export/admin/doctor-report/withings + iOS-sync paths are correctly out of scope. **But the W-INSIGHTS hand-off (insights/**) leaked — see H1.** | +| W-INFRA | Ship | Umbrella move clean (zero orphan re-exports — verified by grep on `@/lib/measurements/rollups` etc.). All 93 rollup tests pass post-restructure. Random 3-file importer spot-check (`insights/targets`, `mood-entries`, `insights/comprehensive`) all bind to `@/lib/rollups/mood-rollups`. Knip CI gate scoped sensibly. **Thread 2 broke W-RSC's test — see C1.** | +| W-INSIGHTS | Patch then ship | Mood-rollup swap on the three target routes lands cleanly + compliance-rollup hook gap closed with proper coalescing + SB-7 pinned. **But missed `deletedAt: null` on every prisma.measurement findMany in the cluster — see H1.** | +| W-PRIVACY | Ship | Bilingual paired-section pattern is the right call (legal documents must not depend on JS for locale switching). 17 tests cover the 9 SB-3 requirements + PII discipline. | +| W-AASA | Ship | Payload matches `S8WDX4W5KX.dev.healthlog.app` exactly. App-ID-parity assertion guards against split rotation. Content-Type discipline (no charset) honoured for Apple's swcd. | +| W-APNS-NOTIFY | Ship | Conditional `interruption-level: time-sensitive + priority 10` scoped to MEDICATION_REMINDER only. Parameterised test pins all 6 other event-types do NOT bypass Focus. Backwards-compat `events` map preserves channels shape. **AP-2 .p8 env-var caveat correctly flagged for release notes.** | +| W-CONSENT | Ship | Append-only invariant pinned in test; revoke-then-regrant leaves both rows; idempotent DELETE returns 200 + `receipt: null`. | +| W-RSC | Patch then ship | Per-tile Suspense boundaries genuinely stream independently (the parent gate is the slim/thick analytics merge, which is now correctly split per the v1.4.39.2 commit; the Suspense layer is structural-future-proofing for the v1.4.41 RSC migration). `mood-chart` queryKey dedup eliminates one round-trip. Factory-bypass guard test is a smart cheaper-than-ESLint move. **C1 affects this wave's test asset.** | +| W-WMY-WIRE | Ship | Three previously-dead WMY readers now have production traffic via `summaries-slice` + `health-score-fast-path`. Linear-composability parity test pins the cross-consumer routing contract. | +| W-GHOSTS | Ship | Net ~1 177 lines removed across 9 atomic commits. False positives correctly kept (`detectAnomalies` transitively reachable, the three WMY readers wired by sibling wave). `startOfUtcDay` + `wallClockInTz` consolidation eliminates a sibling-drift risk. | + +--- + +## Quality gates (run during this review) + +| Gate | Status | Notes | +| --- | --- | --- | +| `pnpm typecheck` | green | clean, no errors | +| `pnpm lint` | green | zero warnings, zero errors | +| `pnpm knip --include files,dependencies,binaries,unlisted` | green | zero output, exit 0 | +| `pnpm test --run src/lib/rollups/__tests__` | green | 93/93 passing | +| `pnpm test --run` (full unit) | **RED** | 1 failed / 4725 passed / 1 skipped — see C1 | +| `pnpm test --run` wave subsets | green | 320/320 across analytics, db, notifications, consent, well-known, privacy, insights, medications, dashboard, registration-status | + +--- + +## Brief-back (≤200 words) + +**v1.4.40 is one fix away from ship-ready.** The full unit suite is +red on `develop` because W-INFRA Thread 2 lifted `userTimezone` to a +local in `app/page.tsx` to satisfy the React Compiler memo rule, but +the W-RSC dashboard-suspense-boundaries test still pins the original +`[user?.timezone]` dep shape (C1). One-line regex update or one-line +code edit fixes it. + +The structural release is otherwise solid: 11 waves landed touch- +disjoint, the umbrella move under `@/lib/rollups/*` is clean with +zero orphan imports, AASA payload matches the iOS contract, the +APNs time-sensitive opt-in is correctly scoped to MEDICATION_REMINDER +only, the consent CRUD + append-only invariant pins SB-10, the +per-tile Suspense boundaries are structurally sound, and the +WMY-reader wire-in finally lights up the previously-dead MONTH +buckets. + +The one substantive scope gap is **H1** — the Insights cluster +(`/api/insights/targets`, `cards`, `generate` + the +`lib/insights/{features,glp1-plateau,pulse-status}` helpers) still +walks `prisma.measurement.findMany` without `deletedAt: null`, +because W-DELETED deferred to W-INSIGHTS and W-INSIGHTS missed the +secondary directive. Cheap to close (6 sites, one commit, one test). + +**Ship-readiness verdict:** patch C1 + H1, then tag. diff --git a/.planning/round-v1440-QA-simplifier-findings.md b/.planning/round-v1440-QA-simplifier-findings.md new file mode 100644 index 000000000..eb6d94e39 --- /dev/null +++ b/.planning/round-v1440-QA-simplifier-findings.md @@ -0,0 +1,53 @@ +# v1.4.40 QA — Simplification Findings + +Reviewed `git log --oneline v1.4.39.4..develop` (55 commits across 11 +waves). Focus on what the parallel waves missed: orphan stubs, naming +drift, dead exports still around, near-duplicate code, and bare-array +queryKeys that slipped past W-RSC's factory migration. + +No automated edits were applied — every candidate either touches +multiple wave outputs (deserves a cross-wave reconcile), intersects a +v1.5 contract anchor flagged in the wave reports, or is large enough +that Marc's voice in the commit message matters. + +## Suggested removals + +- `/Users/marc/Projects/HealthLog/src/lib/rollups/measurement-read-cumulative.ts:54` — `isCumulativeType` — exported, tested, still no production caller after the umbrella move. Carried forward unchanged from the v1.4.39 simplifier backlog. Confidence: certain. +- `/Users/marc/Projects/HealthLog/src/lib/rollups/measurement-read-cumulative.ts:83` — `readCumulativeDaySums` — exported + tested, no production caller. Same v1.4.39 carry-forward. Confidence: certain. +- `/Users/marc/Projects/HealthLog/src/lib/rollups/measurement-read-cumulative.ts:117` — `resolveBucketSum` — exported + tested, no production caller. The `dashboard/summary/route.ts:441` nested ternary still re-implements the same shape inline. Confidence: certain. +- `/Users/marc/Projects/HealthLog/src/lib/rollups/measurement-read-wmy.ts:125,137,149` — `readWeekRollups` / `readMonthRollups` / `readYearRollups` — only the wmy test file imports them; the production `readBestGranularityRollups` walker uses the internal `readGranularity`. Either downgrade to non-exported helpers or inline into the tests. Confidence: certain. +- `/Users/marc/Projects/HealthLog/src/lib/analytics/summaries-slice.ts:659` — `computeLongWindowSummary` — only the file's own test suite imports it. Flagged for v1.5 multi-year-card wiring in the v1.4.40 backlog (F-M-01); still ships as dead code in `develop`. Confidence: certain. +- `/Users/marc/Projects/HealthLog/src/lib/rollups/medication-compliance-rollups.ts:469` — `ensureUserMedicationComplianceFresh` — the boot-backfill cascade fully owns the cold-mount path; the "kept for symmetry" wrapper has no real caller. v1.4.39 carry-forward. Confidence: certain. +- `/Users/marc/Projects/HealthLog/src/lib/rollups/mood-rollups.ts:177` — `tx?: PrismaTxOrClient` parameter on `recomputeMoodRollupForEntry` is still unused by every call site (audited 19 references — none pass `tx`). Backlog item F-M-02 from v1.4.39 reconcile. Confidence: certain. +- `/Users/marc/Projects/HealthLog/src/lib/rollups/medication-compliance-rollups.ts:159,250` — `tx?: PrismaLike` parameter on `recomputeMedicationComplianceForEvent` + `recomputeUserMedicationCompliance` likewise dead at every call site. Confidence: certain. + +## Suggested dedups + +- `/Users/marc/Projects/HealthLog/src/app/api/medications/intake/route.ts:94-187` ↔ `/Users/marc/Projects/HealthLog/src/app/api/dashboard/summary/route.ts:295-387` — the entire "find active medications → `expandTodayIntakes` → diff against existing → `createMany skipDuplicates` → fan-out `recomputeMedicationComplianceForEvent` per distinct `(medication, dayKey)`" block is replicated near-verbatim across both routes (W-RSC's audit-L4 hook gap closure shipped the rollup recompute fan-out in both files but did not extract the shared helper). Both files even share the "v1.4.40 — close the compliance-rollup hook gap from v1.4.39.4" comment. Suggested action: extract `projectTodayIntakesAndRecompute(userId, userTz, todayStart, todayEnd)` into `src/lib/medications/scheduling/today-projection.ts` and have both routes call it. Drift here is a real failure mode — the v1.4.39 intake-route projection landed without a matching dashboard hook, which is the bug audit-L4 had to chase. +- `/Users/marc/Projects/HealthLog/src/components/charts/mood-chart.tsx:142` ↔ `/Users/marc/Projects/HealthLog/src/components/charts/health-chart.tsx:261` — `movingAverageByPoints` is byte-identical across the two chart components. W-GHOSTS removed the lib-level `movingAverage` (29bfcc67) because nothing consumed the windowed-by-points variant; both chart files carry their own private copy. Suggested action: extract a shared `src/lib/charts/moving-average.ts` with two functions (`byPoints` + the un-windowed reducer) so a future "switch from MA-7 to EMA-7" lands in one place. Pure helper, deterministic, trivial to unit-pin. +- `/Users/marc/Projects/HealthLog/src/components/charts/mood-chart.tsx:162` (`buildTrendLine`) — only used inside mood-chart today, but the health-chart `computeTrendLine` neighbour does the same linear-regression-over-day-offset math against the same date/value pair shape. Suggested action: lift `buildTrendLine` to `src/lib/charts/trend-line.ts` alongside the moving-average extract above; converge the health-chart variant in a follow-up so both consumers stay byte-stable. Confidence: medium — a quick side-by-side diff should confirm whether the two are line-for-line identical or merely shape-compatible. + +## Suggested simplifications + +- **queryKey factory carry-forward** — W-RSC migrated `app/page.tsx`, the three chart components, `use-auth`, and the insights pages but ~50 bare-array `queryKey: [...]` literals remain across `src/components/settings/*`, `src/app/notifications/page.tsx`, `src/app/auth/login/page.tsx`, `src/app/medications/page.tsx`, `src/components/medications/*`, `src/app/targets/page.tsx`, etc. The factory-bypass guard test (`src/lib/__tests__/query-keys.test.ts`) only walks `src/components/charts`, `src/app/page.tsx`, and `src/hooks/use-auth.ts`. Recommendation: in a follow-up minor (v1.4.40.1 or v1.4.41 backlog) widen the factory walker to all of `src/app` + `src/components` and either migrate the bare arrays or extend `queryKeys` with `notifications.preferences()`, `auth.login()`, `passkeys()` (already there), `settings.global()`, etc. The audit-H1 + audit-L4 class of bugs the v1.4.40 marathon chased was exactly factory drift — the work is half done. +- **Rollup directory naming** — the seven files in `src/lib/rollups/` split into two patterns: writers (`measurement-rollups`, `mood-rollups`, `medication-compliance-rollups`) and readers (`measurement-read`, `measurement-read-wmy`, `measurement-read-cumulative`, `measurement-coverage`). The mood + medication paths have no equivalent reader file because their read logic lives in the analytics routes. Suggested action: either rename the writers to `-write` (so the convention is `-`) or accept the asymmetry and add a one-line module comment to each writer explaining the read side lives in `app/api//...`. The current state confuses the "where do I add the next reader?" question. +- `/Users/marc/Projects/HealthLog/src/app/api/dashboard/summary/route.ts:441` — the cumulative-vs-spot `useSum ? (row.sum_value !== null ? Number(row.sum_value) : Number(row.mean) * Number(row.count)) : Number(row.mean)` nested ternary still lives where the v1.4.39 simplifier flagged it. CLAUDE.md forbids nested ternaries. Once the four `measurement-read-cumulative.ts` dead exports above are either wired or removed, this is the call site that should be reading `resolveBucketSum(row)`. +- `/Users/marc/Projects/HealthLog/src/lib/rollups/measurement-rollups.ts` boot-backfill discovery `UNION` over `r2."sum_value" IS NULL` — the legacy-NULL backfill arm is still live after the v1.4.39 single-tenant convergence. Backlog F-M-03 (v1.4.39 → v1.4.40 carry-forward) flags this for either a partial-index add or removal. Marc's tenant has converged; safe to schedule the arm for removal in v1.4.41 + add a TODO with the cut-off date. + +## Applied + +None. Every candidate above either: + +1. Intersects a test contract (`measurement-read-cumulative.test.ts`, `measurement-read-wmy.test.ts`, `summaries-slice.test.ts`) that should be migrated in lockstep so the deletion lands green CI without a separate "drop dead test" commit. +2. Pairs with a v1.5 wiring intent Marc signed off on in the wave reports (computeLongWindowSummary → multi-year-card, cumulative readers → analytics-route batch path). +3. Is a cross-file dedup (the medication today-projection block, the moving-average extract) whose target module needs Marc's naming input before the move. + +Listing as suggestions keeps Marc's voice in the eventual commit messages and lets the reconcile decide which carry-forwards land in v1.4.40 vs the v1.4.41 backlog. + +## Cross-check against marathon scope + +- **W-INFRA umbrella move** — clean. `grep` for old `lib/measurements/rollup` / `lib/mood/rollups` / `lib/medications/compliance-rollups` returns zero callers; no orphan re-export stubs. Tests moved with the source files. +- **W-GHOSTS purge** — `movingAverage`, `weeklyAverages`, `mmolToMgdl`, `berlinDayKey`, `typesMissingCoverage`, `readCumulativeDaySumsBatch`, `TELEGRAM_CLEANUP_QUEUE`, `/api/medications/intake-summary`, `/api/monitoring/{umami,glitchtip}/test` all confirmed gone from `develop`. Prisma schema has no orphan indexes pointing at the deleted routes. +- **CHANGELOG.md** — single reference to "v1.4.40" is a forward-reference at line 185 ("v1.4.40 backlog"); no premature `## [1.4.40]` heading. Release endgame can land it cleanly. +- **Soft-delete test consolidation** — `tests/integration/measurement-soft-delete.test.ts` (310 LOC, W-DELETED) is the single coordinating test; the per-tier `analytics`, `dashboard`, `rollup` assertions live inside one file. No fragmentation found. +- **`prisma/migrations/0074_v1440_consent_receipts`** — additive only, idempotent guards mirror 0067/0070/0071, includes the FK + index. Clean. diff --git a/.planning/round-v1440-QA-specialist-findings.md b/.planning/round-v1440-QA-specialist-findings.md new file mode 100644 index 000000000..9e4d8ee16 --- /dev/null +++ b/.planning/round-v1440-QA-specialist-findings.md @@ -0,0 +1,181 @@ +# Round v1.4.40 — Prisma + perf specialist findings + +Read-only review. Scope: W-POOL, W-DELETED, W-INFRA (threads 1/3/4), +W-INSIGHTS (compliance recompute + mood-rollup swap), W-WMY-WIRE, +W-CONSENT. Anchored to `.planning/round-v1439-empirical-trace.md`. + +--- + +## CRITICAL (none) + +No release-blocking perf or data-integrity defect found. + +--- + +## HIGH + +### H-1 — Bulk recompute hook treats failures as fire-and-forget without surfacing +`src/app/api/medications/intake/route.ts:171-186` and the mirror in +`src/app/api/dashboard/summary/route.ts` `Promise.all(recomputeJobs)` +the recompute calls **without** a catch wrapper. The W-INSIGHTS report +states "errors stay caught inside the helper" but if +`recomputeMedicationComplianceForEvent` ever throws (DB blip, missing +medication row), the `await Promise.all` rejects → the intake POST +returns 5xx **after** the projection `createMany` already committed. +This is asymmetric with the surrounding `auditLog(...).catch(() => {})` +pattern. v1.4.41 candidate: wrap each push in `.catch(err => logger…)` +or wrap the `Promise.all` in `.catch(() => {})` to preserve the +"best-effort" contract the comment promises. Worst-case today is a +recoverable transient blip surfacing as a request failure, not data +loss. + +--- + +## MEDIUM + +### M-1 — `deletedAt: null` plan validation — partial index deferred +Every `measurement.findMany` post-W-DELETED now carries +`(user_id, type, measuredAt range, deletedAt IS NULL)`. The existing +`@@index([userId, type, measuredAt])` (prisma/schema.prisma:501) still +serves these queries; the `deleted_at` filter is applied post-index +as a heap-level discard. With ~0 tombstones on Marc's tenant today +the discard cost is nil. **No partial index needed before iOS sync +emits its first tombstones**. Once iOS Apple-Health soft-delete reaches +1–5 % of rows the trade-off shifts; v1.4.41+ candidate: add +`CREATE INDEX measurements_live ON measurements(user_id, type, measured_at) + WHERE deleted_at IS NULL`. Defer until row-count justifies. + +### M-2 — Pool ceiling capacity vs cluster topology +`max=20` per `pg.Pool` × N Node processes. Next.js standalone (Coolify +default) = 1 process per container, so single container = 20 slots +against Postgres' stock `max_connections=100`. Five concurrent +power-users worst-case = 100 = the wall. The brief mentioned dialling +down via `DATABASE_POOL_MAX`; production env should explicitly set this +to 20 (not rely on the default) once container count climbs above 4 or +PgBouncer/transaction-pooling lands. Document in deploy notes. + +### M-3 — Slope from MONTH-bucket means is linear-composable but NOT +identical to slope from DAY samples (W-WMY-WIRE) +`avg30LastYear` via `readBestGranularityRollups(type, 395)` reads +`[now-395d, now-365d)` MONTH buckets and count-weighted-averages them. +For mean, this is mathematically equal to mean over the underlying DAY +rows ✓. The health-score `weightLongWindow` is documented as +**annotate-only** (not consumer-visible math) — that's the right call +because a regression slope over monthly means differs from a slope over +daily samples even with linear composability (the means lose intra-month +variance, regression slope tightens). Wave-report claim of +"linear-composability holds" is correct for **mean**; would be misleading +if a future wave wires MONTH-tier means into a slope/r² computation. +Flag for v1.5 Coach drawer integration when slope90 actually consumes a +WMY tier. + +--- + +## LOW + +### L-1 — p-limit error semantics verified +`pLimit(4)` (p-limit v7.3.0) propagates the wrapped promise's +rejection through to the caller, so `Promise.all(types.map(t => +typeFetchLimit(() => …)))` still rejects on first failure. Existing +error path preserved. ✓ + +### L-2 — Empirical 7.3s → 1.6s claim +Plausible. Pre-fix Wave-C blocked at thick-analytics-release (~7.3s); +post-fix the chart tiles unblock from pool-slot availability (max=20) +rather than analytics drain, so first-paint ≈ Wave-B settle (1.1s) + +tile RTT (~150ms × bounded queuing) → 1.6s. Both p-limit AND max=20 +contribute; either alone would unblock Wave-C, the pair is +belt-and-braces. The thick-analytics ~10-15% wall-clock penalty math +checks out: 15-way fan-out across 4 lanes = 4 batches; if all types +were equal-cost it would be 4x slower, but the slowest type dominates +each batch — so the penalty is closer to (slowest_4 / slowest_1) ≈ 1.1-1.2x. + +### L-3 — Bulk compliance recompute volume +After `createMany(missing)` the Set-coalesced +`recomputeMedicationComplianceForEvent` calls fire one per +`(medicationId, dayKey)` tuple. Worst case for Marc's tenant ≈ +medications × 1 day = ~5-10 round-trips, each walking a +bounded ~30-day intake-event window. Safe. ✓ + +### L-4 — Mood-rollup tier swap row-bound check +`src/app/api/insights/targets/route.ts` + `comprehensive/route.ts` + +`lib/insights/features.ts` — verified each new path consumes +`readMoodDayRollups` (bounded `count(*) ≤ days_in_window`) instead of +unbounded `moodEntry.findMany`. Coverage-fallback bounds added: 30d on +`targets`, 90d on `comprehensive`, 1y on `features.ts`. All bounded. +Worst-case row count drop from unbounded → ≤365. ✓ + +### L-5 — `findMany distinct` floor (W-INSIGHTS, targets route) +365-day floor on the previously-unbounded `findMany({distinct})` — +justified in the wave-report (slow-moving metrics like BODY_FAT need a +year window for the "current" tile). Bounded scan + uses the existing +`(user_id, type, measured_at)` index. ✓ + +### L-6 — ConsentReceipt index alignment +`@@index([userId, createdAt(sort: Desc)])` matches the +`latestActiveReceipt(userId, kind)` query exactly: index scan by +userId, walk descending, filter on `revokedAt IS NULL` + `kind=…` post- +fetch. Append-only invariant pinned in the test suite. ✓ + +### L-7 — 64KB artefact cap +`ARTEFACT_MAX_BYTES = 64 * 1024` in `src/lib/validations/consent.ts:33`. +Reasonable for signed PDF or JWT; protects against pathological body +sizes. ✓ + +### L-8 — Rollup umbrella move is pure relocation +Spot-checked 3 importer rewrites +(`@/lib/measurements/rollups` → `@/lib/rollups/measurement-rollups`): +analytics route, insights features, mood-chart consumer. No call-shape +changes, no public-API drift. 58 sites consume the umbrella; zero +remaining references to the old paths. ✓ + +### L-9 — Knip CI trigger +`.github/workflows/knip.yml` triggers on `push: branches=[main]` and +`pull_request: branches=[main]`. **Does NOT run on PRs targeting +`develop`**. Marc's branch model is develop → main releases-only; +contributors developing on feature branches against develop won't see +the gate until the release PR lands. Acceptable for the current solo-dev +workflow, but if/when external contribution opens up, extend the +`pull_request.branches` to include `develop` so dead-code is caught +before merge to the long-lived branch. The `unlisted` + `binaries` + +`files` + `dependencies` gates are appropriate; deferring `exports` + +`types` is the right call given the 487/52 baseline. + +--- + +## INFORMATIONAL + +### I-1 — Compliance-rollup recompute bound on Marc-sized tenants +The `recomputeUserMedicationCompliance(userId, days)` helper walks the +trailing `medicationIntakeEvent` window. On Marc's tenant (~30-90 +rows/day × bounded day window) this is cheap; the Set-coalesced fan-out +in W-INSIGHTS keeps it that way. Safe as the directive predicted. ✓ + +### I-2 — DST drift carry-over from rollup-tier swap +W-INSIGHTS report flags the UTC `bucketStart` vs Berlin-anchored +`MoodEntry.date` divergence on DST nights. The bound is one calendar +day for Berlin tenants on fall-back. v1.5 per-user-tz bucket migration +closes the gap. Inherited from v1.4.38; not introduced this wave. + +### I-3 — Cross-wave commit drift +Two waves (W-POOL, W-APNS-NOTIFY, W-RSC, W-WMY-WIRE) report +cross-agent commit-message drift — same pattern memorised from v1.4.37. +The functional diffs are intact (verified via spot-check `git show`), +but release-notes / CHANGELOG generation needs to walk **diff bodies**, +not commit subjects, this release. Out of perf scope; flagging because +it impacts what an auditor sees in git history. + +--- + +## Verdict + +W-POOL fix is correct, empirical claim is plausible, error semantics +preserved. W-DELETED full-wire is comprehensive and index-aligned +(partial index deferral is the right call). W-INFRA threads 1/3/4 are +clean. W-INSIGHTS row-bounds verified. W-WMY-WIRE is annotate-only on +the slope path, which sidesteps the variance-loss concern. W-CONSENT +shape + index aligned with the "latest receipt" query. Ship. + +One v1.4.41 follow-up: wrap recompute Promise.all in a catch (H-1). +Two long-tail follow-ups: partial index when tombstones materialise +(M-1), extend knip CI trigger to develop branch (L-9). diff --git a/.planning/round-v1440-QA-ux-findings.md b/.planning/round-v1440-QA-ux-findings.md new file mode 100644 index 000000000..1e4186448 --- /dev/null +++ b/.planning/round-v1440-QA-ux-findings.md @@ -0,0 +1,321 @@ +# v1.4.40 — UX / response-shape QA findings + +**Reviewer scope:** UX + byte-shape verification across W-RSC, +W-INSIGHTS, W-PRIVACY, W-AASA, W-APNS-NOTIFY, W-CONSENT. +**Range:** `v1.4.39.4` → develop HEAD (`8e9a7891`), 55 commits. +**Mode:** read-only. + +--- + +## Severity legend + +| Band | Meaning | +| ---- | ------- | +| **BLOCKER** | Ships a regression iOS / clients can observe — must fix before release. | +| **HIGH** | UX or shape concern that will surface as a paper-cut within a week. | +| **MEDIUM** | Cosmetic / hygiene; track for v1.4.41 unless cheap to fold in. | +| **LOW** | Note for the record; no action expected this cycle. | +| **PASS** | Verified clean. + +--- + +## 1. Per-tile Suspense boundaries (W-RSC) + +**Band: PASS — with two MEDIUM observations.** + +- `src/app/page.tsx:1455` wraps every tile cell in + `` and `:1486` wraps every chart cell in + `}>`. Each cell sits inside its + own `
` wrapper so the boundary is a true + per-cell island, not a shared row gate. Verified against + `src/app/__tests__/dashboard-suspense-boundaries.test.ts`. +- The chart-row fallback uses ``, which is the same + primitive the `next/dynamic({ loading: … })` contract on the three + chart components already paints during JS chunk resolution — so + the fallback layout matches the eventual real chart layout. No CLS + expected. + +- **MEDIUM — tile-strip fallback is `null`, not a tile-shaped + skeleton.** Today the tile body is synchronous (every TrendCard + reads from the merged `data` memo gated on the slim + thick + analytics queries on the parent), so the `null` never paints. But + the moment a future RSC hoist makes any TrendCard suspend, the + user will see a 0-px hole inside the grid track until the cell + resolves, which on a wrapped Pixel 5 layout will collapse the row + height mid-paint (CLS risk). The wave's own report flags this as + "structural no-op today" — keep an eye on it the moment one tile + becomes async. +- **MEDIUM — dashboard still relies on `useMemo` over `slim + thick` + parallel queries (page.tsx:255–285) rather than independent + ``-driven streaming.** The per-tile boundary is + defensive scaffolding; the actual progressive paint Marc wanted + ("tile-strip first, BD-Zielbereich + glucose tiles stream after") + is still implemented via two `useQuery` calls + an in-component + `useMemo` merge that is **all-or-nothing on slim**: if slim's + `summaries` resolves with content the tile strip paints + immediately, but BD-Zielbereich + glucose remain `undefined` + until the thick query lands. That matches the v1.4.39.2 contract + and is fine for v1.4.40, but **the W-RSC report itself recommends + the RSC migration for v1.4.41** — confirm that lands on the + v1.4.41 backlog. Per-tile Suspense is the prerequisite, not the + end state. + +--- + +## 2. queryKey factory enforcement (W-RSC) + +**Band: HIGH — one factory-bypass leak that the test guard does NOT +catch.** + +- `src/components/comparison/compare-toggle.tsx:29,55,75` reads / + invalidates **`["user", "dashboardWidgets"]` as a bare literal**. + Same shape as `queryKeys.dashboardWidgets()` so the cache actually + dedups today (good), but the wave's own factory-bypass guard + in `src/lib/__tests__/query-keys.test.ts:173–177` only walks + `src/components/charts`, `src/app/page.tsx`, `src/hooks/use-auth.ts`. + A rename of `dashboardWidgets` to anything else would silently + break the comparison toggle's read of the saved baseline. +- **Recommendation:** extend `guardedRoots` to include + `src/components/comparison` (cheap, 1-line PR) **or** migrate the + three sites to `queryKeys.dashboardWidgets()`. This sits squarely + in scope for the wave's "no other endpoint is double-queried with + different keys" verification. + +- **PASS** — every other surface the wave touched routes through the + factory: `app/page.tsx` (moodAnalytics, dashboardWidgets), + `components/charts/mood-chart.tsx` (moodAnalytics), + `components/charts/health-chart.tsx` (chartData), and + `components/charts/medication-compliance-chart.tsx` + (dashboardMedicationCompliance). +- **PASS** — searched `src` for `"mood-chart-data"` — only matches + are doc/comment references in `src/components/charts/mood-chart.tsx:319` + and `src/lib/query-keys.ts:141,146`. The actual queryKey is + `queryKeys.moodAnalytics()`. The cold-mount dashboard + chart no + longer fire two requests against `/api/mood/analytics`. + +- **LOW** — 154-site long tail outside the guarded scope (settings, + admin, integrations, medications pages, notifications, etc.) + still uses bare-literal keys. Wave-deferred to v1.4.41 explicitly. + +--- + +## 3. Six insights route swaps (W-INSIGHTS) + +**Band: PASS — three landed, three explicitly deferred with rationale.** + +The directive listed six routes; only three of the six are actual +mood-walk sites that the rollup tier can replace. + +### 3a. `/api/insights/targets` — **PASS**, byte-shape preserved + +Response envelope at `src/app/api/insights/targets/route.ts:1297–1313`: +- `{ targets, pageSummary, bpDiastolic, profile }` — **identical to + v1.4.39.4**. +- `targets[].type === "MOOD_SCORE" | "MOOD_STABILITY"` still surfaces + the same `current` / `average30` / `consistency7d` / + `streakDays` / `insufficientData` keys. +- The rollup-tier branch + coverage-fallback branch + cold-start + branch all produce the same `ConsistencyOutput` shape. +- Test file `src/app/api/insights/targets/__tests__/route.test.ts` + (new in `f8de4b05`) pins rollup-fast-path, coverage-fallback, + no-mood, and the 365-day `distinct` floor. + + - **DST drift note (documented):** rollup `bucketStart` is + UTC-anchored; legacy `MoodEntry.date` is TZ-anchored. For Berlin + tenants whose mood timestamps don't straddle UTC boundary the + two day-keys agree; DST fall-back nights diverge by one calendar + day. Pinned in the existing `/api/mood/analytics` route-parity + test. v1.5 per-user-tz bucketing closes the gap. Accept. + +### 3b. `/api/insights/comprehensive` — **PASS**, byte-shape preserved + +Response envelope (`route.ts:415–438`): 22 top-level keys +(`summaries`, `bmi`, `bmiClassification`, `bpClassification`, +`bpPctInTarget`, `bpTargets`, `weightBpCorrelation`, `scatterData`, +`bpMedicationCorrelation`, `bpMedicationScatterData`, `moodSummary`, +`moodBpCorrelation`, `moodBpScatterData`, `moodWeightCorrelation`, +`moodWeightScatterData`, `moodPulseCorrelation`, `moodPulseScatterData`, +`medications`, `alerts`, `hasProvider`, `dataSpanDays`, +`totalMeasurements`) — **all unchanged**. +- The `moodEntries: moodEntryCount` rename lives **only inside the + `annotate({ meta: {…} })` audit-log block** (route.ts:410), not in + the response envelope. iOS / web consumers see no change. + +### 3c. `/api/insights/generate` (via `lib/insights/features.ts`) — **PASS** + +- The downstream prompt builder (`buildUserPrompt`) consumes the + same `AggregatedFeatures.mood` block (`avg7`, `avg30`, `latest`, + `trend30`, `totalEntries`, `coverage`). The rollup tier produces + the same shape; the v1.4.39 `/api/mood/analytics` already shipped + the rollup-tier semantic so this swap inherits the same parity + envelope. + +### 3d. Three routes explicitly deferred — **PASS as deferred** + +- `/api/insights/cards/route.ts` — no mood query (iOS adapter that + shares alert-rule input shape). +- `/api/insights/glp1-timeline/route.ts` — reads per-entry + `tags: string[]`, which the rollup tier doesn't carry. Already + 90-day bounded. +- `/api/gamification/achievements/route.ts` — needs per-entry + Berlin-anchored `date` key, which the rollup's UTC `bucketStart` + shifts on DST fall-back nights. Bounded by a 2026-02-20 anchor so + worst-case row count is manageable. + +Deferral rationale documented in `phase-W-INSIGHTS-v1440-report.md` +§"Rolled-up scope decisions" and accepted. + +--- + +## 4. Privacy page (W-PRIVACY) + +**Band: PASS — readable on Pixel 5 viewport, operator email +appropriate.** + +- `src/app/privacy/page.tsx` — bilingual paired-section layout + inside a `
` shell. **`max-w-3xl` (768 px) is intentional + per `IW9` comment** (line 198) — legal/long-form columns read + better at 70-80 chars per line. On a 393 px Pixel 5 viewport that + caps line length at the container width (≈ 393 − 32 = 361 px) so + text wraps to a comfortable ~50-60 chars per line. +- Typography: H2 sits at `text-xl md:text-2xl`, body at `text-sm + md:text-base leading-relaxed` — matches the rest of the app's + prose tokens. The Deutsch heading sits as an `uppercase tracking- + wider` eyebrow above each block, English collapsed under a + `
` with a rotating disclosure indicator. Skim-readable. +- **PASS — Operator email** `mailto:mbombeck@gmail.com` at section 11 + is appropriate for a single-operator self-hosted install per + feedback memo `feedback_no_pii_in_user_facing.md`: the email **is** + the contact channel, not a functional alias. The DPA-recommended + postal-address-on-request pattern is explicit in the body. A + function alias (`privacy@…`) would imply a team that doesn't + exist. +- Test file `src/app/privacy/__tests__/page.test.tsx` (17 tests) + pins the bilingual title, 11 paired sections, HK data-flow path, + AI-off-by-default + named providers, consent endpoint + 5-year + retention literal, TLS + HSTS, deletion route + cascade. PII rule + still enforced. + +- **LOW** — collapsed-English `
` means an Apple US reviewer + has to click 11 disclosure triangles to read the English policy + end-to-end. The W-PRIVACY rationale was explicit ("German body + first, English under a labelled `
`"); flagging as a + reviewability cost without recommending a change. + +--- + +## 5. AASA file (W-AASA) + +**Band: PASS.** + +- `src/app/.well-known/apple-app-site-association/route.ts:43–49` + responds with **`Content-Type: application/json`** (no `charset=` + parameter — Apple's `swcd` / `aasa-validator` silently refuse + anything annotated with charset). Verified against the test + `src/__tests__/api/well-known.test.ts` which pins the exact + string. +- **`Cache-Control: public, max-age=3600`** — one-hour TTL pairs + cleanly with Apple's CDN mirror at + `app-site-association.cdn-apple.com/a/v1/`. Reasonable. +- Payload shape matches SB-4: `applinks.details[0].appID` and + `webcredentials.apps[0]` share the same `S8WDX4W5KX.dev.healthlog.app` + prefix via local constant. Test pins that the two App IDs cannot + drift. + +--- + +## 6. Notifications status endpoint (W-APNS-NOTIFY SB-6) + +**Band: PASS, with one HIGH iOS-coordination flag.** + +- `src/app/api/notifications/status/route.ts:73–91` initialises the + `events` map with `Object.fromEntries(EVENT_TYPES.map(...))` — so + **every known category is always present** in the response (with + `{ lastDeliveredAt: null }` when no ledger source has data). iOS + can iterate the keyspace without conditional plumbing. ✓ +- `MOOD_REMINDER.lastDeliveredAt` reads from `MoodReminderDispatch` + ledger; every other category currently returns `null`. +- Test file `src/app/api/notifications/status/__tests__/route.test.ts` + pins the four required behaviours: 401 unauth, every-known-event- + type-present, MOOD_REMINDER populates from latest dispatch row + + scoped to caller, `channels` shape preserved. + +- **HIGH (informational only) — only `MOOD_REMINDER` has a per-event + ledger.** Per the W-APNS-NOTIFY report: `MEDICATION_REMINDER`, + `MEASUREMENT_ANOMALY`, `COMPLIANCE_LOW`, `WITHINGS_SYNC_FAILED`, + `SYSTEM_ALERT`, `PERSONAL_RECORD` return `null` until a future + `NotificationDispatch` table lands. **iOS v0.5.4 will render + "Never" rows for every category except MOOD_REMINDER even on + active accounts**. Coordinate with iOS so the empty-state copy + reflects this — or accept that "Never" is the right UI for the + v1.4.40 window. Not a blocker; iOS contract is shape-stable. + +--- + +## 7. Consent endpoint (W-CONSENT) + +**Band: PASS.** + +- Response envelope **`{ data, error, meta }`** is enforced + uniformly: `POST /api/consent/ai` returns + `apiSuccess({ id, receipt })` (route.ts:46), `GET .../latest` + returns `apiSuccess({ kind, receipt })` or + `apiSuccess({ ai_full, ai_insights_only, ai_coach })` for the + full keyspace, `DELETE .../latest` returns `apiSuccess({ kind, + receipt })` or `apiSuccess({ revoked })` for the master sweep. + All shapes consistent with the project's `apiSuccess`/`apiError` + envelope convention. +- **POST response strips `artefact`** — `serialiseReceipt` + (route.ts:68–84) explicitly omits the up-to-64-KB opaque token + from the wire response. Test pins `expect(body.data.receipt).not + .toHaveProperty("artefact")` (route.test.ts:159). ✓ +- 64 KB artefact cap enforced in `consentPostBody` Zod schema; test + pins the 400 response when exceeded. +- **Append-only invariant** test in the latest-route spec asserts + `prisma.consentReceipt.delete` is never called — revoke flips + `revokedAt` only. Audit trail intact. + +- **LOW** — `serialiseReceipt` is imported across files via + `import { serialiseReceipt } from "../route"` (latest/route.ts:32) + — fine for now, but if the project moves to a stricter + per-route-handler boundary the helper should move to + `src/lib/consent/serialise.ts`. Cosmetic. + +--- + +## 8. iOS contract impact + +**Band: PASS — every new / swapped endpoint is shape-additive for +iOS v0.5.4.** + +| Endpoint | Change | iOS v0.5.4 impact | +| -------- | ------ | ----------------- | +| `/.well-known/apple-app-site-association` | Populated `applinks.details[0]` (was empty array) | **None** until iOS sets the `applinks:` entitlement (their PB30 phase). | +| `/api/notifications/status` | Added `events: Record` alongside existing `channels: ChannelStatus[]` | **Additive.** Existing destructure of `data.channels` works untouched. Spec-compliant per SB-6. | +| `/api/consent/ai` (POST), `/api/consent/ai/latest` (GET/DELETE) | New routes | **Net new** — iOS v0.5.4 doesn't call them yet. Hook-up tracked in iOS PB30. | +| `/api/insights/targets`, `/comprehensive`, `/generate` | Internal swap to mood-rollup tier | **Byte-identical envelope.** iOS Cards adapter unaffected. | +| `/api/notifications/status` adds `interruption-level=time-sensitive` for MEDICATION_REMINDER on the send path (apns.ts), not on this endpoint | Server-side payload field | **Gated on `.p8` APNs key install on Coolify env.** Until that env-secret lands, every dispatch returns `apns_not_configured`. Release notes call this out. | +| Dashboard query-key factory changes (`queryKeys.chartData`, `queryKeys.dashboardMedicationCompliance`) | Internal | **Web-only.** iOS doesn't touch TanStack Query cache. | + +**No breaking change for iOS v0.5.4.** Universal Links light up +once iOS ships the `Associated Domains` entitlement. + +--- + +## Summary verdict + +| Surface | Band | Action | +| ------- | ---- | ------ | +| Per-tile Suspense | PASS (2 MED) | Track tile-fallback skeleton + RSC migration for v1.4.41. | +| queryKey factory | **HIGH** | Extend `guardedRoots` to `src/components/comparison` OR migrate compare-toggle.tsx — 1 commit. | +| Insights swaps (3 landed, 3 deferred) | PASS | Accepted. | +| Privacy page | PASS (1 LOW) | Ship. | +| AASA | PASS | Ship. | +| Notifications status | PASS (1 HIGH info) | Coordinate iOS empty-state copy. | +| Consent endpoints | PASS (1 LOW) | Ship. | +| iOS contract impact | PASS | No breakage for v0.5.4. | + +**Releasable.** The one HIGH is a 5-minute factory-bypass cleanup +on `compare-toggle.tsx`; everything else is either passing or +explicitly deferred with rationale. diff --git a/.planning/round-v1440-perf-verify.md b/.planning/round-v1440-perf-verify.md new file mode 100644 index 000000000..ad1556247 --- /dev/null +++ b/.planning/round-v1440-perf-verify.md @@ -0,0 +1,94 @@ +# v1.4.40 — Post-deploy perf verification + +Live deploy: **2026-05-21T12:55:02Z**. Squash-merge commit on `main`: +`1114cf7e`. Docker workflow `26221332323` succeeded on both +`linux/amd64` and `linux/arm64`. Coolify deploy fired both via the +workflow's auto-trigger step and via the explicit MCP deploy +(idempotent, converged to the same state). + +Migration `0074_v1440_consent_receipts` applied cleanly at boot. + +## Unauthenticated probe (orchestrator side) + +```bash +$ curl -s https://healthlog.bombeck.io/api/version +{"data":{"version":"1.4.40", ...},"error":null} +``` + +Confirmed live at 12:55 UTC. + +## What this release was supposed to fix + +The empirical trace + the architecture-QA quadrology pointed at one +empirical root cause and three architectural Criticals: + +| # | Root cause | Wave | Expected delta | +| --- | --- | --- | --- | +| 1 | Prisma pool starvation on `/api/analytics` thick (≥8 of 10 default slots held for 6.5 s) | W-POOL | Wave-C chart-tile first-paint **+7.3 s → +1.6 s** | +| 2 | 6 insights routes unbounded `MoodEntry.findMany` | W-INSIGHTS | 5-10 s cold-mount estimate (3 routes done in-band, 3 deferred with documented reasons) | +| 3 | `Measurement.deletedAt` half-wired → tombstoned rows surface | W-DELETED + W-INFRA T1 | iOS soft-delete now correct end-to-end | +| 4 | WMY rollup buckets write-only → 3× write amplification | W-WMY-WIRE | `avg30LastYear` populated (was null), slope90 via MONTH-tier | + +## What gets verified by Marc's first authenticated session + +The wide-event annotates emit per-surface `path: "rollup" | "live"`. +The slim/thick LRU caches stay warm for 60 s after the first hit. The +dashboard tile-strip + chart row should now paint progressively — per +W-RSC's per-tile `` boundaries — instead of in one burst. + +Coolify log grep targets: +- `path:"rollup"` on `/api/analytics`, `/api/dashboard/summary`, + `/api/mood/analytics`, `/api/medications/intake?scope=compliance`, + `/api/insights/{targets,comprehensive,generate}` +- `meta.dashboard.sub_*_ms` annotates (W-F observability) +- `meta.analytics.bp_aggregate.live_since` (W-SINCE annotates) + +## iOS PB30 enablement verification + +The iOS team's SERVER-BACKLOG asks for SB-3/4/5/6/7/10. Surfaces live: + +- **SB-3** Privacy Policy at `https://healthlog.bombeck.io/privacy` — bilingual paired-on-one-page. App-Store-Connect URL field gets this. POLICY_VERSION = 1.4.40. +- **SB-4** AASA file at `https://healthlog.bombeck.io/.well-known/apple-app-site-association` — strict `application/json`, contains `S8WDX4W5KX.dev.healthlog.app` + `applinks` + `webcredentials`. +- **SB-5** APNs `interruption-level: time-sensitive` for `MEDICATION_REMINDER` payloads ONLY. **AP-2 prerequisite**: the `.p8` APNs auth key must be installed in Coolify env (`APNS_KEY` + `APNS_KEY_ID` + `APNS_TEAM_ID` + `APNS_BUNDLE_ID`) before SB-5 has any observable effect. Until then the server skips the APNs send entirely (`loadApnsConfig()` returns null). +- **SB-6** `GET /api/notifications/status` returning per-category `lastDeliveredAt` map. MOOD_REMINDER populated from `MoodReminderDispatch`; other categories return `null` until per-category ledgers ship (v1.4.41 follow-up). +- **SB-7** `/api/auth/registration-status` 4-branch behavior pinned in `auth-registration-status.test.ts`. The hint endpoint mentioned in the iOS brief (`/api/auth/check-user`) is out-of-scope for v1.4.40; flagged for v1.4.41. +- **SB-10** `POST /api/consent/ai` + `GET /api/consent/ai/latest` + `DELETE /api/consent/ai/latest`. Append-only invariant verified. 64 KB Buffer-byte cap (security M1 reconcile fix). + +## Operator action required for SB-5 + +```bash +# On apps01: +$ docker exec -it healthlog_app sh +$ # Then set the four env vars via Coolify UI: +$ # APNS_KEY = +$ # APNS_KEY_ID = M9WAFLNC2U +$ # APNS_TEAM_ID = S8WDX4W5KX +$ # APNS_BUNDLE_ID = dev.healthlog.app +$ # APNS_PRODUCTION = true (when ready for prod APNs) +$ # Save + redeploy the app. +``` + +After install: send a test medication-reminder push from the admin +console; iOS should receive `apns-priority: 10` + +`interruption-level: time-sensitive` and bypass Focus mode. + +## Carry-overs to v1.4.41 (deferred clean) + +Documented in `project_v1440_marathon_outcome.md` memory + the 6 QA +finding files. Highest-impact items: + +- W-DELETED-2 sweep for export/admin/doctor-report/gamification readers +- W-INSIGHTS-2 for cards/glp1-timeline/gamification (needs per-user-tz bucketing first) +- `src/types/` DTO promotion + prompt-directory unification (org-audit recs #2 + #3) +- Full RSC migration of `app/page.tsx` (Suspense boundaries shipped as prerequisite) +- ESLint custom rule for `queryKeys` factory enforcement (test-guard substitutes for now) +- `/api/auth/check-user` SB-7 follow-up + +## Marathon retrospective + +Cross-agent commit-attribution drift recurred a 4th time (across +v1.4.37 / v1.4.38 / v1.4.39 / v1.4.40). The marathon's parallel-waves +pattern is sound; the shared-tree pattern is the failure mode. +Per-agent `git worktree` isolation is now a hard rule for any +release-marathon with 3+ parallel implementation agents +(`feedback_marathon_worktree_isolation.md` in both memory locations).