Commit Graph
2939 Commits
Author SHA1 Message Date
Marc-André Bombeck 6e1945e8a0 docs(workouts): correct the seam comments now that the card can mount 2026-07-19 14:59:24 +02:00
Marc-André Bombeck e44cf8b804 test(workouts): guard the insight queue against silent provisioning loss
Nothing warms this queue and no read path triggers it, so an unregistered name
fails open in the worst way: every send resolves, every job vanishes, and the
symptom is indistinguishable from the surface's honest empty state.
2026-07-19 14:55:46 +02:00
Marc-André Bombeck 69b61770c4 test(workouts): pin the insight card's text-children rendering
The paragraph is model output. Rendering it as markup would be an XSS surface,
and this project ships no markdown library so the question stays closed — the
test is what keeps that from being treated as a formatting decision.
2026-07-19 14:48:08 +02:00
Marc-André Bombeck 87cda9b7a7 test(arrivals): assert the workout dispatch, not its placeholder label 2026-07-19 14:33:30 +02:00
Marc-André Bombeck bec7538012 fix(workouts): enforce the numbers-only evidence claim in code
The projection's central claim was carried by a comment. It is now walked
before it is returned: any non-numeric leaf at any depth throws, and the only
two string leaves are allowlisted by shape — the sport type against the schema
enum itself, and the day key against its format.

This matters because sport_type is a TEXT column whose API write path is
enum-constrained but whose backup-restore path accepts any non-empty string, so
a restored row can carry arbitrary text. It is re-asserted at the projection
boundary rather than trusted from the column. The heart-rate provenance string
is dropped rather than allowlisted, and route geometry stays consumed-not-carried
— it yields one number, and location history may not ride a prompt.

Own-history rows now collapse cross-source twins through the canonical picker,
as the detail page's comparison already did; a session recorded by two paired
watches was inflating the sample size and biasing every median.
2026-07-19 14:27:59 +02:00
Marc-André Bombeck 048d7a4d84 Merge remote-tracking branch 'origin/main' into work/v131-slice3-activity 2026-07-19 14:16:29 +02:00
Marc-André Bombeck 6fee8ad091 test(workouts): pin each rung of the activity-insight gate stack
One test per gate, each asserting the refusal reason AND that the provider
entry was never reached — a gate that refuses but still resolves a provider
would pass a reason-only assertion while costing what the gate exists to
prevent. The cap and the hash are asserted independently of each other, since
the singleton queue key that normally collapses a burst is best-effort.

The backfill fixture walks the real chain: 750 historical sessions plus 40
re-synced ones plus one fresh, through the live classifier and the live spine
worker, yielding exactly one generation.

The peak detector changed under test: on a flat ride the ninetieth percentile
sits a beat above the mean, so a bpm of sampling noise read as two efforts the
rider did not do. Peaks now need an absolute prominence over the session mean
as well.
2026-07-19 14:03:52 +02:00
Marc-André Bombeck 1a469301b3 feat(workouts): generate the per-workout insight on arrival, never on view
The spine dispatches a generator-free enqueue; the worker resolves the
provider. Five gates stand in front of it in ascending cost order — both
modules on, ten-minute duration floor, a hard count of four a day in the
user's own timezone, an input hash over the deterministic evidence, and the
token ledger inside the shared status chokepoint. Each refusal returns a
status rather than throwing, because retrying against a ceiling that does not
move until midnight is an unbounded loop.

The detail route only reads: a workout with no row serves null and renders no
card, which is every workout that predates this and every re-synced one. An
undecryptable paragraph degrades to no card rather than failing the page.
2026-07-19 13:56:50 +02:00
Marc-André Bombeck e53a8ce8a0 feat(workouts): add the activity-insight evidence projection and prompt
The evidence block is a numbers-only server projection: zone seconds, the
workout-window heart-rate shape, route climb and a same-sport 90-day own-history
median. Free-text metadata never enters it, and the sport string is narrowed
against the closed enum first, so the payload carries no text the user did not
generate through a measured field.

The prompt composes the shared assessment base and adds the session contract:
device-attributed, own-history-comparative, effort acknowledged where the
numbers show it, and no training prescriptions of any kind. Registered in the
tone harness beside the assessment cards.
2026-07-19 13:50:54 +02:00
Marc-André Bombeck ee6ae9c450 Merge remote-tracking branch 'origin/work/fallback-locales' into release/v1.30.35 2026-07-19 13:49:53 +02:00
Marc-André Bombeck bfb4dbd7da Merge remote-tracking branch 'origin/work/v131-slice5-copy' into work/v131-slice3-activity 2026-07-19 13:44:20 +02:00
Marc-André Bombeck 8b13b46ac7 feat(insights): write the no-key fallbacks in every shipped locale
The deterministic fallbacks nine metric surfaces render when no provider is
reachable carried German and English bodies only. `getLocalizedText` returned
`locale === "de" ? de : en`, so French, Spanish, Italian and Polish readers got
English — on the no-key path, an outage, a spent budget or a timeout, which is
precisely when this text is the whole assessment.

Every string now has a body per locale: the verdict leads and value sentences
of the signal-grounded composer, the per-metric labels and pointers, and the
eight no-signal floors. Selection reads the reader's locale; English stays as
the structural floor for a locale that is ever genuinely missing a body.

The bodies are written per language, not translated word-for-word. fr/es/it
replace the comparative adjective ("higher than your usual average") with a
preposition — au-dessus de / por encima de / sopra — because a comparative
would have to agree in gender with the metric noun, and the labels do not share
one. Polish restructures further: the sentence takes `wynosi`, which is
invariant for the subject's gender, and the comparison uses powyżej / poniżej
with the genitive; the labels span all three genders, so nothing that inflects
can sit in a shared template.

The tone harness grades the four locales by the same rules rather than passing
them by default: false cheer, clinical-verdict and imperative-opener vocabulary
extended to all six, the no-assessment sentinel keyed per locale instead of a
de/en binary, and a byte-identity guard that fails if any locale silently falls
back to the English text again. The value-led-opener rule was rewritten as
well — it assumed the copula sits against the figure, which under-matched its
own second locale (German "liegt aktuell bei 24") and would have missed all
four new ones.
2026-07-19 13:42:09 +02:00
Marc-André Bombeck f9112925d2 test(workouts): count the source-priority read by shape, not by call total
The batch route now also resolves the user's timezone and module map through
the arrival seam, so a raw call count on user.findUnique no longer isolates
the read this guard is about. Filtering by the SELECT shape keeps the
once-per-batch contract pinned while letting the unrelated cached lookups
through.
2026-07-19 13:40:18 +02:00
Marc-André Bombeck 80e88cfb23 fix(insights): warm the deterministic fallbacks and re-fingerprint the cards
The no-signal floors were the coldest text the product renders. They run when
there is nothing to ground against, and they opened on a bare order — "Measure
blood pressure at rest and under comparable conditions", "Evaluate mood trends
over several weeks" — so the one moment the surface has nothing to say was also
the one moment it told the reader what to do.

They now open on what the surface can honestly say: no assessment is available
right now. That is the meaning-first opening for this case, and it is the only
one available, because a floor with no signal may not imply a read. What
follows describes how the measure behaves rather than ordering the reader
around. The signal-grounded lines above them are unchanged.

The prompt version bump re-fingerprints the assessments cached under the old
biomarker prompt; the per-card input hash folds the constant in, so the
regeneration is one-time and paced by the nightly warm.
2026-07-19 13:25:32 +02:00
Marc-André Bombeck ccd3c5e68f test(arrivals): pin the spine's guardrails against heavy fixtures
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.
2026-07-19 13:24:22 +02:00
Marc-André Bombeck 5d62236412 feat(arrivals): add the data-arrival event spine
Every ingest path in the product can now emit a typed, salience-classified
event into pg-boss, consumed by one worker that claims a durable per-day
reaction marker and fans out to the reaction surfaces.

The recency classifier runs at the seam, before anything is enqueued, so a
mass backfill — a multi-year Apple Health export, a provider re-sync, a
30-day catch-up — emits nothing at all. Non-salient writes are annotated and
dropped.

The worker makes no provider call by construction: it imports no provider
module, writes only markers, and enqueues nothing that is not budget-gated
downstream. Business refusals return a status rather than throwing, so a
refusal is never retried against a ceiling that will not move until the local
day rolls over.

Seams: sleep (one hook, inherited by all seven transports), workouts (the
batch route, the four provider syncs, the export importer), weight and blood
pressure (both measurement routes), labs (manual, OCR commit, document fact
commit).

The provider workout syncs could not previously tell a created row from an
updated one, so a re-sync of a recent session would have re-fired on every
poll; they now recover the distinction from the row the upsert already
returns.

Reaction lines are AES-256-GCM at rest, following the InsightNarrative
precedent, and the column is registered for key rotation. Markers are pruned
after 14 days by a daily maintenance job.
2026-07-19 13:17:34 +02:00
Marc-André Bombeck 1b50dc0909 test(insights): add the per-metric tone harness
A deterministic grader over the assembled assessment prompts and the
deterministic fallback text. No provider, no network, no database — it runs in
the normal suite in under half a second, which is the point: the live-judge
eval needs a key and can only advise, so the regressions that actually shipped
need a check that can block.

Three classes are blocking. A surface stops leading with meaning (it drops the
shared opening contract, or its user prompt loses the meaning-first
instruction). A locale collapses (fr/es/it/pl fall back to the German body or
lose the reply-language directive). A prompt reintroduces a value-first
instruction — matched with negation handling, since the shared prompts quote
that failure mode in order to ban it.

The deterministic fallbacks are graded on the same axis, including the
asymmetry that matters: a signal with no baseline supports no confident read,
so that one line honestly opens on the value. Pinning it stops a future
"always lead with meaning" edit from pushing the surface into claiming
something the data does not support.

A coverage guard fails when an assessment prompt module has no harness entry,
so the next surface cannot stay uncovered the way the biomarker card did.
2026-07-19 13:16:41 +02:00
Marc-André Bombeck ed049661b7 feat(insights): bring the biomarker card onto the shared assessment contract
The per-biomarker assessment was the last surface in the family building its
prompt inline: a five-line scaffold that skipped the base system prompt, took
no opener hint, and instructed the model to "state the current value" — so the
one card where a bare number reads most like a verdict was value-first by
instruction while every sibling had moved to meaning-first.

It now composes exactly as the seven bespoke cards do. The new prompt module
carries a METRIC section that states the one thing this card needs and the
others do not: its snapshot has no signal block, so the read comes from the
pre-computed range placement plus the direction across draws.

Also point both archetype-driven cards at a registered budget. The generic
metric card and the biomarker card each carried temperature/maxTokens inline
at 0.45/1000, so the registry that documents every surface's ceiling was
silently wrong about the two widest assessment surfaces.

The single-marker no-key fallback replaces the multi-metric overview tip the
card used to render — text about watching several metrics at once, on a card
showing one lab value.
2026-07-19 13:14:23 +02:00
Marc-André Bombeck d68a7ca244 Merge remote-tracking branch 'origin/work/parity-contracts' into release/v1.30.34 2026-07-19 12:46:10 +02:00
Marc-André Bombeck 4c65949bac Merge remote-tracking branch 'origin/work/parity-coherence' into release/v1.30.34 2026-07-19 12:46:04 +02:00
Marc-André Bombeck 670e7097a2 fix(settings): destructive session and device controls ask before firing
Signing every other device out, revoking a single session, revoking a
trusted device and revoking them all each fired on a single tap. None is
undoable — the other devices simply have to sign in again, and a revoked
trusted device asks for its second factor next time — while every other
destructive surface in the app confirms first. On a shared screen a
mis-tap is a support call.

A sweep of the rest of the tree found two more hard deletes with neither
a confirmation nor an undo: a Coach reminder and a travel entry. Both now
confirm. Deleting a document deliberately does not: it soft-deletes and
offers an undo, which covers the mis-tap.

The four controls share one ConfirmButton rather than four copies of the
dialog, so the next destructive control gets the confirmation by using
it instead of by someone remembering. Copy is per control and states the
actual consequence — no claim of more finality than is true.
2026-07-19 12:46:04 +02:00
Marc-André Bombeck 30954901ac chore: reconcile the catalogue and metric-unit guards
The widget catalogue grew to 42 ids, so the snapshot round-trip count
follows. The mood card's unit key carries "/5" rather than an empty
string — the locale-integrity guard forbids empty values, and the scale
is the honest thing to print next to a 1-to-5 score. It joins the
identical-across-locales allowlist alongside the other unit notations.
2026-07-19 12:44:23 +02:00
Marc-André Bombeck 4c3cc84daf feat(medications): carry manufacturer and printed strength on an inventory container
The native pen list renders these two as its headline and subhead. The
server held neither, so a container registered on the web could not be
shown there without fabricating an empty card, and the client kept its
own copy locally with no back-fill possible.

Both columns are additive and nullable (migration 0255). Every existing
row is truthfully NULL — the values were never collected, and a plain
supply row such as a blister pack or a bottle has no use for either.

Strength stays free text rather than a parsed number + unit: pens state
it per dose, per ml, or per cartridge depending on the product, and
forcing a numeric split would lose which of the three was printed.

On PATCH an omitted field leaves the stored value untouched, the same
rule notes already followed, so a client that does not know the fields
cannot blank what another populated; an explicit null still clears. The
web add-container form collects both for a PEN, so the columns do not
ship empty forever.
2026-07-19 12:28:48 +02:00
Marc-André Bombeck e9d28b399e fix(copy): make delete confirmations, sleep-debt labels, and the glucose band honest
Three surfaces where the words no longer matched the code, plus the missing
web control for a preference the server already honours.

Delete confirmations. Four dialogs promised permanence on surfaces that
render an Undo: the measurements and mood bulk bodies, the lab-result body,
and the illness-episode body (which said "cannot be undone" sixty lines
below the code that fires the Undo toast). All four now use the same
sentence the single-row measurement and mood dialogs already used. A sweep
of every other confirmation in the tree found the rest accurate — the
biomarker cascade, the mood-tag purge, and the account purge keep their
warning because those deletes really are unrecoverable.

Sleep debt. v1.19.0 replaced the summed shortfall with a running balance
that credits catch-up sleep; the labels still described the old sum, so the
word contradicted the number for anyone who slept in after a short night.
Corrected in all six locales, plus the places the old semantics had
propagated: the Coach system prompt, the OpenAPI field descriptions the
native client reads, and four module docblocks.

Glucose reference band. `hasDiabetes` selects the tighter reference band and
grounds the Coach, but no web surface set it — a web-only account was pinned
to the non-diabetic scale with no way out. The control goes on Targets,
above the per-metric editor, because it answers the same question that page
answers and the two are ordered: it picks the default band, an override
below beats it. The copy says what it is — a reference band, not a
diagnosis, never inferred from a reading, no clinical action taken on it.

The passkey list was reported as always reading "never used". It does not
reproduce: the column is selected by the list route and stamped on every
verified assertion, both since v1.23.0. Pinned with tests on both halves so
the contract cannot quietly regress.
2026-07-19 12:28:05 +02:00
Marc-André Bombeck 4ddc08f784 feat(dashboard): emit mood and BMI on the summary payload
Both metrics exist in the record but the summary route emitted neither,
so a native client could render every home-screen tile except those two.

Mood reads the canonical daily series (`buildMoodDailySeries`) — the same
rollup-backed aggregation the web mood analytics and the dashboard
snapshot render — and emits the newest day's score with a trailing
seven-day sparkline. BMI comes from `computeBmi`, the one implementation
the web vitals dashboard already renders through the derived-metric
dispatch; the route resolves the figure so no client re-derives weight
over height squared.

Mood gates on the mood module through a synthetic `MOOD_ENTRY` key, the
same route `NUTRIENT_WATER` takes, because mood is not a measurement
type; without the join the card would ship past a disabled module. BMI
belongs to no module and rides through with the other core vitals.

The BMI card is omitted rather than emitted empty when the profile has no
height or no weight in the window: the card shape carries no honest
"unknown" state, and a null value would read as a logging gap rather than
a missing profile field.
2026-07-19 12:24:07 +02:00
Marc-André Bombeck 8168e60dfd fix(dashboard): stop the widgets PUT discarding a comparison baseline
The layout PUT preserved chartOverlayPrefs, selectedScoreRings and
heroRingOrder when a client omitted them, but the top-level
comparisonBaseline was not in that merge. serializeDashboardLayout clamps
a missing baseline to "none", so a layout save from a client that does
not know the field — the native client treats it as web-only and never
sends it — silently reset the comparison the user had picked on the web.
No error, no audit row.

Replace the hand-maintained three-field merge with an explicit
disposition map over every top-level layout field. The map is declared
`satisfies Record<keyof DashboardLayout, ...>`, so adding a layout field
without deciding replace-or-preserve fails typecheck; the preserve set
and the merge are derived from it rather than restated. The stored-layout
read is skipped when the client sent the full set.

Also catalogue the four clinical signals the native client pins — grip
strength, pain NRS, waist circumference, waist-to-height. They reached
the route as unknown ids, were filtered out ahead of Zod, and vanished
from the persisted layout, so a tile placement was lost on every save.
They join the iOS-only set because the web dashboard has no render path
for them; their charts live on the insights sub-pages. Raise the widget
array cap to 50 to clear the now-42-id catalogue.
2026-07-19 12:19:17 +02:00
Marc-André Bombeck fd9603a578 chore(release): v1.30.33 — the session cookie becomes a secret
Also closes the device list's exposure of the same value, refuses a
cross-device refresh rotation, replaces a vacuous expiry test with one
that fails when the check is removed, and asserts the Nightscout host
guard at save time rather than relying on the probe's placement.
2026-07-19 12:09:17 +02:00
Marc-André Bombeck 03fdcddb92 Merge remote-tracking branch 'origin/work/session-secret-and-nightscout' into release/v1.30.33 2026-07-19 11:59:44 +02:00
Marc-André Bombeck 2ab21bb433 chore(release): v1.30.32 — consent that stays withdrawn; quotes that come from the document 2026-07-19 11:59:28 +02:00
Marc-André Bombeck 0627648bea Merge remote-tracking branch 'origin/work/document-and-model-correctness' into release/v1.30.32 2026-07-19 11:52:41 +02:00
Marc-André Bombeck 306d1f7443 Merge remote-tracking branch 'origin/work/contract-and-prefetch' into release/v1.30.32 2026-07-19 11:52:41 +02:00
Marc-André Bombeck 1c2cfffdb8 fix(consent): a withdrawn AI consent stays withdrawn, and the web can withdraw
The settings mount fires an idempotent consent heal so an account that
predates the gate does not fall into the no-key state. It looked only for
an active receipt, so a deliberate withdrawal — which sets revokedAt —
read the same as never having consented, and the next visit to the AI
settings granted again, writing an audit row for an act the user never
performed. GDPR Art. 7 (3) puts withdrawal on the same footing as the
grant; one undone by a page load is not a withdrawal.

The mint now distinguishes the two callers. The mount heal grants only to
an account with no consent history at all and leaves a revocation
standing; switching document auto-read on is the consent act itself and
may supersede an earlier revocation. The revocation is re-checked inside
the transaction so a withdrawal landing mid-mint is not overwritten.

The revoke endpoint has existed since v1.4.40 but only the native client
called it: on the web, consent could be given and never taken back. The
AI settings now carry the standing decision and the control that changes
it, in all six languages.
2026-07-19 11:52:34 +02:00
Marc-André Bombeck a8b0365ca5 fix(auth): carry a generated secret in the session cookie, not the row id
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.
2026-07-19 11:40:56 +02:00
Marc-André Bombeck fd6a80a921 fix(nightscout): assert the public-host floor before persisting the URL
Egress was already correct — the client fetches through safeFetch with
requirePublicHost pinned off the user's opt-in, manual redirects and a
timeout — but the check on the value we STORE was only a side effect of
the live probe the connect route happens to run first. That makes the
guard contingent on the probe's placement rather than on the input.

Run isPublicUrl at input time, gated on the same self-hoster opt-in, so
the URL that gets encrypted onto the row is the URL that was validated.
Cover the alt-notation classes the helper knows about; each case asserts
the probe was never reached, which is what distinguishes the input-time
refusal from the fetch-time one.
2026-07-19 11:40:56 +02:00
Marc-André Bombeck 0fca5ca207 fix(auth): bind a live refresh token to its issuing device
Rotation refused a replayed token under a spoofed device id (M-4) but
still rotated a *live* one presented from a different device, and then
inherited the caller's id onto the new row — putting the resulting family
outside the victim device's containment scope. Refuse the mismatch
outright when both ids are present; an absent id on either side stays
unattributable and rotates as before, so a client that sends no
X-Device-Id is not locked out.

The expiry refusal had no coverage at all: the standing test presented a
token that was never issued, so it passed on not_found and stayed green
with the expiry branch deleted. Replace it with the real secret plus a
just-inside-the-window companion, and cover the device binding.
2026-07-19 11:40:56 +02:00
Marc-André Bombeck b00187e659 docs(api): correct the drifted request/response contracts
Field-by-field comparison of the handlers against the registered Zod
schemas across measurements, workouts, medications, daily and auth. The
spec was corrected to match the handlers; no runtime behaviour changed.

Measurements:
- GET /api/measurements returns `{ measurements, meta: { total, limit,
  offset, … } }` on all seven of its modes. The spec claimed a top-level
  `total` that no arm has ever emitted.
- The list rows are stored rows minus the notes ciphertext, or synthesised
  day-sum / sleep-segment rows, or pre-folded aggregate buckets. The
  resource declared seven fields; the rest are now on the wire contract.
- POST accepts a bare ARRAY body (the combined blood-pressure write) and
  answers with an array, and returns 409 on the dedup index.
- GET/PUT/DELETE /api/measurements/{id} carried no path entry at all.

Workouts: both GET operations run the module gate and answer 403
`module.disabled`; the reusable block existed but was never spread. The
batch ingest stays deliberately ungated and undocumented for it.

Medications: `nextDueAt` / `nextDueOverdue` are computed by the read paths
and absent from the create/update results, so they cannot be required on
the shared shape (the list row keeps them required). `stockUnitsRemaining`
sums a Decimal(12,4) and is fractional, not an integer. The cadence chips
object documented four fields that do not exist and missed the five that
do. `IntakeSource` admits APPLE_HEALTH.

Auth: the token bundle had its optionality inverted — `user` is always
returned, the token pair only for native callers. Adds the undocumented
403 `oidc_only`, the passkey 403/404, the TOTP enrolment 409s, the
attestation 400, the no-key-registered 409, the security-key 404s, the
refresh `revoke` response shape, and the path/query parameters three
operations read but never declared.

Daily was checked and is accurate; so are the batch ingest, series,
series-batch, bulk-delete, restore and by-external-ids routes.
2026-07-19 11:15:17 +02:00
Marc-André Bombeck eb5f81c84a fix(rollups): let a recompute replace every bucket in its window, not just the ones it recomputed
persistRollupRows scoped its delete to the [min, max] bucket range of the rows
the aggregate RETURNED. That made the repair self-limiting in exactly the case
that needs it: a recompute could only overwrite a bucket the live aggregate
still produces, so a bucket whose source rows had all gone was never in the
delete range and kept its stale aggregate.

Three shapes of the same hole. An empty aggregate skipped the write entirely
(the caller short-circuited on rows.length === 0). A returned range narrower
than the requested window left the edge buckets untouched. A type that lost
every reading in the window dropped out of the returned rows, so the per-type
delete filter could never reach it.

The DAY write hook masked it — it deletes its own bucket by hand when the day
empties — but nothing covered the WEEK / MONTH / YEAR worker, which recomputes
one bucket span at a time: deleting the last reading of a type in a week
dropped the DAY bucket and left the coarser ones stale forever. The coverage
probe still reported hasBuckets = true, so the read-swap served them.

A recompute now prunes the window it was asked for and writes its rows in the
same transaction. The single-bucket write hook keeps its own scope, which is
correct there because it already deletes its bucket when the aggregate empties.
Still one tier: the rollup replaces the legacy read and falls back to live SQL
only on a coverage miss.
2026-07-19 11:12:49 +02:00
Marc-André Bombeck 80258914f2 feat(documents): catch up unread documents when auto-read is switched on
The per-document summary job is enqueued at upload time and no-ops while the
documentsAutoAiRead opt-in is OFF. Nothing ever re-enqueued it, so the switch
only applied to documents uploaded AFTER the flip — a user who filled the vault
first and opted in later had documents that would never be read, and the
setting looked inert.

A genuine OFF->ON transition now schedules one bounded catch-up pass over the
user's un-summarised documents. It grants nothing of its own: it only enqueues
the ordinary summary job, which still re-reads the opt-in, re-asserts the
per-document egress consent and reserves the daily token budget. The pass
itself re-reads the opt-in first, so a toggle flipped back OFF before it runs
egresses nothing.

Bounded by an id-cursor walk capped at 200 documents per pass (a summary is a
provider call, unlike the local-compute thumbnail backfill). Idempotent at four
levels: the trigger fires only on a real transition, the per-user singleton
collapses a double toggle, the candidate set excludes summarised documents, and
the summary job skips one that already has a summary.
2026-07-19 11:09:57 +02:00
Marc-André Bombeck 8e938a4f45 fix(documents): anchor extracted-fact provenance to the real document span
Every staged fact carried a sourceText the model echoed back, stored verbatim
as 'the exact verbatim span of the document the fact came from'. Nothing
checked that the span existed. A paraphrase, a normalised rendering, or an
outright invention was persisted and shown to the reviewer as a transcription
of their own clinical record — an anchor pointing at nothing, or at a span the
document never contained.

Treat the echo as a lookup key instead. anchorSourceText resolves it against
the extracted text (exact, then whitespace-collapsed and case-folded to absorb
OCR reflow) and stores the span the document actually carries, with its offset.
A quote that matches nothing is dropped and the fact is flagged unanchored;
vision mode has no extracted text at all, so its facts are unanchored by
construction. Rows written before this resolve to unanchored on read — their
quote is preserved but never presented as checked.
2026-07-19 11:06:14 +02:00
Marc-André Bombeck d40cb3eef3 fix(ai): align the corrective retry prompt with the validator that grades the reply
The retry suffix still described the strict insight wrapper that was removed
long ago: it demanded citations[] and warnings[] — fields insightResultSchema
does not have — required metricSource, rationale, id and severity on every
recommendation when all four are optional, and named none of the five fields
the live schema actually requires (classification, findings, correlations,
dataQuality, disclaimer).

So the one corrective pass steered the model away from the contract it was
being corrected towards. A reply that obeyed the correction could not
validate, fell through parseComprehensiveResult's raw-object escape hatch,
and the downstream surfaces read an unvalidated payload.

Describe insightResultSchema and nothing else, and carry the contract as data
in RETRY_CONTRACT_EXAMPLE so a test can assert prompt and validator agree.
2026-07-19 11:01:57 +02:00
Marc-André Bombeck 860cb61b0b fix(proxy): pin no-store on every session-gated page
The dashboard, Insights, the workouts list, the medications list and the
Coach server-prefetch their data and serialise one account's health record
into the HTML document through a TanStack HydrationBoundary. Those pages
are dynamic today only as a side effect of reading the session cookie —
a property of the page code, not a guarantee. A stray revalidate export, a
force-static segment, or a caching proxy in front of the app would be
enough to retain one account's record and hand it to the next caller.

Stamp `Cache-Control: private, no-store` at the edge instead, driven by the
same predicate that governs the auth redirect, so a page cannot be
session-gated without also being uncacheable. Public legal, auth and
locale-catalog surfaces keep their cacheable posture; API routes keep
managing their own caching.

Two guards: the proxy test pins the header on gated pages and its absence
on the public ones, and a structural test walks the app tree and refuses a
prefetching page that sits on a public path or opts into static /
revalidated rendering.
2026-07-19 10:59:40 +02:00
Marc-André Bombeck 02a56047a4 Merge branch 'main' into release/v1.30.31
# Conflicts:
#	CHANGELOG.md
#	docs/api/openapi.yaml
#	package.json
#	public/sw.js
2026-07-19 10:49:00 +02:00
Marc-André Bombeck 54634a4f12 Merge branch 'main' into release/v1.30.30
# Conflicts:
#	CHANGELOG.md
#	docs/api/openapi.yaml
#	package.json
#	public/sw.js
2026-07-19 10:32:59 +02:00
Marc-André Bombeck cd502d5aef Merge branch 'worktree-agent-a908bda23e0007c88' into release/v1.30.31 2026-07-19 10:07:42 +02:00
Marc-André Bombeck 6f3d06c39b Cover the summary DTO's decrypt-failure degrade
A READY row whose ciphertext will not open reports UNAVAILABLE, so the view
does not render a heading over nothing. Nothing asserted that, and removing the
degrade left the suite green.
2026-07-19 10:06:33 +02:00
Marc-André Bombeck d773f460e5 Tell the four document-summary outcomes apart
Opening a document said "the summary is being prepared" and kept saying it.
The view had one fallback for a null summary column and used it for every
outcome: a document nobody ever enqueued, a missing vision provider, a spent
budget, a withdrawn consent, an unreadable file, a provider error, and a
summary the outbound screen withheld all rendered as work in progress.

The common case is not the screen. The background job is enqueued at exactly
one place — a fresh upload with the auto-read opt-in already on — and no
backfill reaches anything else. Every document uploaded before the toggle went
on has a null column no job will ever fill, and the view promised all of them a
summary that was never coming.

Record the outcome as `summary_state` (migration 0254, additive, defaults to
NONE which is truthful for every existing row) and branch on it. Only PENDING,
set when a job genuinely exists, may claim generation is running. NONE,
WITHHELD and UNAVAILABLE each say what happened and offer to generate one.

The withhold keeps its rule and loses its dead end. Blocked prose is still
never shown and never stored — only the fact of the refusal is kept, and it is
no longer terminal, so asking again re-runs the gauntlet. That was the original
intent; leaving the column null defeated it, because null read as "generating"
and the guard meant nothing would ever overwrite it.

The on-demand summary now persists what it produces. It is the only repair path
for the documents the job never ran for, and re-deriving the same paragraph on
every open costs a provider call for nothing. Generation is still only ever a
click — nothing runs because a view rendered. Transcription stays session-only.

Also drop the "read by AI" line from the detail footer.
2026-07-19 10:01:06 +02:00
Marc-André Bombeck e4f5aa0b09 fix(ui): drop the redundant Today briefing link, align the sheet close control
The Today hero carried a read-the-full-briefing link to /insights while
the health-score ring above it already opened the same page — two doors
to one room in a single card. The ring stays; the link and its six
locale strings are gone.

The document sheet's close control sat at the plain p-4 right edge, but
an icon button carries its own inner inset, so the glyph landed ~28px
from the frame against a title starting at 16px. Trim the right padding
by that inset when a consumer renders its own trailing control, leaving
the tap target reaching the frame.

Also rewrites the v1.30.15-v1.30.31 changelog entries: they had drifted
into summary lines, internal vocabulary and reasoning asides that do not
belong in a release note.
2026-07-19 09:46:57 +02:00
Marc-André Bombeck cbb9528cb2 test(daily): cover the due-dose rail item and the briefing grounding disposal 2026-07-19 09:14:09 +02:00
Marc-André Bombeck f8bb8f4b9b fix(daily): surface a due dose on Today and keep the last grounded briefing
The Today hero stayed on its all-clear line while a dose was actually due:
the dose-window item only fired once the slot was already overdue, so a
dose the user could take right now produced no rail item at all. Emit the
item from the moment the dose becomes takeable — the on-time window's lead
plus the early grace, the same instant logging it starts crediting the
slot — and give the two faces distinct wording and status: a due dose reads
calm and informational, an overdue one keeps the warning. A settled day
still emits nothing, and a cached block whose anchor has since passed keeps
rendering neither face. Push reminders are untouched; they still fire only
on overdue.

The briefing grounding gate discarded the whole block over one number it
could not verify, and it could not verify plenty of legitimate ones: the
allow-set was a hand-maintained list of feature blocks that had already
drifted from the prompt three times, and still omitted medication
compliance, the context counters, the correlation coefficients, every
coverage object and the comparison snapshot — all of which the prompt hands
the model verbatim. Derive the allow-set by walking the prompt payload
instead, so grounded means exactly what it says: a figure the server gave
the model. A fabricated number still matches nothing.

Fix the disposal to match. A content-failed retry left a hole while a
transport-failed one fell back to the previous cached briefing, though in
both cases the rejected text is discarded and the previous briefing passed
this same gate when it was written. Fall back on both paths, and only
report the briefing as withheld when there is genuinely nothing to show.
The on-demand route had no fallback at all; it now mirrors the background
generator that writes the same cached payload.
2026-07-19 09:11:57 +02:00
Marc-André Bombeck d4c4e5d08b Merge branch 'worktree-agent-a9a623139a6af16d5' into release/v1.30.30 2026-07-19 06:58:47 +02:00