mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-10 13:22:14 +00:00
docs(planning): v1.4.40 marathon closure row, QA findings, perf-verify
Adds the v1.4.40 row + the four prior v1.4.39.x stacked rows to
.planning/round-audit-marathon-closure.md (v1.4.39.1/.2/.3/.4 were
documented inline in the corresponding memory files but the table
hadn't picked them up).
Commits the six v1.4.40 QA reviewer findings files (senior, security,
specialist, UX, product-lead, simplifier) so the audit trail stays
self-contained.
Adds round-v1440-perf-verify.md anchored on the live deploy commit
1114cf7e (squash-merge), Coolify deploy timestamp 2026-05-21T12:55:02Z,
docker workflow 26221332323. Includes the AP-2 .p8 install runbook for
SB-5 live effect.
This commit is contained in:
1 parent
32c73bddc7
commit
4cca62dce9
8 files changed
+1390
No files matched your search
@@ -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 `<Suspense>` 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) |
|
||||
|
||||
|
||||
@@ -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 `<Suspense>` 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.
|
||||
@@ -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.
|
||||
@@ -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<void>`
|
||||
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<dayKey>`
|
||||
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.
|
||||
@@ -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 `<entity>-write` (so the convention is `<entity>-<role>`) or accept the asymmetry and add a one-line module comment to each writer explaining the read side lives in `app/api/<entity>/...`. 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.
|
||||
@@ -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).
|
||||
@@ -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
|
||||
`<Suspense fallback={null}>` and `:1486` wraps every chart cell in
|
||||
`<Suspense fallback={<ChartSkeleton />}>`. Each cell sits inside its
|
||||
own `<div key={entry.id}>` 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 `<ChartSkeleton />`, 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
|
||||
`<Suspense>`-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 `<main className="mx-auto max-w-3xl space-y-10 px-4 py-8
|
||||
md:px-6 md:py-12">` 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
|
||||
`<details>` 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 `<details>` 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 `<details>`"); 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/<host>`. 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<EventType, {lastDeliveredAt}>` 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.
|
||||
@@ -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 `<Suspense>` 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 = <contents of M9WAFLNC2U.p8>
|
||||
$ # 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).
|
||||
Reference in new issue
Block a user