Commit Graph
316 Commits
Author SHA1 Message Date
Marc-André Bombeck c3125078b9 chore: resolve sharing lint checks
Keep attribution and provider-work tests lint-clean.
2026-08-06 02:13:50 +02:00
Marc-André Bombeck f5ca810643 chore: format sharing authority controls
Apply repository formatting to provider and import coverage.
2026-08-06 02:05:24 +02:00
Marc-André Bombeck 620817b10b fix: retain import actor attribution
Persist record and actor identities through job completion.\nWrite one bounded transactional completion audit per job.
2026-08-06 02:00:35 +02:00
Marc-André Bombeck e209db24f4 fix: block delegated provider egress
Carry bounded work authority through queues and revalidate it at dispatch.\nResolve shared records without personal credentials.
2026-08-06 01:49:33 +02:00
Marc-André Bombeck 42587d52bd Merge main 2026-08-02 05:54:53 +02:00
Marc-André Bombeck 86f0e5d2cb refactor(dashboard): remove the orphaned verdict resolver
The nine-rung verdict ladder was written for the opt-in dashboard hero.
That hero was retired when the digest-driven Today rail became the single
promoted hero, and its i18n copy was dropped across every locale in the
same change, but the resolver stayed behind. Since then it has had no
caller outside its own tests: nothing renders it and no string exists for
any of its variants.

Every rung it computed already has a live owner. Dose states ride the
digest's dose_window item, an elevated-at-rest window rides tension_window,
the score delta rides the digest score block the Today hero paints its chip
from, weight drift and sleep deficit and a silent stretch are the coach
nudge cron's own triggers, and a crisis blood-pressure reading is the
safety-floor engine's. Wiring a renderer back on would rebuild the second
hero the retirement removed.

- Delete the resolver, its ladder suite, and the client-safety import walk
  that pinned its bundle graph.
- Fold the two shared nudge thresholds back into the cron. The leaf module
  existed only so a client component could read them without dragging the
  server graph along; there is no such component now.
- Repoint the score-config guard off the verdict onto the digest fields the
  Today hero actually reads, so it still fails on the assembly it was
  written for.
- Repoint the clinical-floors notes off the retired surface.
2026-08-02 02:32:24 +02:00
Marc-André Bombeck 6ac3a61829 Take the writes off three read routes
A GET must not have side effects — api-handler.ts says so next to
READ_HTTP_METHODS, where an MCP-audience token is admitted on the safe
methods precisely because they are assumed to write nothing. Three routes
broke that: the sync handshake bumped User.lastSyncedAt, the achievements
read inserted unlock rows, and the cycle calendar fired a discarded promise
at the forecast cache so a failed write left no trace anywhere.

Each write moves to the place that causes it.

The sync checkpoint is now stamped by the batch ingest endpoints for
measurements, mood and intakes. The handshake reports it. That also fixes
the watermark: the checkpoint used to advance past a window the client had
not drained, and a client trusting it would skip that window. It can only
trail a real sync now.

The unlock rows move to a nightly achievement-unlock-sweep. The badge grid
never needed them — unlocked comes from the live metrics and the completion
date is derived — so the payload is unchanged; the rows pin that date and
carry it into the backup, and the sweep reaches accounts that never open
the page.

The cycle forecast moves to a nightly cycle-prediction-refresh. The
CyclePrediction row is not a cache of a read: it is the index the reminder
cron scans, so which reminders an account received depended on whether
somebody had opened the calendar. persistPredictionCache no longer
swallows its errors either — the job reports them.
2026-08-02 01:21:03 +02:00
Marc-André Bombeck 8488092d48 test(medications): prove a dose taken before midnight really stops reminding
The overnight reminder tests handed the check every intake row the
fixture held, whatever date range it had actually asked for. That left
the suppression assertion unable to fail. Narrow the read back to the
day the check happens to run in and the 23:45 dose logged at 23:50 drops
straight out of it, yet the test stayed green. The fake now honours the
scheduledFor range it is given, so the assertion answers the question a
person really asks: does it stop pestering me about a dose I already
took.

Two cases were missing at the other end of the same boundary. A dose
genuinely missed at 23:45 still has to escalate in the small hours, and
the missed-dose row it leaves behind has to name the evening it belongs
to rather than the morning the check runs in. Both are pinned now, next
to the case where the dose was taken and no missed row may be written at
all.
2026-07-31 07:36:17 +02:00
Marc-André Bombeck 271f0ed28a fix(medications): make import replay results honest 2026-07-29 16:11:45 +02:00
Marc-André Bombeck 5b695f43ea fix(analytics): restore device-priority sleep selection 2026-07-29 16:08:41 +02:00
Marc-André Bombeck fb02cf89b1 fix(jobs): reconcile durable fact inventory 2026-07-29 15:59:27 +02:00
Marc-André Bombeck fb9ae2ac61 fix(coach): keep stateless turns grounded 2026-07-29 15:56:19 +02:00
Marc-André Bombeck 8db21ab718 fix(jobs): persist bounded success outcomes 2026-07-29 15:50:42 +02:00
Marc-André Bombeck c3ffc43335 refactor(01-22): reuse Google cohort concurrency limit
- preserve the shared Google Health poll fan-out width
2026-07-29 14:53:22 +02:00
Marc-André Bombeck 6a3861122c feat(01-22): publish truthful cohort outcomes
- fold complete partial failed parked and skipped user verdicts
- bound serialized job facts to a privacy-safe scalar allowlist
- preserve provider retry isolation and reminder queue completion
2026-07-29 14:50:30 +02:00
Marc-André Bombeck addd6b7000 fix(01-19): avoid phantom ECG failure counts
- increment archive-level failures only for discovered ECG artifacts
2026-07-29 14:47:47 +02:00
Marc-André Bombeck a686c7200b feat(01-19): persist Apple Health ECG recordings
- encrypt and upsert stable content-addressed ECG recordings transactionally
- keep XML imports successful while ECG members fail with scalar counters
2026-07-29 14:45:40 +02:00
Marc-André Bombeck 0ae9165aae refactor(01-18): format occurrence identity changes
- apply repository formatting to reminder and scheduling updates
- preserve verified occurrence behavior without semantic changes
2026-07-29 14:36:21 +02:00
Marc-André Bombeck c0af97ce46 fix(01-18): preserve reminder occurrence across midnight
- select active canonical recurrence slots instead of rebuilding from tick day
- scope intake suppression and dedup metadata to the occurrence local date
2026-07-29 14:30:23 +02:00
Marc-André Bombeck 690c4aeb5a test(01-14): add failing truthful cohort contracts
- distinguish complete partial failed parked skipped and clean-zero outcomes
- require bounded privacy-safe scalar serialization and retry codes
2026-07-29 14:26:26 +02:00
Marc-André Bombeck 958b0e10d9 fix(01-11): keep reminder fixture type-safe
- mutate the inferred schedule fixture without widening nullable fields
- preserve the cross-midnight RED expectation unchanged
2026-07-29 14:24:00 +02:00
Marc-André Bombeck 6cc3de8248 test(01-12): add failing Apple ECG import contracts
- pin bounded path-safe archive and parser behavior
- require encrypted idempotent fail-soft persistence and WHOOP regressions
2026-07-29 14:20:56 +02:00
Marc-André Bombeck 81893ac972 test(01-11): reproduce cross-midnight reminder identity bug
- pin prior-day occurrence lookup across timezone and DST boundaries
- prove real worker dedup and distinct next-dose behavior with PostgreSQL
2026-07-29 14:15:48 +02:00
Marc-André Bombeck 2bab06e323 feat(medications): resolve each dose slot's own window duration 2026-07-28 21:26:53 +02:00
Marc-André Bombeck 7b8fb98e48 feat(analytics): replace health score with reference composite 2026-07-28 21:12:05 +02:00
Marc-André Bombeck 3aff2c16db fix(insights): scope generation guard to prompt fields, clear cache on restore 2026-07-28 21:11:43 +02:00
Marc-André Bombeck c91558f18f feat(analytics): replace health score with reference composite 2026-07-28 21:10:15 +02:00
Marc-André Bombeck 87544af2cd Fix medication reminder slot attribution 2026-07-28 21:06:07 +02:00
Marc-André Bombeck 8ece6f39cc fix(status): expose background failures 2026-07-28 14:00:44 +02:00
Mathew Sims 08e535a0d9 fix(reminders): resolve each dose slot's own window duration, not the whole schedule's span
The 15-minute reminder tick computed windowDuration ONCE per schedule
as windowEnd - windowStart, then applied that SAME span to every
timeOfDay slot in the schedule. For a single-dose schedule that span
genuinely is the one slot's window, but for a multi-dose schedule
(e.g. windowStart=08:00/windowEnd=18:00 with timesOfDay=[08:00,18:00])
it silently gave the 08:00 slot a 600-minute grace before even
entering ORANGE phase — ten hours nobody configured — while an
identically-intended single-dose schedule with a narrow legacy span
(08:00-09:00) correctly escalated on schedule but then had only a
30-minute radius for a logged-but-late dose to suppress its reminder.
Same misapplied field, opposite-looking symptoms: one medication
silently never reminded, the other kept escalating despite being
logged as taken.

resolveSlotWindowDurationMinutes resolves each slot's duration
independently, checked in priority order: an explicit per-dose
doseWindows entry for that exact timeOfDay (the actual per-dose rule
set in the dose-window editor) > the schedule's reminderGraceMinutes
(documented for exactly this purpose but never actually read by the
reminder tick until now — the write path already reads it via
graceToleranceMs) > the same default an unconfigured "point" dose
gets everywhere else in the app, bounded by the gap to the nearest
sibling slot so two doses can never share a window. A single-dose
schedule with neither override keeps the exact legacy windowEnd -
windowStart behaviour, unchanged, for backward compatibility with
schedules that predate the per-dose doseWindows system.

The core property this restores: a dose's resolved window never
depends on how many sibling doses share its schedule. Two
identically-configured doses now resolve identically regardless of
whether one of them happens to have a second dose elsewhere in the
day.

Full suite (18k+ tests) passes with no regressions; 13 new tests
cover both real-world cases that surfaced this (a single 08:00-09:00
schedule and a twice-daily 08:00/18:00 one) plus the sibling-
independence property directly.
2026-07-28 12:55:34 +01:00
Marc-André Bombeck a689329e79 feat(dashboard): configure Today highlights 2026-07-28 13:22:08 +02:00
Marc-André Bombeck dddd2164b1 test(withings): teach the fallback mock the shape its function now returns
The sync function was widened to answer with what it wrote rather than a bare
count. This mock still resolved to a number, so the handler destructured
`imported` off it, got undefined, and reported `measurements_imported: NaN`.

Nothing in the type system could see it: `vi.fn().mockResolvedValue(0)` carries
no relation to the real signature, so a widened contract drifts past every mock
in the tree unnoticed. This was the only sibling; the class is worth a sweep of
its own.
2026-07-28 00:07:53 +02:00
Marc-André Bombeck 602ca36ee2 merge: s3-written into release/v1.34.0 2026-07-27 23:41:33 +02:00
Marc-André Bombeck 1f47db5955 docs(jobs): name the binding contract the registrars actually use 2026-07-27 23:29:20 +02:00
Marc-André Bombeck ae294573ef test(jobs): follow the queue guards onto the new binding
Thirty-three suites read a registrar as source text and matched
`boss.work` next to a queue constant. They now match `createAndWork`,
and the seven schedule tuples that grew a `cronIsTheRetry` third element
assert that element rather than accepting any arity — a guard that
accepts more than it did before is a guard that notices less.

Two negative assertions were tightened while they were open: the
"has no cron" checks on the arrival and workout-insight queues ended at
a literal `]`, so a cron added WITH send options would have slipped past.

The behavioural repairs carry the real change. Handlers that resolved to
undefined now resolve to their outcome, so the assertions name the counts
instead of asserting nothing. The reconcile tick's test said it swallows
a failure instead of throwing; it reports a failed outcome now, and the
test says that, with the comment pointing at the retryLimit that carries
the old anti-retry-loop concern.
2026-07-27 23:23:53 +02:00
Marc-André Bombeck f0a804a812 test(jobs): pin the outcome spine and the binding rule
runJob's claim is behavioural, not typed: an ok:false outcome has to
reach pg-boss as a failed job, reported once, with the handler's own
facts and the thrown error's stack rather than runJob's frame. That is
what the retention cleanups now rely on, so it is pinned.

The rule's tests carry its blind spots in the valid list, where a reader
will trip over them: an aliased receiver is not detected, and neither is
a handler that returns ok:true after swallowing.
2026-07-27 23:11:38 +02:00
Marc-André Bombeck 0aa5cec17d feat(jobs): bind every queue through createAndWork and ban the bare call
All 104 bindings across the five registrars now go through createAndWork,
whose handler type is (jobs) => Promise<JobOutcome>. The forty-two inline
handlers return what they did. A handler that falls off the end no longer
compiles, and the healthlog/job-handler-outcome rule refuses a bare
boss.work( anywhere under src/lib/jobs except the one module that owns
the wrapper. The rule's header states its own limit: it catches the
omission, not a handler that claims ok:true after a swallow.

Retry policy, reviewed queue by queue because the rethrow changes what
runs, not only what is visible:

  - Twenty-one cron passes carry cronIsTheRetry (retryLimit 0). They are
    the retention DELETEs, the OAuth-state prunes, the hourly maintenance
    sweeps and the nightly per-sample drain — every one of them a
    statement that will fail identically a second later. pg-boss defaults
    to two retries with no delay, so converting them without this would
    have run the same doomed statement three times a night. The next tick
    is the retry and it arrives with the whole gap as backoff.
  - The policy rides on the schedule, not on createQueue: create_queue()
    ends in ON CONFLICT DO NOTHING, so a queue-level default would be
    inert on every already-running instance.
  - Everything else keeps its retries deliberately. A TLS probe, an
    off-host upload, a restore drill and a provider poll all fail
    transiently, and the reminder ticks only fail at job level when the
    pass itself could not run.
2026-07-27 23:08:50 +02:00
Marc-André Bombeck 67aa3338da feat(jobs): return an outcome from every named queue handler
Sixty-two handler functions across twenty-four modules now resolve to a
JobOutcome instead of undefined. The counts they already wrote to their
wide event ride out in `did`, so a run that deleted nothing and a run
that could not run are no longer the same shape.

Three rules decided each conversion:

  - a catch that swallowed becomes jobFailed, keeping its warning line;
  - a catch that already rethrew keeps its throw, because the throw
    already fails the job and rewrapping it buries the stack;
  - a fan-out pass is judged on the pass. One account's revoked grant
    belongs on that account's integration ledger, not in a queue-wide
    failure that retries the whole cohort, so per-leg failures ride out
    as a users_failed count — visible for the first time.

The retention cleanups are the behaviour change worth naming: every one
of them used to warn and return, so a purge that had not run for weeks
looked like a purge with nothing to do. They fail now. Their registrar
sets retryLimit accordingly in the commit that converts the bindings.

JobFailure carries the thrown error's stack; without it every one of the
101 queues would report the same runJob frame.
2026-07-27 22:56:59 +02:00
Marc-André Bombeck 530f78dcf5 fix(sync): answer with what a run wrote, not that it completed
Six provider syncs returned a bare count, which narrowed their failures away:
a Nightscout window in which every SGV row was refused by a constraint
returned the same 0 as a quiet night, and an Oura or Polar run whose
collections all failed returned 0 too. The routes then answered 200 with a
count and the settings card drew a green tick over it.

Every sync now reports what it imported AND whether any part of it did not
land, and the route resolves the outcome server-side so the web card and the
native client cannot disagree. Google Health keeps its 502 for a run that
failed and wrote nothing, but a run that failed after writing some resources
is now a partial rather than a discarded result. The labs OCR commit carries
the same resolved outcome.
2026-07-27 22:53:43 +02:00
Marc-André Bombeck 119af56f6f feat(jobs): derive job completion from a returned outcome
A pg-boss handler signalled success by falling off the end of a function.
Every binding in this tree resolved to undefined and nothing read the
value, so a handler that caught an error, logged a warning and returned
was indistinguishable from one that finished its work: pg-boss marked
both completed and the failed-job readers saw nothing.

JobOutcome makes the completion a value the handler has to construct.
runJob turns ok:false into a reported, rethrown failure so the queue's
retry policy applies. createAndWork is the one binding path; its handler
type is (jobs) => Promise<JobOutcome>, so a handler that returns nothing
no longer compiles.

The type cannot make a handler honest — jobDone() after a swallow still
compiles. It converts omission into commission, which is the smaller and
far more reviewable class.
2026-07-27 22:41:02 +02:00
Marc-André Bombeck 20824aa798 fix(documents): say that turning auto-read on reaches back over the vault
The switch was described three times as applying to what you upload
next. A genuine off-to-on flip also enqueues a pass over the documents
already stored without a summary, capped at 200 per flip — scanned
medical documents going to a third-party provider, from copy that reads
as forward-looking only.

Drop "newly uploaded" from the sub-label and give the disclosure the
sentence it was missing, including the cap. Six locales. The behaviour
is right and stays as it is; only the sentence changes.

A guard pins the number in the copy to MAX_ENQUEUES_PER_RUN, so moving
the cap without touching the copy fails.
2026-07-27 14:47:55 +02:00
Marc-André Bombeck 1c05bba56c docs(jobs): correct two migration citations and the import dedup contract
The retention-purge comments still cite migration 0277 for indexes that landed
in 0276 — the renumbering moved the directories and left the prose behind. The
schema is trustworthy because its comments are, so a wrong number is not
cosmetic.

The import worker's `already_recorded` reason claimed the skip covers a dose
recorded on any other intake surface. It does not: `source` is part of the
live-row unique, so a hand-logged dose and an imported one at the same slot are
different keys and both are written. Say what the mechanism actually does, and
say plainly that cross-surface dedup is not there.
2026-07-27 14:47:55 +02:00
Marc-André Bombeck 3088753a92 docs(reminders): drop the stale client-managed cross-reference
The module-gate branch cited the client-managed skip as its precedent for
advancing past a cycle. That skip no longer exists in this file, and the
reason the gate advances is its own: a disabled module has nothing left
to deliver.
2026-07-27 14:26:20 +02:00
Marc-André Bombeck c6b944b7fb fix(reminders): send a dose reminder once per slot, phase and day
The per-phase "already sent today" guard read the Telegram message
ledger, and that table is written only when a Telegram send succeeds. A
user on email, APNs or ntfy never had a row, so the 15-minute tick
re-derived the same overdue slot and sent it again on every pass — around
fifty mails for a single missed dose.

Anchor the guard in `push_attempts` instead, the ledger the daily
briefing and the illness escalation already use. Two properties are
deliberate: the anchor is stamped for every dispatched slot regardless of
what any channel returned, because keying it on a delivered push would
re-open the flood the moment a channel starts failing; and it is stamped
before the dispatch, so a crash costs one phase's notice rather than a
repeat every quarter hour.

The key carries the schedule id and the slot's real HH:mm alongside the
phase and the local date. Both are load-bearing: two legacy
single-window schedules on one medication share the empty first-class
time-of-day, so a coarser key would collapse the evening dose into the
morning one. The tests assert the other direction too — the next phase
still escalates, and the next local day starts clean.

The Telegram ledger keeps its message-tracking duties; it is no longer
the dedup key. Anchor rows carry a sentinel channel and stay out of the
operator's notification health and diagnostic reads, which count
deliveries, not decisions.
2026-07-27 14:26:20 +02:00
Marc-André Bombeck fcc4e661df fix(notifications): scope client-managed suppression to the APNs leg
`medication.clientManaged` and `measurementReminder.clientManaged` mean
the iOS client schedules the reminder locally, so the server should not
push it. That is what the OpenAPI description, the settings copy and the
iOS contract all say. What the crons did was skip the dispatch entirely,
so a user who flipped the switch also lost Telegram, ntfy, webhook,
email and Web Push — channels they may well read on a desktop while the
phone holding the local schedule sits in a drawer.

Move the gate into the dispatcher's channel loop, where it applies to
APNs and nothing else. The skip is not counted as an attempt, so
`dispatched` keeps meaning "something reached the user", and it stays
countable: the same wide-event keys the crons emitted are emitted here,
plus a `skipped` row in the push-attempt ledger.

The Vorsorge path is the sharper half. It suppressed every channel and
then advanced `nextDueAt`, which dropped the reminder out of the
preventive-care card and out of the daily digest: a reminder that never
reached anyone was recorded as handled. The advance now happens only
where it always belonged, after a dispatch that a channel actually
delivered. When nothing delivers, the reminder stays overdue and visible.
2026-07-27 14:26:20 +02:00
Marc-André Bombeck 0c7059804b feat(ai): resolve an OpenAI-compatible gateway as its own provider
The chain gains a seventh entry. It builds the OpenAI client against the
base URL the user saved, with the bearer they saved for it and the model
they named, and it reads none of the OpenAI columns — the pinned
api.openai.com arm stays pinned.

Host policy is the Local provider's, unchanged: a public host always, a
private one only where the operator allowlisted it, which is what a LAN
LiteLLM or vLLM needs. Egress keeps riding safeFetch.

The JSON-mode dialect the Local client learns per endpoint moves into a
shared module and now serves both clients, so a gateway that rejects
response_format is detected once and retried without it.

The entry stays out of the default chain: it can only resolve against a
base URL somebody configured.
2026-07-26 22:27:48 +02:00
Marc-André Bombeck 4be01a67f0 chore(release): fold in the coach discriminant, the dependency bumps, and the notes the audit found wrong 2026-07-26 18:57:21 +02:00
Marc-André Bombeck d91b7cb57a test(medications): cover the container kind on the import and refund paths
Hardcoding the container kind in the batch-import consumption path or in the
refund path left the whole suite green — neither had a scenario that read a
clock-less container. Both now do: a back-dated import chunk against a tablet
pack stays IN_USE with no deadline, and a refund that brings units back to a
long-opened pack reads IN_USE rather than expired. The pen counterpart of the
import case is pinned alongside.

The fake client's `updateMany` learns the `id: { in: [...] }` form the batch
claim uses; it only understood a single id before.

The no-runway arm of the low-stock evaluation now has its written-off count
asserted too.
2026-07-26 18:00:08 +02:00
Marc-André Bombeck a4ff11f10d fix(medications): surface and correct the dates that decide a container's state
The supply tab rendered a container's kind, its unit count and a state badge,
and nothing else. A row could read "Expired" with nothing on screen to say
which date caused it, and the correction dialog only ever offered the
remaining-unit count — so the opening date, which the intake consumption hook
writes without the user ever typing it, could not be taken back.

Each row now carries the two dates it has, and the correction dialog edits
both alongside the unit count. Each field rides along only when it actually
changed, so saving a stock correction never rewrites a date left untouched,
and clearing the opening date sends null, which the route reads as "this was
never opened" rather than "leave it alone". Beside the opening date the
helper text says whether the kind carries a 30-day window at all.

The headline stops contradicting itself. "0 of 0 doses remaining" next to
"+ 54 units expired" read as "nothing was ever recorded" beside "stock was
written off"; when the available pool is empty and only expired stock is
left, one line says so instead. The low-stock push follows: no usable supply
with written-off units on hand is a different errand from an empty pack, and
`expiredUnits` now reaches the payload, the notification metadata and the
wide event.
2026-07-26 18:00:07 +02:00
Marc-André Bombeck b627b92809 feat(medications): rule on each export status for what it actually is
The seven documented statuses are four kinds of thing, so they get four
rulings instead of one.

Taken and Skipped state a decision about a dose and each maps onto something
already recorded here — a row with a take time, and a row marked skipped. That
second mapping was checked rather than assumed: skipped is documented as a
deliberate skip and held apart from autoMissed, which is the marker for a dose
nobody ever acted on, and the compliance re-fold counts a skipped slot in its
own column and never as taken. So a deliberate skip arrives as one.

Not Interacted, Not Logged and Unspecified are absences rather than
statements. Written as taken they would invent a dose; written as skipped they
would invent a decision, because not answering a notification is not choosing
not to take something; written as pending they would put outstanding doses
from years ago in front of someone today. Snoozed describes what happened to a
reminder. Notification Not Sent is about the software. None of the five
becomes a row.

All five are refused, but each under its own name, because "the file says
nothing about this dose" and "this row is about a reminder" are different
things and a person acts on them differently. Three reasons where there was
one, each with its own sentence in six locales.

An inactive medication's history imports and the medication stays inactive.
Preserving what happened and putting a medication back on the active list are
two acts, and only the first belongs to an import.
2026-07-26 16:37:10 +02:00