The dynamic /i18n/[locale] route handler answered with Next's default
Cache-Control (private, max-age=0, must-revalidate) and no validator —
its immutable-cache header was overridden, so a non-PWA browser would
re-download the full catalog on every hard load. Static public/ files
keep an ETag (repeat loads revalidate to a 304) and the service
worker's cache-first rule covers the offline relaunch; the ?v= build
key the layout appends busts both per deploy.
scripts/generate-i18n-boot.mjs writes public/i18n/<locale>.js for all
six locales as a prebuild/predev step (the sw-version.js pattern);
output is gitignored. The route handler is removed.
Measured on a production build, authenticated DE session: dashboard
document 522 KB raw / 139 KB gz -> 170 KB raw / 24.6 KB gz; the DE
catalog moves into a one-time 119 KB gz cached asset.
H2
The measured first-load gap on / was never the server: FCP painted the
SSR shell at ~1.8 s but LCP landed at ~5.5 s (mobile-throttled) because
the tiles waited for JS download + hydrate + a CLIENT-side snapshot
fetch — while /api/dashboard/snapshot answers in ~8 ms server-side.
src/app/page.tsx is now a thin RSC wrapper: it runs the same cached
snapshot read the API route uses (extracted to
src/lib/dashboard/snapshot-read.ts, one shared SWR cell for both
readers) and hands the result to TanStack via HydrationBoundary +
dehydrate, so the first HTML carries real tile data and the mounted
cell starts warm (staleTime 60 s, refetchOnMount false — no double
fetch). Query keys come only from the central factory
(dashboardSnapshot(locale) + the dashboardWidgets seed the client cell
already performs); the payload is JSON-round-tripped so the hydrated
shape is byte-equal to the wire shape. Fail-soft: no session or any
builder error renders the page exactly as before. The 1,727-line client
page moves unchanged to src/app/page-client.tsx; no component-tree
rewrite.
Companion: the documented bundle tooling was dead on Turbopack
(ANALYZE=1 silent no-op, bundle-report read a webpack-only JSON), so
H2/H3-class regressions shipped invisibly. scripts/check-bundle-budget.mjs
derives per-route gzip totals from the Turbopack client-reference
manifests + rootMainFiles, prints the report (pnpm bundle-report), and
enforces bundle-budget.json (pnpm bundle-check): per-route caps,
total-client-JS cap, a recharts-duplication guard (fingerprint count
must stay 1), and a catalog guard (no message catalog referenced by any
route manifest). Wired into the existing e2e workflow after next build.
H4
Add the request surface and the background sync for the Google Health
provider, all built on the shared integration plumbing.
- Routes under /api/google-health: connect (mint the PKCE pair and state
nonce, redirect to Google), callback (timing-safe CSRF, atomic state
consume, token exchange, connection upsert, clear the reauth flag,
enqueue backfill), credentials (encrypt the operator's BYO client id
and secret field-by-field), disconnect, status, and a rate-limited
manual sync.
- Jobs: a boot-time backfill, an hourly poll cohort, and a daily
oauth-state cleanup, registered under the existing dead-queue contract.
- Rank GOOGLE_HEALTH just after Fitbit in the per-metric source ladders;
it is the successor API for the same devices.
- Add GOOGLE_HEALTH to the readable measurement-source enum but leave it
out of the writable set — the provider is server-owned, so a client
cannot attribute a write to it.
- Register the two new encrypted token columns and the two BYO-client
columns in the encryption-rotation registry and the rotation script.
The inbound-document store encrypts the raw upload
(`inbound_documents.content_encrypted`) because it is sensitive clinical
data, but the per-fact staging columns persisted the same class of data in
the clear: `extracted_facts.data_json` held the transcribed clinical values
(diagnosis text, lab values, medication names, stated codes) and
`provenance_json` held the verbatim source span (up to 2000 chars copied
straight out of the report). The at-rest guarantee was defeated for staged
facts, which persist through PENDING and are retained after the decision.
Convert both columns to the AES-256-GCM `*_encrypted` BYTEA convention shared
with the note columns, going through the same Bytes codec. The upload and
fact-edit paths encrypt on write; the confirm path decrypts at commit time to
write into the normal structured stores, so the review-then-confirm flow is
unchanged. Reads are fail-closed (a bad key id throws).
Register both columns in the encrypted-columns registry and the key-rotation
script so a dropped legacy key cannot strand them (the guard test enforces
both). Migration 0220 swaps the plaintext JSONB columns for the encrypted
BYTEA columns; pre-confirm staged rows are ephemeral and cannot be
re-encrypted in SQL, so any left by a pre-release build are dropped rather
than retained as plaintext (their parent document keeps its encrypted
original).
Store an uploaded doctor report / discharge letter and stage extracted
structured facts for review. Two models:
- InboundDocument: the uploaded document, raw bytes encrypted at rest via
the shared AES-256-GCM Bytes codec (content_encrypted), with queryable
metadata (kind, mime, size, status, report date).
- ExtractedFact: the review-then-confirm staging row — fact type, status,
per-field provenance, a confidence score, and a needs-review gate, plus
the FHIR-staged payload (stated codes/status only) as JSONB.
Wire content_encrypted into the encryption-key rotation registry and the
rotation script so a key drop never strands a stored document.
Introduce two structured health-record tables, kept as reference records
(not time-series signals):
- `allergies` — AllergyIntolerance-style: substance + category + type +
severity + status + optional onset. The substance label stays queryable
plaintext; the free-text reaction and note are AES-256-GCM encrypted
(`reaction_encrypted` / `notes_encrypted` BYTEA).
- `family_history_entries` — one condition per relative: relationship +
condition label + optional age-at-onset; the free-text note is encrypted.
Both are owner-scoped and soft-deleted. Migrations 0215/0216 are additive
and idempotent. The three new encrypted columns are registered in
ENCRYPTED_COLUMNS and wired into the key-rotation script, so a key drop can
never silently strand them undecryptable.
Add the validated mental-health self-assessment surface, beside mood tracking
(never replacing it) and off by default:
- MentalHealthAssessment stores per-item answers in a single AES-256-GCM Bytes
blob (registered for key rotation); only the server-authoritative total,
severity band, and item-9 flag are denormalised. The total projects to a
PHQ9_SCORE / GAD7_SCORE measurement row so the trend rides existing infra
without ever exposing item content.
- Item-9 safety: any non-zero PHQ-9 self-harm answer surfaces a calm,
locale-aware crisis-resource set (DE: TelefonSeelsorge 0800 111 0 111 / 112;
international fallback) and stamps crisisShownAt. No clinical interpretation,
no AI ingestion of item content, no third-party alerting.
- API + OpenAPI: POST/GET /api/mental-health/assessments; the response excludes
raw item answers by construction. Required Pfizer/PHQ attribution lives in the
instrument module, rendered verbatim.
The screener scores are kept off the Coach snapshot and MCP surfaces, and out of
exports by default — item content never reaches the AI pipeline.
Encrypt MedicationSideEffect.notes, MedicationDoseChange.note and
MedicationInventoryItem.notes with AES-256-GCM, closing the last plaintext
PHI columns left after the v1.23 note-encryption rollout. Add the three
*Encrypted Bytes columns (migration 0208), register them in the encrypted-
column rotation set, route every read/write through the note cipher, and add
a transactional fail-closed boot-time backfill for legacy rows. Plaintext
columns drop in a later release once the backfill reports zero remaining rows.
Require a fresh second factor on password change for MFA-enrolled accounts,
matching the existing step-up on MFA-disable and recovery-code regen. Redact
free-text note values from the Zod validation excerpt that can reach a wide
event.
Removing the second factor is a security-state change, so it should not leave
stale credentials behind. The disable endpoint now drops every other web
session, every native refresh token, and every trusted device, keeping only the
caller's current session. The lockout-recovery CLI does the same out of band,
clearing all sessions and trusted devices for the account.
Drop the in-app bug-report surface end to end: the user submission page,
the feedback intake and admin inbox, the optional GitHub-issue
escalation, the operator config, and the dedicated achievement badges.
- delete the /bugreport page and the bugreport / feedback / admin-feedback API routes
- drop the Feedback model, its enums, and the AppSettings GitHub-issue and enable columns (migration 0205)
- remove the admin Feedback section, the Bug Report integration card, and the system-status row
- drop the bug-report nav entry and the app-settings provider that only carried its flag
- remove the bug-hunter achievement badges and their metric
- prune the related i18n keys across every locale and the encrypted-column and key-rotation registries
Add an opt-in to download the full backup as an encrypted archive instead
of a plaintext JSON file. The user supplies a passphrase over the request;
the server derives a 256-bit key with Argon2id and seals the export bytes
with AES-256-GCM into a self-describing HLX1 archive (magic, version, KDF
params, salt, iv, tag, ciphertext), mirroring the off-host backup envelope.
POST /api/export/encrypted returns the binary archive and is step-up gated
for accounts with a second factor; single-factor accounts use a normal
session. The passphrase is never stored and never logged, and there is no
server-side recovery — the UI states this. A standalone decryptor
(scripts/decrypt-export.ts) opens the archive with the passphrase outside
the app. The plaintext full-backup route now shares one payload builder so
both variants emit the same restore-compatible shape.
Add the TOTP enroll/confirm/disable surface and Argon2id-hashed recovery
codes as the v1.23 second factor.
- totp.ts wraps otpauth (zero-dep) pinned to the interop floor (SHA-1, 6
digits, 30 s) with a +/-1 drift window. Replay protection persists the last
accepted time-step per account (User.totpLastStep, migration
0202_v123_totp_laststep) and rejects any step that is not strictly newer,
so a captured code dies the instant it is accepted once.
- recovery-codes.ts issues ten single-use, high-entropy codes, Argon2id-
hashed at rest, shown once; regeneration replaces the entire prior set in a
transaction and verification burns the matched code with a guarded update.
- The TOTP secret is stored AES-256-GCM encrypted (already registered in the
encrypted-columns registry + rotation script); decrypt is fail-closed.
- Routes: setup (pending secret), confirm (activate + issue recovery codes),
disable (step-up + current factor), recovery-codes/regenerate (step-up).
Setup/confirm are cookie-only; disable/regenerate add requireFreshMfa.
- scripts/disable-mfa.ts is the operator escape hatch for a locked-out
self-hoster, audited, run via pnpm dlx tsx.
Lay the account-security and data-sovereignty substrate for v1.23. No
user-facing behaviour yet — schema, crypto registry, redaction, and the
note-encryption data path only.
Schema + migrations:
- 0199 — User totp_secret_encrypted / totp_confirmed_at / mfa_enforced /
passkey_upgrade_nudge_dismissed; Session mfa_verified_at / last_active_at;
Passkey last_used_at. All additive, nullable / defaulted.
- 0200 — mfa_challenges (single-use step-up ticket; only the ticket hash is
stored, so no session or token is ever minted before the second factor),
mfa_recovery_codes (Argon2id at rest), webauthn_mfa_credentials (second-factor
keys, kept separate from passkeys, which remain the passwordless-primary set).
- 0201 — mood_entries.note_encrypted + measurements.notes_encrypted (BYTEA),
matching the existing free-text encrypted-note convention.
Free-text health notes:
- New writes encrypt the mood / measurement note into the *_encrypted column and
null the legacy plaintext column; reads prefer the ciphertext and fall back to
plaintext only for not-yet-migrated rows, via a single read/write helper.
- A boot-time job migrates existing plaintext rows in an idempotent,
transactional, fail-closed pass (encrypt, then null the plaintext, per row in
one transaction; safe to re-run; a row with content is never left both-null).
- The legacy plaintext columns are kept this release and dropped in a follow-up
once the backfill reports zero remaining rows — the same boundary the
key-rotation playbook uses before dropping a legacy key.
Crypto coverage:
- Register the three new encrypted columns and wire them into the key-rotation
script so a rotation never orphans them.
- Extend the observability and log redaction denylists so TOTP secrets, MFA /
recovery / backup codes, and export passphrases never reach a wide event.
Add three per-user AI settings, all configured in the app (no new env vars),
behind one migration (0198):
- Response timeout (seconds). A per-user upstream timeout threaded onto the
provider call. A local/self-hosted backend can take more than the legacy
60 s default to load a model on the first request, which timed the Coach
out before the first token. Defaults to a generous ~180 s when unset;
validated to 10–600 s.
- True token streaming for the local OpenAI-compatible client. The client now
POSTs with stream:true and parses the SSE delta chunks, emitting tokens as
they arrive, with a per-idle-gap timeout instead of a whole-call ceiling and
a graceful fallback to the buffered path when a server cannot stream. The
Coach route runs the provider call inside the response stream behind a
keepalive heartbeat so a reverse proxy no longer idle-drops the connection
during model load or generation. Grounding, refusal, and sentinel-stripping
still run on the complete reply before any token reaches the client.
- Dedicated document-scan (Lab-OCR) provider. Off by default — the OCR path
uses the main provider chain. When enabled, lab-report processing resolves a
separate provider/model/key, with a hint when the chosen model is not
vision-capable. The new key column is AES-256-GCM at rest and registered in
the encrypted-columns registry and the rotation script.
i18n: EN + DE translations for the new Settings → AI strings (other locales
carry interim copies to keep locale parity green).
Add the medication-class to target-vital map and the adherence-to-vital
storyline so the Coach can surface, association-only, when an adherence dip
lines up with a drift in the vital that medication targets. Never causal,
never a dose-change suggestion; dose questions defer to the prescriber.
Add an in-repo CUSUM / binary-segmentation changepoint detector over DAY
rollups with a high firing bar (persistence plus within-segment-spread
magnitude) so the Coach can name a dated, sustained level shift instead of a
vague "lately", and stays silent when no break clears the bar.
Surface which source recovery is read from when two sources materially
diverge, with the honest reason, instead of silently averaging.
Lay the n-of-1 experiment substrate: a nullable encrypted outcome column on
coach_plans (migration 0197), the review_due / reviewed lifecycle statuses
wired into the encrypted-column registry and rotation script, and a daily
worker that reads the experiment result back in the user's own before/after
numbers — association-only, honest-null, no cheerlead on an adverse trend,
and routing a worsened vital to the clinician. The user-visible read-back
stays behind COACH_EXPERIMENT_VERDICT until its review cases clear.
Extend the outbound dose screen to catch experiment-shaped dose changes
(halve / skip / stop a medication to test it). Add evaluation cases and unit
tests for each new surface.
Introduce a third Coach memory type alongside facts (durable traits) and
plans (standing if-then habits): a time-anchored "bring this back later"
reminder with a due moment and a done state. The free-text note is
AES-256-GCM ciphertext via the shared Bytes codec; metric, status, the
trigger fields and the dates stay plain so the sweep and the injection
picker rank and filter without a per-row decrypt.
Migration 0196 is purely additive (status and trigger_kind are app-side
closed enums, not DB enums). Register the new note_encrypted column in the
encrypted-columns registry and the rotation script so a key rotation can
never silently skip it.
A deterministic golden-set + red-team floor that gates every Coach change
offline, plus an opt-in live-judge layer that scores warmth and groundedness
with a pinned model only when COACH_EVAL_API_KEY is set.
- 42 golden cases across grounding, cross-metric, data-honesty, provider-parity
and own-baseline taxonomies, graded by behaviour-anchored binary criteria.
- Structured-claim grounding helpers extend the numeric verifier from numbers
to threshold / own-baseline / confident-verdict claims; the numeric API is
unchanged so the existing grounding tests stay green.
- Red-team battery asserts the existing inbound refusal, outbound dose screen,
acute red-flag escalation and learn-link guards fire.
- Nightly + manual workflow runs the deterministic floor always; the live judge
step runs only when the secret is present and is non-blocking.
Add a CoachPlan model: durable, user-scoped if-then implementation plans
the Coach proposes and the user confirms. The background memory-refresh
pass extracts plans the user agreed to and writes them as "proposed"; only
the user-facing PATCH on /api/coach/plans/[id] activates one. The free-text
cue / action / target are encrypted at rest with the shared Bytes codec;
metric, status and dates stay plain so the injection picker ranks without a
per-row decrypt. Active plans fold into the Coach snapshot's memory block
and survive the prompt-budget drop alongside facts. List, confirm and
soft-delete routes are Coach-gated and narrow the owner from the session.
Key the dashboard-snapshot query cell by locale so a locale switch reads
freshly localised tile copy on its own cell instead of serving the prior
locale's prose until the slot goes stale. The zero-arg key stays
byte-identical, so every prefix-based invalidation keeps reaching the
locale-keyed cells; the offline-banner and preloader match on the prefix.
Migration 0194 (additive CREATE TABLE). Encryption-rotation registry and
script cover the three new Bytes columns.
The demo account seeds rich sleep, steps, glucose, VO2max and workout
data but inserted no dashboard layout, so the home page rendered the
new-account default and under-sold the product on its primary surface.
Persist an explicit dashboard layout derived from the default with the
seeded-rich strip tiles forced on and the daily-verdict hero enabled, so
a fresh seed renders a full, lived-in dashboard for screenshots.
The v1.19.0 EcgRecording.waveformEncrypted Bytes column was not added to the
key-rotation script, so an operator rotation would strand ECG waveforms on the
legacy key and dropping it would make them permanently undecryptable — the same
class as the v1.18.1 clinical-spine gap. Wire the column into the rotation and
add a schema-driven coverage test that fails CI if any encrypted Bytes column
is ever left out again.
Round out the public demo so every feature is explorable:
- Glucose: a 14-day Nightscout-style CGM stream (a reading every 15 min)
alongside the existing spot panel, so the glucose surfaces render fully.
Tagged RANDOM to satisfy the glucose-context CHECK.
- Labs: a second older panel plus qualitative rows (negativ / nicht
nachweisbar) so the list, detail, sort and delete all have content and a
per-analyte trend.
- Illness journal: a resolved cold with a day-by-day symptom curve and an
active chronic condition carrying a recent flare.
- Cycle: opt the demo account in and seed five observed cycles with biphasic
basal body temperature, period flow, fertile-window mucus and OPK, per-day
symptoms, BODY_TEMPERATURE dual-writes, and a cached forecast.
Cleanup extended to the new tables in FK-safe order; the seed stays
idempotent across re-runs.
Mint an active AI-processing consent receipt for the demo user so the
Coach renders its sample conversation without a consent-accept write the
read-only tenant rejects. The receipt is kind `ai_full`, the master grant
the Coach and Insights gates both accept; the read path only checks an
active row of that kind, so a placeholder artefact suffices. Add a
consent_receipts reset to keep the seed idempotent.
Anchor every taken intake's scheduled_for on its canonical dose-slot
instant (08:00 morning, 20:00 evening, Europe/Berlin) with taken_at set
slightly later, so the row is slot-anchored and resolves its slot within
the dashboard's resolution radius. Previously today's morning rows used
scheduled_for = taken_at, which the scheduler reads as an ad-hoc take that
only resolves within a minute of its own instant, leaving the 08:00 slot
reading overdue while the take floated beside it. Today's picture now reads
on-track at any daytime clock, with the evening dose pending and not yet due.
The public demo has no live AI provider, so the Insights, daily-briefing,
and per-metric status surfaces rendered empty or prompted to connect a
provider. Pre-populate the same cache rows a real generation would write
so the demo shows finished, grounded English prose that survives every
re-seed without any outbound call:
- users.insights_cached_text: comprehensive insight + daily briefing
(paragraph, signals of the day, key findings, trend annotations), with
insights_cached_at refreshed so the read serves it as current.
- audit_logs: the seven per-metric status cards, stamped with today's
Berlin day key and a real model name so the cache read treats each as a
fresh assessment.
- insight_narratives: week and month narratives, AES-256-GCM at rest like
every other narrative row.
All prose follows the assessment voice: second person, grounded in the
seeded numbers, autonomy-supporting, never diagnostic. English only, the
demo locale. Adds insight_narratives to the cleanup block so the seed
stays idempotent.
One-shot prettier pass so the new format:check gate starts from a clean
baseline. Formatting only — no behaviour change. Generator-owned and
vendored artifacts stay untouched via .prettierignore.
Extend the demo tenant so it reads as a long-term, fully engaged user
across the whole app, not a thin sample. All series span the existing
ninety-day window with the latest point on today and sit in healthy
bands:
- Body composition (fat, fat-free, lean, muscle mass, total body water,
bone mass, visceral fat, BMI) derived from the same-day weight and
body-fat series so the numbers reconcile with the headline tiles.
- Blood glucose in a healthy non-diabetic band — fasting, post-meal and
bedtime readings stored in mg/dL with the glucose context tagged.
- Cardio fitness and vitals: HRV (SDNN and RMSSD), oxygen saturation,
respiratory rate, periodic VO2 max, active energy, walking/running
distance and flights climbed.
- WHOOP-style recovery, day strain and sleep performance/efficiency/
consistency scores aligned with the seeded sleep nights.
- Three to four workouts a week (running, strength, cycling) with
duration, energy, average and max heart rate, distance, a sparse
per-workout heart-rate sample series, and per-workout strain in the
metadata blob.
Clear the workout tables in the same truncate pass so the seed stays
re-runnable.
The demo seed unlocked twelve achievement ids that no longer exist in
the registry (first-measurement, week-streak-measurements,
three-month-streak, …), so the achievements surface rendered them as
unknown placeholders. Map the unlocked set to live registry keys —
first-entry badges, logging and in-range streaks, compliance and
miss-free streaks, plus the self-context and seven-night sleep badges —
each on a plausible unlock date the seeded history supports.
Tune the public demo tenant for App-Store screenshots:
- Extend the measurement loop to include today so every headline metric
has a current reading.
- Retarget blood pressure into the optimal band (~118/76), weight to a
stable healthy BMI, and add a clean resting-heart-rate series so the
resting-pulse tile scores it directly instead of the spot pulse.
- Write a full week of per-stage sleep nights (awake/REM/core/deep,
~7.5h, source APPLE_HEALTH) including last night, in minutes, and skip
the aggregate row for those nights to avoid double-counting.
- Schedule today's doses on-track: morning meds taken, the evening dose
pending and never auto-missed, so today never reads overdue or 0%.
- Add Vorsorge reminders (dental check-up, annual physical), one healthy
lab panel, one resolved illness episode, and an AI-configured Coach
with a short encrypted sample conversation.
All dates stay relative to now so the demo refreshes on every re-seed.
The demo seed's random-walk BP generator could drift below the
physiological floor and mint a systolic-0 row (iOS #33). Clamp the walk
to the same band the input validator enforces (systolic >= 40,
diastolic >= 20), reusing the VALUE_RANGES floors, so a fresh seed never
writes a sub-floor reading. Add a read-side selection guard on the blood
pressure series endpoint that excludes rows below those floors from both
the series and the implicit latest point, so a corrupt row that slipped
in before the floor was enforced can never surface as the latest
reading. Export VALUE_RANGES so both sites share one source of truth.
The rotation script omitted the four v1.18.1 clinical-spine note columns
(LabResult.noteEncrypted, Biomarker.contextEncrypted,
IllnessEpisode.noteEncrypted, IllnessDayLog.noteEncrypted) plus a longer
pre-existing tail (Nightscout/Polar/Oura credentials, the KVNR field, the
health-profile and cycle-day-log payloads, InsightNarrative content).
Rotation reported zero legacy rows while these still held old-key
ciphertext, so dropping the legacy key left them permanently
undecryptable (decrypt is fail-closed).
Introduce src/lib/crypto/encrypted-columns.ts as the canonical registry of
every reversibly-encrypted column, drive the rotation script from it, and
add a guard test that cross-checks the registry against every encrypted
column in schema.prisma and asserts the script references each one. A new
*Encrypted column not wired into rotation now fails CI by construction.
Add the webhook + email Settings copy and the admin notification-health
panel labels across all six locales. Add the SMTP_* operator vars to the
env manifest (all-or-none: HOST + PORT + FROM enable the channel,
USER/PASS optional), to .env.production.example, and to the docker-compose
environment whitelist so an operator's .env values actually reach the
container.
Connect an Oura account over the Cloud v2 API: an authorization-code OAuth
flow (shared app from OURA_CLIENT_ID / OURA_CLIENT_SECRET) on the same signed
state machinery as Polar. Unlike Polar, Oura issues refresh tokens; the sync
refreshes reactively on a 401, rotating and re-encrypting both tokens, and a
403 stays a hard reauth rather than a refresh.
An hourly poll-cohort job maps daily readiness to RECOVERY_SCORE, sleep to
per-stage durations plus efficiency / HRV / resting heart rate / respiratory
rate, and daily activity to steps and active energy — all tagged source OURA,
idempotent on (userId, type, source, externalId). Readiness flows through the
canonical recovery ladder above the computed proxy. Settings gains a connect /
disconnect card. Tokens are never logged or returned; every egress goes
through safeFetch.
Connect a Polar account over AccessLink v3: an authorization-code OAuth
flow (shared app from POLAR_CLIENT_ID / POLAR_CLIENT_SECRET) backed by a
stateless HMAC-signed state token doubling as an httpOnly cookie for CSRF.
The callback verifies the signature, expiry, provider binding, and the
byte-exact cookie match, then encrypts the access token plus member id onto
the user row.
An hourly poll-cohort job maps Nightly Recharge recovery, sleep stages,
and daily activity to canonical measurements tagged source POLAR, idempotent
on (userId, type, source, externalId). The recovery band feeds RECOVERY_SCORE
through the canonical ladder, which now ranks native readiness above the
computed proxy. Settings gains a connect / disconnect card. Tokens are never
logged or returned; every egress goes through safeFetch.
The app-wide service-worker work routes the fallback literal through a
`@sw-version-fallback` block-comment anchor that the prebuild generator
keys on, placed between the `||` and the literal. The pre-existing
fallback-shape assertion predates the marker and matched only the inline
form, so it failed on the marker comment. Widen the pattern to allow the
optional anchor while still pinning that a reachable "v" literal exists.
`public/sw.js` carries a `|| "..."` fallback literal used for `CACHE_VERSION`
when `importScripts('/sw-version.js')` is unavailable. The literal had drifted
to `v1.4.43`, four-plus minors behind `package.json`; in any environment where
the generated file is missing this keyed every cache to the stale version and
turned the activate-step eviction into a no-op — the exact bug the generator
was written to kill.
Extend the prebuild generator to rewrite the fallback literal in `sw.js` to the
current `package.json` version on every build, anchored on a
`/* @sw-version-fallback */` marker (the build fails loudly if the marker is
gone). The literal and the generated `sw-version.js` can no longer diverge.
The SW test now reads the fallback straight from the source so it tracks the
literal instead of hard-coding it.
/settings/mood manages the whole tag vocabulary: own groups (encrypted
labels, twelve per account), custom tags with a searchable icon picker
over a 75-icon catalog, hide and unhide of catalogue tags, archive
semantics that never touch historical entries (hard delete only behind
an explicit second confirm), and drag-or-arrow ordering of groups and
tags shared by the picker. One GET serves the fully ordered tree —
groups, placements, hidden and archived state — so the entry form, the
settings section and a native client all read the same resolved layout.
The picker finally renders custom tag labels (they were ignored since
v1.13.0) and offers inline tag creation per group. The mood-tag API
enters the OpenAPI contract for the first time, and the key-rotation
script now covers both encrypted tag and group labels — custom tag
labels had been missing from it since v1.13.0.
The self-context adopt runs read-modify-write inside one transaction,
the admin shell renders nothing until the role is confirmed, and the
json-body helper merges Headers instances correctly. The compose image
reference accepts a pinned digest through an env var, and a promote
script resolves the published digest, verifies its signature and hands
the operator the exact pinning call — the documented path away from a
moving latest tag.
A typed apiFetch wrapper owns the envelope unwrap, the ok-check and the
error read for every client call — two hundred call sites across the
app migrate onto it and a lint rule keeps raw api fetches out for good.
The legacy timezone helpers fold into the tz module and test fixtures
drop personal names.
The five-thousand-line route registry splits into thirteen domain
modules and the query-key factory into twelve feature files — each
behind an index that preserves exact registration order, proven by a
byte-identical generated contract. The factory lint rule learns the new
home directory.
Editing a schedule's times no longer rewrites the past. Each material
change archives the outgoing cadence as a dated revision, and every
band consumer — history, compliance, write attribution, the auto-miss
cutoff — mints past periods from the schedule that was valid at the
time, so a dose era taken at its then-correct times stays on time
forever. The repair script can infer historical eras from consistent
old anchors behind an explicit flag. The welcome tour scrolls its
target into view, flips above it when space below runs out and falls
back to a centred sheet on any viewport, so no step can strand its
buttons below the fold. The avatar menu drops the about entry — that
lives in settings.
The detail page gains a dedicated API tab, the full-edit button becomes
a lifecycle row that opens the editor, the danger zone drops its red
wash in favour of the shared card chrome, and the card menu's edit
entry leads to the detail page it describes. The inventory tab finally
records stock — packs and pens with optional expiry, plus corrections —
instead of pointing at an API. The card's next-dose line reads from the
canonical band model instead of a stale schedule window, which also
showed old dose times after a schedule change; the repair script learns
to reconcile drifted windows and tombstone pending rows parked on
abandoned anchors.
Every standalone intake anchors its slot on the take time since
v1.15.19, so guarding the nightly dedup on that shape excluded ordinary
drift duplicates from collapsing — the original point of the sweep.
Releasing a pinned dose now persists the user's choice on the row, the
dedup and the repair script key their exclusion on that flag, the read
model reports a released row as deliberately off-schedule instead of
re-snapping it within the anchor epsilon, and the attributed badge only
shows on rows actually bound to a slot. A few fixture names and stray
comments also drop personal references.
A deliberately detached ad-hoc intake could be folded back into its slot
cluster by the nightly dedup, and a pinned row could lose its slot to a
sibling on the sync-version tiebreak — pins now outrank every other row
and detached rows never join a cluster, mirrored into the operator
repair script. Pinning onto an already-served slot no longer overwrites
the existing record silently; the API refuses with a clear error while
an identical re-post stays idempotent. The idempotency cache also
refuses to store invite-token-shaped responses.
First load showed bare dark tiles, then the mood chart finished alone
and the remaining charts popped in one after another. Every widget now
renders a structured silhouette of its final shape, charts mount in
parallel but stay behind height-stable skeletons until all visible
charts report ready — with a two-second fallback so one slow widget
cannot hold the page — and the reveal fades in once, without layout
shift. The intake repair script becomes self-contained on plain pg so
it actually runs inside the production container, with the runbook
updated to the working invocation.
repair-intake-anomalies.ts reports duplicate live rows sharing a slot
anchor and intake rows whose taken time sits implausibly far from their
slot. Dry-run by default; --fix tombstones duplicate losers with the
same precedence the nightly dedup applies and recomputes the affected
compliance rollup days; implausible rows are only listed unless
--tombstone-implausible is passed explicitly. Runbook under docs/ops.
Blood-pressure fixtures in the in-target integration suite were copied
from live readings; they are now synthetic with the same in/out split so
every assertion holds. Hard-coded operator hostnames move behind env vars
(DEPLOY_LOGS_URL, HEALTHLOG_PROD_URL), the codex client identifies via
the public repository URL, the OpenAPI servers entry points at a neutral
example host, and remaining workflow, docs and comment references to the
operator's instance or working notes are generalised. The compose
whitelist also gains DEPLOY_WEBHOOK_SECRET, which was missing and made
the deploy webhook unusable on compose installs.