The one-paragraph-per-workout claim is a unique index, the cascade is an FK,
and the daily-cap window is a SQL fact — a mocked upsert reports success for
all three regardless.
Backfill immunity is proved by replay, not by a hand-picked case: a ten-year
Apple Health export (12772 writes) and a 30-day provider re-sync (58 writes)
must both emit zero events, while a fresh sleep night, workout and first
weight of the day emit exactly one each.
The provider-isolation guard walks the worker's static import graph and fails
if any provider client or resolver becomes reachable. It carries two positive
controls, so a broken entry path cannot make it pass against an empty graph.
The integration test covers what unit mocks cannot: the unique index, the
ON CONFLICT DO NOTHING claim, the losing side of ten concurrent claims, the
forward-only marker, ciphertext at rest, the delete cascade, and the
retention window.
The assertion compared the cookie to the new session row's id, which only
held while the id WAS the cookie. Check the stored hash instead: it
proves the cookie authenticates the surviving row and that the row id
never reaches the client.
The cookie was the Session row's cuid. cuid is built for collision-
resistant identity across distributed writers, not for unguessability —
it encodes a millisecond timestamp and a process-local counter and never
touches a CSPRNG. A session cookie is a bearer credential and has to be
unguessable, so it now carries 32 random bytes and only the HMAC of that
value is stored, matching how api_tokens and refresh_tokens already hold
their secrets.
Compatibility is passive, and deliberately so. A backfill cannot help:
the cookie already sitting in a browser is the cuid, and nothing we
generate server-side can be pushed into it — backfilling would sign
everyone out at deploy. Upgrading a row in place on first use loses to
the parallel requests a single page load fires: each mints its own
secret, one write wins, and the losers cannot re-emit the winner's
secret because only a hash is stored, so the browser can end up holding
a value the row does not honour. Instead, rows minted before the column
keep a NULL token_hash and stay resolvable by id, and are denied the
sliding-expiry extension so they drain within the 30-day session
lifetime. Every login from this release forward is on a secret at once,
and an id stops authenticating the moment its row holds one.
Note for operators: API_TOKEN_HMAC_KEY now also keys session cookies, so
rotating it signs browser sessions out as well as invalidating tokens.
It is already Core-required, so no deployment gains a new prerequisite.
Two layers. The integration suite runs a real pg-boss against a real
Postgres and proves the semantics the policies rest on: that a bare
singletonKey under the default policy de-duplicates nothing, that
exclusive suppresses a duplicate while the first job is queued AND while
it is active, that short suppresses only while queued and admits a send
once the job is active, and that a distinct key always enqueues.
It also pins the two facts that make the fix reach a running instance:
createQueue does not change the policy of a queue that already exists,
and updateQueue refuses a policy change, so the reconcile is required.
The unit guard couples each enqueue site to its registrar entry. The key
and the policy live in different files with nothing in the type system
holding them together, so deleting an entry would leave the call site
looking correct while it silently stopped working.
Four structural guards freeze the invariants the change rests on: the set of
files that can resolve a Bearer token to a user, the single deliberate
any-valid-token posture, the mint sites with their scope sets, and the rule
that a declared scope is an exported constant rather than a literal.
Thirteen behavioural cases run against a real Postgres and the real resolver.
Four of them go red on the pre-fix tree — a narrow token reading the
full-backup export and the labs surface, and an MCP token on a REST read.
The rest pin the flows that must NOT change: the per-medication ingest pair
still works, the token without the per-medication grant is still refused, a
wildcard token still reaches the export and the batch ingest, an MCP token
still resolves on /mcp with its write scope intact, a declared fhir:read
still opens the FHIR face and nothing else, cookie sessions are unaffected,
and requireAdmin still refuses a wildcard Bearer.
The workout-batch case that asserted narrow tokens are admitted claimed the
iOS sync depended on it. It does not: workouts:ingest has no mint site
anywhere, and iOS holds a ["*"] token. Replaced with a case that asserts both
halves of the real contract.
Documentation updated for the enforcement rule and the retired mint.
The raw-SQL data backfills 0247 (WHOOP) and 0251 (Strava) rewrite
historical Workout.sport_type rows with no test — the thinnest-covered
class in the tree (audits L2). Add an integration test that seeds
pre-backfill rows, applies the EXACT migration SQL from disk against the
testcontainer Postgres, and asserts:
- canonical relabel across both value shapes (id placeholder + raw
label), case/normalisation, unknown→other fallback;
- the source guard (a WHOOP pass leaves Strava/MANUAL rows verbatim and
vice-versa);
- idempotency (a second apply reports zero affected rows).
Runs in CI's integration job; locally needs Docker/OrbStack via
pnpm test:integration.
Adds prev/next-day paging to the intraday pulse "shape of the day" chart
(TileHeader right slot, next disabled at today) so a user can step back
through prior days on the existing date-scoped route.
Raises DENSE_INTRADAY_RETENTION_DAYS from 14 to 90 — a self-hoster's own
storage is the trade-off for three months of full 10-minute-resolution
history instead of two weeks. Days outside the window keep folding to
hourly-mean stats: rows as before; loadIntradayPulse now falls back to
that hourly tier when a day's raw samples are gone, so the navigator
shows a coarser shape instead of an empty chart. The tension-window
read is suppressed on hourly days — it needs per-sample resolution to
stay honest. A new resolution field on the DTO ("tenMin" | "hourly")
drives a small note in the chart.
Replace the scalar document_id discriminator on a coach conversation with a
CoachConversationDocument join table and a sticky documentScoped flag, so a
conversation can be grounded on several stored documents at once while the
prompt-injection fence stays intact across the data-model change.
The fence is re-established at both enforcement points, fail-closed:
- Client: resolveCoachSendTarget routes any turn on a fenced conversation (or a
fresh chat with staged attachments) to the new /api/insights/chat/fenced
endpoint; the fenced body is strict and never carries scope, guided-question,
or prefill hints.
- Server: the tool route fetches with documentScoped:false AND 404s when the
fetched row still carries an attachment (flag/join drift), emitting a
fence_drift audit alarm. Document text now enters an LLM prompt in exactly one
code path — the fenced pipeline — with no tools and no health snapshot; a
module-graph test pins that the tool loop is unreachable from it.
The fenced turn fences each document separately with a server-built, scrubbed
header line (title and filename are untrusted text), runs a per-document egress
consent check on the single picked provider, and grounds numerically over the
live attachment union only. Attachments are capped at five documents and 192 KB
of combined text with honest truncation.
Attach and detach ride dedicated endpoints: attaching validates the document is
owned, live, indexed, and within cap, then flips the conversation fenced (the
one legal, privilege-reducing transition, audit-logged); detaching removes the
document but never clears the flag. The document-sheet chat keeps its public
wire contract and shares the same fenced pipeline over the join.
Migration 0245 adds the join table and the flag, backfills every legacy
single-document chat into a one-attachment fenced conversation, and drops the
scalar column in the same transaction — no dual-truth window.
The Apple Health ZIP import upserted each spot reading on the
(userId, type, source, externalId) index only. When two readings shared the
natural key (userId, type, measuredAt, source, sleepStage) with different
externalIds — a re-import of overlapping data, or two same-instant Apple
samples from different source apps — the externalId upsert missed and the
create collided on the second unique index (migration 0055), raising P2002
and aborting the entire import (issue #486, after the v1.28.36 dedup fix let
the upload run).
flushSpotBatch now wraps the per-row upsert: on P2002 it probes the occupying
row by the natural key (tombstone-inclusive) and adopts it in place — re-keys
to the incoming externalId, takes the new value/unit, resurrects if
soft-deleted — mirroring the v1.28.39 provider natural-key rescues (closest to
withings/sync-sleep). If no twin resolves it, the one reading is skipped and
the run continues; the catch sits in the per-row loop outside any transaction,
so a collision can never abort the batch or the import. Adopted rows count as
updated, skipped ones surface honestly. Workout + stats: paths verified unable
to hit this (no second unique index / deterministic externalId). Repro +
real-Postgres integration tests added.
Deferred perf fixes from the campaign's verified perf-correctness audit,
correctness-first (each with an output-equivalence test):
- comprehensive-generate fan-out: replaced per-type 400d findMany+JS-average
with DB prisma.aggregate(_avg) over the exact half-open windows the JS
reducer used (current 30d, lastMonth (60d,30d], lastYear (395d,365d]), same
rounding + null-on-empty, pLimit(4). SLEEP_DURATION stays on the raw path
(per-night reconstruction dedup). Byte-equality test vs the old algorithm.
- boot-discovery: replaced Prisma in-memory distinct with DB SELECT DISTINCT
in the 4 backfill discovery scans + migration 0243 (7 additive
partial indexes matching each predicate). Integration test asserts identical
discovered set against real Postgres.
- ChatBubble wrapped in React.memo with a comparator (onRegenerate compared by
presence — the thread rebuilds it each render), so only the streaming bubble
re-renders per token.
- doctor-report mood read narrowed to {score, tags} (the only fields the
collector uses); the 730d main measurement read left as-is — a blind bound
would corrupt clinical stats (documented deferred follow-up).
Migration 0243 additive-only.
The v1.28.33 rollup + staging fixes were correct but never reached the
reporter's case: the upload dedup keys on the content SHA-256 and the
lookup matched a previously FAILED job too, so re-uploading the same
export.zip returned the dead job's id and replayed its stale
failureReason without ever staging or sending again. The dedup now
excludes failed rows (short-circuits only on queued/unpacking/parsing/
upserting/done); the same bytes retry cleanly. A redundant staged upload
is unlinked on a dedup hit instead of leaking in the staging dir.
reconcileOrphanImportJobs no longer unconditionally fails every
non-terminal ImportJob at boot: it now checks pg-boss job state plus a
progress heartbeat (new import_jobs.updated_at, migration 0242), so a
split or multi-worker deployment cannot kill an import running in
another worker. The missing-staging message names the separate-container
cause explicitly, with a matching note in docs/self-hosting/scaling.md.
Client-rendered times fell back to the hardcoded display zone
(Europe/Berlin) because neither useFormatters() nor the legacy format.ts
helpers ever received users.timezone. A third localStorage mirror
(timezone-mirror.ts, following the time-format/date-format pattern)
carries the validated profile zone from /api/auth/me into both read
paths; the fallback chain is strictly profile → Europe/Berlin — never
the browser zone, so screen, PDF, and export cannot disagree.
makeFormatters validates its zone before Intl (a poison value falls back
instead of throwing inside every render).
Same pass, per the two-document audit: formatUpdatedLabel boundary and
clock now resolve one zone in every state (the undefined-tz browser hole
is closed); @db.Date calendar fields render UTC-pinned so profiles west
of UTC cannot shift a day; dose-history day headings and GLP-1 weekday
labels derive from the display zone; chart day/month labels are
encoding-derived and UTC-pinned (bucket math untouched, byte-identical
for Berlin profiles); the clinician share view renders in the patient's
timezone instead of the container's; Playwright pins Europe/Berlin.
Server day-key semantics, streak anchoring, insight cache keys, and
verbatim wall-clock strings are untouched by construction and pinned by
the new Auckland/New_York/Manila + DST test matrix.
Supplement-style intake written to Apple Health by nutrition apps —
vitamins, minerals, water, caffeine — can now sync as daily totals into a
dedicated NutrientIntakeDay store (migration 0241): composite (user, day,
nutrient) identity, re-post replaces, no soft delete, no rollup coupling.
A code-side catalog carries the 26 nutrient codes with canonical units,
plausibility caps, and EFSA dietary reference values (direction-aware;
chromium cites NIH ODS where EFSA declined a DRV).
Deliberately not a nutrition feature: no diary, no calories, no dashboard
surface. POST /api/nutrients/batch is gated behind the new opt-in
nutrients module (default off — with the module off the server refuses
ingest entirely), rate-limited, idempotency-wrapped, with per-entry
unit-mismatch and out-of-range skips. GET /api/nutrients serves a
catalog-ordered summary; the only visible surface is a read-only card
under Settings → Sources. Coach/correlation access is a later slice; the
iOS reading side is coordinated in the companion-app tracker.
Rollup writes were delete-then-createMany inside a transaction; under READ
COMMITTED a concurrent recompute racing the import's full fold on the same
(user, type, granularity, bucket, source) partition tripped the composite
PK — and the P2002 swallow dropped the loser's freshly computed aggregate,
leaving a stale bucket. persistRollupRows now writes chunked
INSERT … ON CONFLICT DO UPDATE with deterministic (type, source, bucket)
lock ordering inside one transaction: a race can neither fail the import
nor lose an aggregate, and previously-staled buckets self-heal on their
next recompute. The vanished-source delete and in-transaction guarantees
are preserved; the now-unreachable P2002 swallow is removed.
The masked ENOENT is gone with it: long imports outlived the queue's
15-minute expiry, pg-boss redelivered the still-running job, and the
redelivery re-opened the consumed staging file — overwriting the real
terminal state with a raw ENOENT. Import sends now carry retryLimit 0 +
6h expiry (a retry after the staging file is consumed can only mask),
deliveries for terminal ImportJob rows are no-ops, a genuinely missing
staging file reports an honest re-upload message, and the extracted XML
is cleaned up on failure too.
Long-term retention for the dense intra-day tier (PULSE, HRV, SpO2) folds
to per-local-hour means instead of a single daily mean: the last 14 days
keep every raw sample, older days keep up to 24 points per metric — the
day's shape survives permanently at ~9k rows/metric/year. Hourly rows key
as stats:<HK>:<YYYY-MM-DD>T<HH> anchored at local HH:30, adopt-in-place
against both unique indexes with the P2002 retry outside the transaction.
The pre-fold DAY-rollup recompute extends to HRV (mean fidelity, not only
min/max) and the post-fold recompute is gone for the whole tier. Derived
resting rows for proxy users still mint from the day's raw samples.
A one-shot rebuild reconstructs the hourly shape for every already-folded
day from the soft-deleted raw samples and retires the daily row in the
same transaction — at no instant are both grains live, so no
AVG-over-live-rows reader can double-count. Boot discovery pairs live
daily-grain rows with surviving tombstones (parameter-bound SQL, pinned
against real Postgres) and converges to zero once converted; days whose
tombstones were pruned keep their daily mean. PULSE/SpO2 DAY buckets keep
the true pre-fold stats; HRV DAY buckets recompute from the hourly rows
(strictly better than the collapsed fold-time bucket); WEEK/MONTH/YEAR
respans enqueue whole-bucket recomputes.
Cover the new DTO provenance end-to-end against a real Postgres: the index
source threads through the list and detail reads, and — the regression the
UI wave flagged — survives a metadata PATCH, which now reads the index row
rather than returning a stale null source.
The foreign-document ownership test only needs owner A's upload to exist;
it seeded the user into a discarded local. Call it for its effect instead
so lint stays clean.
P3-1. Integration coverage the unit mocks cannot pin: migration 0229's join +
cascade, create-with-documents in one transaction, foreign-doc refusal at
create, the public serve route (passphrase gate, token confinement to the
frozen set, exact-bytes Class A posture, foreign id + revoked link → 404), and
membership cascade on document delete.
P2-1. End-to-end: TEXT-mode indexing persists only ciphertext text + opaque
HMAC tokens (A4), a body-only word finds the document through the GIN token
overlap while the list still omits the blob, a word that appears nowhere does
not match, re-indexing upserts in place, and hasContentIndex + the usage gauge
reflect the index. Migration 0228 applies clean on the fresh testcontainer.
A twice-daily card advances its display-due to the evening slot once the
morning slot's catch-up window has lapsed, so a late-morning "Genommen"
tap posts `scheduledFor = <evening slot>` with `takenAt = now`. Band
attribution correctly refuses the take (it lands in no window), but the
intake routes' source-agnostic convergence probe then bound it to the
evening slot's pending REMINDER row — recording the morning dose as the
evening one, silently consuming a slot the user had not reached, and
jumping the dashboard "next intake" to the following day.
Gate the convergence probe with `mayConvergeOntoSuppliedSlot`: a taken
write may not converge onto a slot whose anchor is in the future relative
to the take (the same forward-grace invariant `resolveCanonicalSlotInstant`
already enforces). Off-window late takes now record standalone (ad-hoc),
leaving both real slots intact; skips and pending echoes still name their
slot deliberately. Applied to both the per-medication and bulk intake
routes.
The filter bar derived its condition chips from the loaded corpus, so an
episode whose only linked documents sat pages deep in the timeline never
surfaced a chip — the condition route into an older document silently
disappeared at four-digit corpus sizes. The usage endpoint (already read
on page mount) now returns `linkedEpisodes` — every episode carrying at
least one live document link, one indexed grouped query, no blobs — and
the view unions it with the active-filter fallback. Additive DTO change;
OpenAPI regenerated.
Concurrent uploads all read the same pre-insert usage SUM, so a burst
could overshoot the per-user quota by rate-limit x cap in one window. A
transaction-scoped advisory lock keyed on the user id serialises the
sum-and-insert; uploads by different users never queue on each other.
Integration probes pin both races: four parallel uploads against a
two-file quota land exactly two, and two racing identical uploads
collapse onto one row via the partial unique index.
Also aligns the round-trip integration test with the serve-route CSP
posture: the proxy carve-out owns the response CSP, the route sets none.
Pins what the unit mocks cannot: the binary2 round-trip through upload and
/original with class-correct headers, the policy error contract, sha256
dedupe per user with the partial unique index live, Idempotency-Key replay
without a second row, tombstone-inclusive quota accounting, pre-link +
replace-set + kind/episode/year list filters, restore incl. both 409
shapes, bulk per-id semantics, usage with a per-user override, and the
purge job claiming only past-grace tombstones. Migration 0227 applies
clean on the fresh testcontainer as part of the ordered chain.
Tasks: W1-T1, W1-T5, W1-T6, W1-T7, W1-T8, W1-T9, W1-T10, W1-T12.
The midnight-spanning dual-source case seeded fixed calendar dates; once
they aged past the route's trailing 30-day window the assertion went red
on every branch. Anchor the night relative to now, like the first case
in the file already does.
- Reword two LOINC verification comments in the signals registry so they
no longer trip the forbidden-marker grep, keeping the meaning intact.
- Resolve the dead-code gate: gate the seeded-question opener on the
per-user ambient-suggestions opt-out (wiring the previously orphaned
resolveCoachAmbientSuggestionsEnabled, server-authoritative), drop the
unread decryptDocumentFromBytes helper and its now-unused import, and
remove the unused SignalKind type alias.
- Delegate undici's fetch to the global stub in the provider-chain
integration test so the egress-pinned client HTTP is mocked again.
- Defer the JSON example-download cleanup a tick so revoking the blob URL
no longer races the browser download start (stabilises the e2e).
- Sync the service-worker version fallback to v1.25.0.
The mental-wellbeing screener surface shipped built but unreachable: no nav
entry, no module key, ungated. Add a `mentalHealth` module key (opt-in,
default OFF — a depression/anxiety self-assessment is at least as sensitive
as mood) to the registry, the module-hub icon map, and the exhaustive module
maps. Give the check-in a nav home at /insights/mental-wellbeing, born-gated
on the module so it appears only once the account opts in, and redirect the
page home on a direct hit when the module is off.
Item answers stay encrypted and off the AI Coach / MCP regardless of the
toggle; this only controls whether the surface is visible and reachable.