A migration container starts empty, so applying 0281 there proves the
statements parse and nothing else — the backfill's WHERE matches no rows and a
broken expression passes green. Every self-hoster runs it against rows that do
carry a leg, and those are the only rows it exists for.
Seed the pre-migration shape, run the migration's own UPDATE read out of the
committed file, and assert an attributed row comes out still knowing which leg
broke, an unattributed one is left alone, and a second run does not nest the
array.
The batch route has accepted a `syncTrigger` since v1.32.8 and put it on the
wide event, where no human reads it. Half of what was promised got built: the
value is written and appears in three places in the codebase, none of which is
a screen anyone looks at.
Persist it on the account, alongside the instant of the most recent batch that
arrived without the app being opened, and publish both from
`GET /api/integrations/healthkit`. The card now names how the last sync arrived
and when data last came in on its own. `healthKitLastSyncedAt` alone reads
identically whether the phone delivers by itself or only while someone has the
app open, which is why "is background delivery working" could not be settled
from either side.
A batch that declares no trigger writes null rather than leaving the previous
one standing — the field describes this sync, and an older client's silence is
not evidence about it. A foreground batch leaves the background timestamp
alone; overwriting it would erase the only answer the card has.
Also here: WHOOP, Fitbit and Google Health join the workout-freshness table.
All three write workouts, and without an entry their cards could report every
measurement type fresh while the workout leg had been silent for weeks. And the
status pill drops a generic `aria-label` that replaced its own accessible name,
so a screen reader announced the same words for a healthy connection as for a
broken one.
`failing_leg` was one slot, so two legs of the same provider failing in
overlapping windows overwrote each other. The row remembered whichever failed
last, and that leg recovering then cleared an error the other one was still
causing — the ledger forgot a live failure and painted the card green over it.
The slot becomes a set. A failure adds its leg, a success removes its own, and
the error state, the per-kind buckets and the streak anchor clear only once the
set is empty. The array is ordered oldest failure first, so the last element is
the leg that wrote the message `last_error` holds; when that leg recovers while
others are still failing, the message is cleared rather than left describing a
leg that is now healthy.
An unattributed failure adds nothing and removes nothing. A single-leg provider
therefore behaves exactly as before, and a multi-leg provider no longer loses
what its other legs already reported.
The column never reached a client — it is absent from the status serialiser and
from the OpenAPI contract — so it is replaced rather than shimmed.
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.
`apns-collapse-id` is a replace directive: a push carrying an id
supersedes the earlier undelivered push with the same id. Every
medication reminder carried the literal string MEDICATION_REMINDER, so
three medications due at 08:00 arrived on the phone as one notification
naming one of them. The user takes that one and misses the others with
nothing on screen to suggest anything was dropped.
Key the collapse on the per-slot tag the Web Push sender already
coalesces on, falling back to the event type when a payload carries none.
Grouping stays on `threadId`, which is what grouping is for. Discreet
mode keeps the generic id — no record-identifying string on the lock
screen — and a tag over Apple's 64-byte ceiling falls back rather than
costing the send.
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.
`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.
The restore resolves a mood entry's rated factors by key and asks only for the
seeded catalogue. A tag the user created is not in it, so the lookup came up
short and the whole restore threw 'Unknown mood factor keys'. The file was
complete; it simply could not be read back.
That is the same cause as the cycle symptoms two commits ago, with the
opposite ending. There, an unresolvable key was filtered out and the loss was
silent. Here it stopped everything. One shape of missing definition, two
answers, neither of them the right one.
The account's own tag definitions travel now, get written before the factors
resolve, and the lookup accepts the seeded catalogue or the owner's own. The
seeded rows stay out of the file for the same reason as before: a restore must
not rewrite another instance's reference data.
Four more test mocks needed the new delegate, which is again the compiler and
the suite naming the call sites rather than me hunting for them.
The export has written nutrient day totals since v1.29. The canonical schema
never declared the field, so `.passthrough()` carried it through parsing and
no reader ever looked at it: every water and vitamin total in the file was
discarded on restore, and the restore reported success.
Declared in the contract, cleared and re-created beside its siblings, and
counted in both places that count — what the file holds and what the restore
replaced. A count that stops at the domains someone remembered is how this
stayed quiet.
restoreCycleData was loud about one missing reference and silent about
another, three lines apart. An unknown cycle reference threw; an unknown
symptom key was filtered out of the list, so the day-log came back with one of
its symptoms quietly gone and the restore reported success.
The cause was upstream: the seeded catalogue is reference data every instance
has, but a symptom the user created lives only in their account and was never
carried. The key had nothing to resolve against, and the filter turned that
into silence.
Both ends now. The account's own definitions travel with the backup, the
seeded rows deliberately do not — shipping those would let one instance's
restore rewrite another's reference data. And the resolve step throws on an
unresolvable key rather than filtering it, so the same function answers the
same question the same way twice.
Widening the builder's client type made four export tests fail on a missing
delegate, which is the sweep working: those are the call sites, named by the
compiler instead of found by reading.
The mirror of the wipe plan, for the same reason and against a worse failure:
a list that was right when written and stops being right every time a model is
added. On the delete side that leaves data behind; here it means data does not
come back, and nothing says so until someone is restoring a backup because
something already went wrong.
The list is longer than the wipe list by sixteen models, and that gap is the
point. A wipe may lean on the database — deleting a medication cascades to its
schedules, dose changes and inventory events — but a restore has no cascade,
so every child has to be named here.
Every exclusion carries its reason in prose, and the guard enforces a length
floor on it. That floor is not pedantry: the first draft of this file said
'Same as WithingsConnection' four times, which is a pointer rather than a
reason, and a pointer silently inherits whatever the thing it points at
becomes. Writing the decision where it is made is the only way it stays
reviewable.
The guard found two things in its own author's work on the first run: a model
with no verdict at all, and a reason too thin to be one.
The restore side of the guard — that every carried model also has a restore
branch — lands with the payload work it will police.
A formatter with no timeZone follows the browser, which is a different
question from the one this app answers. Four surfaces had drifted apart that
way before anyone noticed, and one of them printed a shared report a day off
from the page it came from.
The admin host chart went with them rather than onto the allowlist. Server
samples are real instants, and an operator reading their own instance should
not find a spike at 14:20 here and 15:20 two screens away.
Two deliberate limits, both written into the test. It does not match
toLocaleString, which in this tree is overwhelmingly a number formatter, and
burying four real hits under every thousands separator is how a guard gets
silenced. And it cannot tell a Date built from calendar arithmetic — a month
label, a weekday header, no instant behind it — from one parsed out of stored
data, so those four sites carry a written reason instead. The value is the
ratchet: a new bare formatter has to be argued for before it ships.
The bucketer hard-coded Europe/Berlin for day, week and month boundaries, so
a reading taken on the last evening of a month landed in the wrong month's
bar for everyone who does not live there. Two smaller siblings did the same
thing: the Today chip asked the browser for a time instead of the profile,
and the score card's tooltip formatter carried a locale but no timezone.
The boundary timezone is a required option rather than a defaulted one. A
default would have left any caller that forgets to pass one still wrong and
still silent; required makes the compiler name them, which is how the three
call sites and the eight test sites in this diff were found rather than
guessed at. The tests were always asserting Berlin's calendar without saying
so, and now they say so.
Two fixture mistakes worth recording, both caught by running the tests rather
than trusting them: summer time is already in force in Berlin on 31 March, so
the first instant chosen was April on both sides; and 31 March 2025 falls in
the same ISO week as 1 April, so the week case needed its own instant where
one side is still Sunday.
The page at c/[token] resolved the owner's timezone; the PDF download beside
it resolved nothing and fell through to the display default. Same link, same
reading, two dates — and the one a practice files is the one that was wrong.
Resolved in the shared download gate rather than in the route, so the PDF and
the FHIR bundle cannot drift apart the way the page and the PDF did. The
owner's clock preference travels with it for the same reason.
The fixture is deliberately west of UTC: Berlin is the fallback, so a Berlin
fixture cannot tell a resolved timezone from a forgotten one.
The rotation-coverage guard greps the script for a quoted column name.
Listing the new column in the header comment satisfied that grep without
the script rotating anything, which made the guard pass on a script that
would have left the column behind. The comment now names it unquoted and
says why.
Names LiteLLM, OpenRouter and vLLM, states what it does not share with
the OpenAI provider and why, and keeps the Local provider's own section
intact — it still speaks the same wire and remains the right pick for a
model on your own machine.
The properties this provider has to keep: it resolves against the base
URL that was saved for it, the pinned OpenAI arm cannot see that URL and
still posts to api.openai.com, and the OpenAI key never travels to the
gateway. Each is asserted at the wire, not on internals.
The SSRF floor is checked through the same alt-notation classes the
Local provider is checked against, and the pinned tags are shown to keep
requiring a public host even when the operator opened every private one.
The AI settings connect-src is pinned to what it already allowed: the
gateway is called server-side, so no CSP change is needed, and a future
change that needs one will turn this red.
Its own form with its own three fields, its own entry in the provider
dropdown and in the fallback-chain editor, and copy in six locales that
says plainly where the OpenAI key does not go.
The chain editor offers the entry but the default chain does not carry
it: it resolves only against a base URL somebody saved.
The read endpoint reports the gateway's base URL and model, and the
bearer as presence only. OpenAPI regenerated.
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.
Three dedicated User columns — base URL, optional bearer, model — for a
provider whose endpoint the user chooses. They are separate from the
OpenAI columns on purpose: the OpenAI arm pins api.openai.com, so it must
share no column with a user-supplied host, and a gateway must never
receive the key issued for OpenAI.
The bearer joins the encrypted-column registry and the rotation script;
the three columns join the account-wipe reset set.
Four specs navigated and then read the DOM once, with nothing in between
that waits for anything but the network. An idle network says nothing about
whether React has rendered, so the read can sample a tree that is still
mounting and the assertion then blames the application for a race inside the
test. Two such reads went red on consecutive CI runs, on different specs.
The settings over-scroll guard also slept a fixed 500 ms, which is the wrong
shape twice over: too long when the page is ready, too short on a loaded
runner, and it never says what it is waiting for.
The structural guard runs in the unit suite rather than in Playwright,
because e2e runs at PR-CI only and flaky counts as failed there — one
unguarded read otherwise costs a whole pipeline before anyone sees it. It
refuses to count a wait whose rejection is thrown away, since that reads
like a guarantee and is not one, and would leave the check defeatable by
appending a catch. The one deliberate swallow is on the allowlist with its
reason: a genuine regression there still fails at the width assertion below
it, which reports the measured number instead of a timeout message.
A release asset is served with content-disposition attachment and nosniff, so
an image embedded from that URL renders as a broken image in the notes. The
raw path serves it as an image, which is the only thing that actually works.
A chart shows the shape and hides the numbers. An appointment needs the
numbers, and so does anyone moving readings into a spreadsheet.
The table reads the same expression the chart hands Recharts, after the range
slice, the week/month bucketing and the display scale, and it formats through
the chart's own value and date formatters. Nothing is re-derived, so a cell and
the tooltip cannot print two numbers for one point.
Collapsed by default, with the point count in the trigger so the size reads
while it is closed; the region scrolls in its own box rather than growing the
page, and the table primitive already scrolls a wide row set sideways. The
caption names the grain, because a weekly mean labelled as a daily reading
would be a lie, and the grain now has one source shared with the bucketing.
Derived overlays get no column: a moving average is drawn from the points, not
measured. An empty window renders no table at all, since the chart's own empty
state already says there is nothing there.
Opt-in per mount. The metric sub-pages take it; the dashboard tiles do not,
because a table under every tile buries the dashboard it summarises.
Refs #636
The tag crosstab already ran a with-versus-without Welch test per structured
tag, with a D to D+1 pairing mode whose stated example was an evening tag
against next-day recovery. It only ever had one channel at that lag, and a
recovery score is a wearable composite plenty of accounts never record. The
rated-factor board next to it has tested next-day resting heart rate and HRV
since v1.14.0, but a rated factor is a continuous score, so a binary tag never
reached either vital.
Add both to the tag map. Same engine, same per-side day floors, same
Benjamini-Hochberg family, same card. The fetch filter derives from the map, so
the channels come along without a second query.
The delta read-out goes neutral to match the rated-factor board: with resting
heart rate on the board, a positive delta is worse on one row and better on the
next, and a green plus sign would claim a verdict the association cannot carry.
Refs #616
The preamble already says once what the two states mean and that neither is
absence; repeating it per row would have doubled a manifest that rides every
turn.
The unit suite mocks Prisma, so it proves the discriminant and nothing about the
query underneath it — a wrong column would have left every test green and every
account answering no_data_unconfirmed in production. Seeds the reported case and
the four other shapes: an empty record, another account's rows, tombstoned rows,
and a history that mixes units.
Rounds the out-of-window mean to one decimal, like every other mean the snapshot
surfaces, and annotates a failed manifest probe rather than letting it degrade in
silence.
The mutation check found both holes: an addendum test that only looked for the
new reason codes passed with the old declare-absence-and-move-on sentence still
in place, and a block-failure check whose window was wide enough to see the
neighbouring block's annotation.
The fixture is the report: a fortnight of glucose readings from April 2024 on an
account that displays them everywhere else. The clock is pinned so it cannot age
out of the window it was written to sit outside of.
Covers the four states an empty window read can resolve to, that a never-recorded
metric and a historical one differ in the SAME run, that a mean is withheld
across mixed units rather than fabricated, that the manifest advertises the range
instead of "absent", that both instructions branch on the reason code, and that a
miss carrying figures reaches the prose verifier's authoritative set. Adds the
medication-compliance page to the failed-read sweep and pins the narration
blocks' failure annotation.
A Coach retrieval tool that read the window and found nothing returned
`reason: "no_data"` — the same code for two situations that have nothing in
common: the record holds nothing for that metric, and the record holds a
substantial history for it, all of it older than the window that was searched.
Nothing downstream could tell them apart, so the Coach said the honest-sounding
sentence for the wrong case, and then, having been told there was nothing there,
changed the subject. Someone with a year of imported glucose readings was told
the readings did not exist, on the same account whose glucose page was showing
them.
The window cap stays. It exists because a multi-year history does not fit a
prompt budget, and widening it would only move the same failure. What was
missing is the ability to say a different true sentence.
- `tools/availability.ts` — one grouped aggregate per miss, no rows
materialised, resolving four states: `no_data` (probe confirmed empty),
`outside_window` (rows exist, older than the searched window; carries the
count, the date range in the user's own timezone, and a bounded per-series
mean/min/max), `unavailable_in_scope` (rows exist inside the window and the
domain still produced none — a module is off), and `no_data_unconfirmed` (the
probe itself failed; absence was never established, so it is not asserted).
- Every empty read in the executor routes through it. A bare `no_data` can no
longer be returned without the record having been checked.
- The DATA INVENTORY carries the same distinction, so a domain the window
cannot reach is advertised with what it holds instead of as absent — the model
no longer skips the fetch, and a vague follow-up keeps the subject.
- Both instructions that told the model to report absence and move on now
branch on the reason code, and forbid answering about one metric by reporting
another.
- The out-of-window aggregate joins the authoritative set for the prose
verifier, so the sentence the fix exists to make sayable is not elided.
- A cross-cutting narration block that throws is now counted rather than
vanishing into a `null` that looks like honest absence.
- The medication-compliance page tells a failed read apart from an account with
no medications, instead of inviting someone to re-enter what it already holds.
Refs #648
`Partial<typeof item>` inferred the literal types off the base fixture, so an
override could not swap the container kind or null a date. The fixture takes
an explicit row shape instead.
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.
The comparison now includes `updated_at`, so a second application that
rewrites an already-correct row is visible even when the value it writes is
identical. Without it the assertion passed whether or not the guard that
skips untouched rows was there.
The first pass of migration 0276 restored the rows the in-use clock had
already flipped, but every clock-less container still IN_USE carried the same
clock-derived `expires_at`, most of them with the deadline still ahead. The
daily expire scan would have worked through them over the following weeks and
reproduced the whole defect one row at a time.
A clock-less container's `expires_at` is now set from its printed expiry —
usually nothing, which is how it stays out of that scan. Pens and ampoules
keep theirs; their clock is real.
Submitting the correction dialog is out of reach for the SSR component
tests, so the two behaviours that only show up on submit are pinned against
the module source: a date rides along only when it changed, and a cleared
field sends null rather than an empty string. The route side of the same
contract is covered by a real request test.
The row printed the opening date and the printed expiry with nothing between
them. It carries the same middle dot the unit figures use.
The docblock now says out loud what the enum cannot distinguish: AMPOULE
covers both a punctured multi-dose vial, where the post-opening window is
real, and a carton of single-use glass ampoules, where it is not. The kind is
never auto-selected, so picking it is a statement about an opened container,
and the opening date is editable when it turns out not to be.
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.
Migration 0276 reverses the rows that reached EXPIRED only through the
post-opening clock on a container that never had one. It moves a row back to
IN_USE when an opening date is recorded and ACTIVE when none is, and sets
`expires_at` from the printed expiry — which for these rows is usually
nothing at all, because they recorded none.
The predicate is exact rather than approximate: nothing but the printed
expiry and the in-use clock can write EXPIRED, so a clock-less container
whose printed expiry is absent or still ahead can only have got there through
the clock. Pens and ampoules keep their EXPIRED, so does anything whose
printed date genuinely lapsed, and so does a drained container. No date is
invented and no printed expiry is fabricated.
The integration test seeds the before-state, applies the migration SQL from
disk, and asserts both what moves and what stays — the testcontainer starts
empty, so the migration running at boot proves nothing on its own.
A supply container went EXPIRED 30 days after its first use no matter what
kind of container it was. That window exists because puncturing an injection
pen exposes its contents and the product information caps how long the rest
stays usable; a blister of tablets has no such limit, every tablet sits in
its own sealed cavity, and pressing the first one out says nothing about the
rest. The state machine never read `containerType`, so a tablet pack opened
five weeks ago had its remaining stock written off.
The clock now keys off the container kind. PEN and AMPOULE carry it: both are
a single sealed reservoir that the first dose breaches. BLISTER, INHALER,
BOTTLE and OTHER do not: their units stay individually sealed, or the
arrangement is simply unknown, and a fixed 30 days would be a number nobody
entered. The printed expiry still applies to every kind, so an unknown
container is not an unbounded one.
`computeExpiresAt` takes the kind too, so a clock-less container with no
printed date now persists no `expiresAt` at all and the daily expire scan
never sees it.
The per-item PATCH gains a way back out: `markAsFirstUseAt` accepts null and
clears the opening date, which is the correction a container needs after the
intake hook auto-opened it from a back-dated dose.
Also drops `daysRemainingInUse`. It had no caller anywhere in the tree and
its docblock described a chip that does not exist.